Skip to content

Commit 68ee40f

Browse files
committed
Add modular prompt composition and dialog subsystem for AI reviewer
prompts/: per-prompt template.md + spec.yaml with Jinja loops over toggleable list items, shared fragments, PromptLibrary.render(overrides, note). dialog/: Dialog/Option/Answer schema, YAML loader, presenter interface with menu renderer and overrides resolver, optimization_strategy spec.
1 parent ee91a87 commit 68ee40f

14 files changed

Lines changed: 389 additions & 0 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from jumper_extension.adapters.ai_reviewer.dialog.loader import load_all, load_dialog
2+
from jumper_extension.adapters.ai_reviewer.dialog.models import Answer, Dialog, Option
3+
from jumper_extension.adapters.ai_reviewer.dialog.runner import (
4+
DefaultPresenter,
5+
DialogPresenter,
6+
render_menu,
7+
resolve_overrides,
8+
)
9+
10+
__all__ = [
11+
"Answer",
12+
"DefaultPresenter",
13+
"Dialog",
14+
"DialogPresenter",
15+
"Option",
16+
"load_all",
17+
"load_dialog",
18+
"render_menu",
19+
"resolve_overrides",
20+
]
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from pathlib import Path
2+
3+
import yaml
4+
5+
from jumper_extension.adapters.ai_reviewer.dialog.models import Dialog, Option
6+
7+
_SPECS_DIR = Path(__file__).parent / "specs"
8+
9+
10+
def load_dialog(dialog_id: str, specs_dir: Path = _SPECS_DIR) -> Dialog:
11+
return _build_dialog(yaml.safe_load((specs_dir / f"{dialog_id}.yaml").read_text()))
12+
13+
14+
def load_all(specs_dir: Path = _SPECS_DIR) -> dict[str, Dialog]:
15+
return {
16+
path.stem: _build_dialog(yaml.safe_load(path.read_text()))
17+
for path in sorted(specs_dir.glob("*.yaml"))
18+
}
19+
20+
21+
def _build_dialog(data: dict) -> Dialog:
22+
options = tuple(
23+
Option(
24+
id=entry["id"],
25+
label=entry["label"],
26+
effect=entry.get("effect") or {},
27+
description=entry.get("description", ""),
28+
default=bool(entry.get("default", False)),
29+
require_note=bool(entry.get("require_note", False)),
30+
)
31+
for entry in data["options"]
32+
)
33+
return Dialog(
34+
id=data["id"],
35+
header=data["header"],
36+
options=options,
37+
kind=data.get("kind", "single"),
38+
allow_note=bool(data.get("allow_note", False)),
39+
)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import dataclasses
2+
3+
4+
@dataclasses.dataclass(frozen=True)
5+
class Option:
6+
"""One selectable choice in a dialog.
7+
8+
``effect`` is an opaque payload interpreted by a domain handler (for the
9+
optimization dialogs it holds ``{"overrides": {id: enabled}}``), which keeps
10+
the dialog schema decoupled from whatever the choice controls.
11+
"""
12+
id: str
13+
label: str
14+
effect: dict = dataclasses.field(default_factory=dict)
15+
description: str = ""
16+
default: bool = False
17+
require_note: bool = False
18+
19+
20+
@dataclasses.dataclass(frozen=True)
21+
class Dialog:
22+
"""A single question with its options.
23+
24+
Identical whether loaded from ``specs/*.yaml`` (deterministic) or built by
25+
the agent at runtime (dynamic) - the presenter and handlers treat both the
26+
same way.
27+
"""
28+
id: str
29+
header: str
30+
options: tuple[Option, ...]
31+
kind: str = "single" # single | multi | text
32+
allow_note: bool = False
33+
34+
def default_option(self) -> Option:
35+
for option in self.options:
36+
if option.default:
37+
return option
38+
return self.options[0]
39+
40+
41+
@dataclasses.dataclass(frozen=True)
42+
class Answer:
43+
dialog_id: str
44+
selected: tuple[str, ...]
45+
note: str | None = None
46+
47+
@property
48+
def option_id(self) -> str:
49+
return self.selected[0]
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from jumper_extension.adapters.ai_reviewer.dialog.models import Answer, Dialog
2+
3+
4+
class DialogPresenter:
5+
"""Renders a dialog in the chat and returns the user's answer.
6+
7+
Concrete presenters (notebook widget, terminal, ...) implement ``present``.
8+
Everything else depends only on this interface, so the presentation
9+
technology can change without touching dialog specs, the schema, or the
10+
effect handlers.
11+
"""
12+
13+
def present(self, dialog: Dialog) -> Answer:
14+
raise NotImplementedError
15+
16+
17+
class DefaultPresenter(DialogPresenter):
18+
"""Non-interactive fallback: selects each dialog's default option.
19+
20+
Used when ``interactive: false``, in tests, and until a real widget
21+
presenter is wired in.
22+
"""
23+
24+
def present(self, dialog: Dialog) -> Answer:
25+
option = dialog.default_option()
26+
return Answer(dialog_id=dialog.id, selected=(option.id,))
27+
28+
29+
def render_menu(
30+
dialog: Dialog,
31+
cursor: int,
32+
selected: set[str],
33+
note: str | None = None,
34+
) -> str:
35+
"""Text of the arrow-select menu; ``❯`` marks the cursor row.
36+
37+
Reused by any concrete presenter regardless of the widget technology.
38+
"""
39+
lines = [f"{dialog.header}:"]
40+
for index, option in enumerate(dialog.options):
41+
pointer = "❯" if index == cursor else " "
42+
if dialog.kind == "multi":
43+
mark = "[x]" if option.id in selected else "[ ]"
44+
else:
45+
mark = "●" if option.id in selected else "○"
46+
lines.append(f"{pointer} {mark} {option.label}")
47+
if note:
48+
lines.append(f" note: {note}")
49+
return "\n".join(lines)
50+
51+
52+
def resolve_overrides(dialog: Dialog, answer: Answer) -> dict:
53+
"""Merge the ``overrides`` payloads of the selected options into one map."""
54+
by_id = {option.id: option for option in dialog.options}
55+
overrides = {}
56+
for option_id in answer.selected:
57+
overrides.update(by_id[option_id].effect.get("overrides", {}))
58+
return overrides
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
id: optimization_strategy
2+
header: Optimization strategy
3+
kind: single
4+
allow_note: true # key `n` attaches a free-text note to any option
5+
6+
options:
7+
- id: faster
8+
label: Just make it faster
9+
default: true
10+
description: Agent figures out the bottlenecks and suggests several options.
11+
effect:
12+
overrides: {}
13+
14+
- id: parallelization
15+
label: Focus on parallelization
16+
description: Agent focuses on GPU under-use and threading/multiprocessing libraries.
17+
effect:
18+
overrides:
19+
gpu_offload: true
20+
parallel_analysis_hint: true
21+
22+
- id: custom
23+
label: Explain what you need
24+
description: Free-form instruction to the agent.
25+
require_note: true
26+
effect:
27+
overrides: {}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from pathlib import Path
2+
3+
import yaml
4+
from jinja2 import Environment, FileSystemLoader
5+
6+
_PROMPTS_DIR = Path(__file__).parent
7+
_FRAGMENTS_DIR = _PROMPTS_DIR / "fragments"
8+
9+
10+
class PromptLibrary:
11+
"""Composes the AI-reviewer system prompts from per-prompt templates + specs.
12+
13+
Each prompt has its own folder (``analyze``/``suggest``/``refine``) with a
14+
``template.md`` (Jinja prose + loops over list items) and a ``spec.yaml``
15+
(the toggleable items, each ``{id, enabled, text|fragment}``).
16+
17+
``render`` takes an ``overrides`` map (``id -> enabled``) so any caller - a
18+
deterministic dialog or one an agent builds at runtime - can flip items
19+
on/off without this layer knowing where the choice came from.
20+
"""
21+
22+
def __init__(self, specs: dict[str, dict], env: Environment):
23+
self._specs = specs
24+
self._env = env
25+
26+
@classmethod
27+
def load(cls, root: Path = _PROMPTS_DIR) -> "PromptLibrary":
28+
env = Environment(
29+
loader=FileSystemLoader(str(root)),
30+
trim_blocks=True,
31+
lstrip_blocks=True,
32+
)
33+
specs = {
34+
prompt_dir.name: _resolve_fragments(
35+
yaml.safe_load((prompt_dir / "spec.yaml").read_text()) or {}
36+
)
37+
for prompt_dir in sorted(root.iterdir())
38+
if (prompt_dir / "template.md").exists()
39+
}
40+
return cls(specs, env)
41+
42+
def prompt_ids(self) -> list[str]:
43+
return list(self._specs)
44+
45+
def render(
46+
self,
47+
prompt_id: str,
48+
overrides: dict | None = None,
49+
note: str | None = None,
50+
) -> str:
51+
overrides = overrides or {}
52+
context = {}
53+
for key, value in self._specs[prompt_id].items():
54+
if isinstance(value, list):
55+
context[key] = [
56+
{**item, "enabled": overrides.get(item["id"], item["enabled"])}
57+
for item in value
58+
]
59+
else:
60+
context[key] = {**value, "enabled": bool(note), "text": note or ""}
61+
template = self._env.get_template(f"{prompt_id}/template.md")
62+
return template.render(**context).strip() + "\n"
63+
64+
65+
def _resolve_fragments(spec: dict) -> dict:
66+
resolved = {}
67+
for key, value in spec.items():
68+
if isinstance(value, list):
69+
resolved[key] = [_resolve_item(item) for item in value]
70+
else:
71+
resolved[key] = _resolve_item(value)
72+
return resolved
73+
74+
75+
def _resolve_item(item: dict) -> dict:
76+
if "fragment" in item:
77+
return {**item, "text": (_FRAGMENTS_DIR / item["fragment"]).read_text().strip()}
78+
return item
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
given:
2+
- id: an_cell_source
3+
enabled: true
4+
text: Source code of a notebook cell
5+
- id: perf_tags
6+
enabled: true
7+
text: Performance classification tags JUmPER assigned to it
8+
- id: metrics_summary
9+
enabled: true
10+
text: Summary of the measured CPU/GPU/memory metrics, and a description of the available hardware
11+
12+
hints:
13+
- id: parallel_analysis_hint
14+
enabled: false
15+
text: >-
16+
Pay special attention to whether the workload under-uses available parallel
17+
hardware - an idle GPU or unused CPU cores.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
You are a performance engineering assistant embedded in a Jupyter notebook profiler called JUmPER.
2+
3+
You are given the:
4+
{% for item in given if item.enabled %}
5+
- {{ item.text | indent(6) }}
6+
{% endfor %}
7+
8+
Identify the most likely performance bottleneck of the cell in 2-4 sentences.
9+
Be concrete: name the resource that is the bottleneck, point at the part of the
10+
code responsible for it, and explain why the measured metrics support your
11+
conclusion. Do not propose code changes here - that happens in a later step.
12+
Respond with plain text only, no markdown headings.
13+
{% for item in hints if item.enabled %}
14+
15+
{{ item.text }}
16+
{% endfor %}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
If the hardware includes one or more GPUs and the code contains a workload that can be efficiently parallelized:
2+
- Include at least one option that refactors the CPU implementation to run on the GPU, whichever fits the existing code with the least disruption.
3+
- Prefer using preinstalled libraries (check the list of "Available libraries").
4+
Skip this if no GPU is available or the workload is not a good parallelization candidate (e.g. it is I/O-bound or inherently sequential).
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Write code as properly formatted multi-line Python with real newlines between statements (PEP 8 style), never as a semicolon-joined one-liner.

0 commit comments

Comments
 (0)