From 292fd0fddf29bfebbfb706353e6cedc23b98d7e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 23:34:49 +0000 Subject: [PATCH] Assistant: place images, diagrams, references and child notes by paragraph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A block-inserting action could only be placed against a heading, so "in the second chapter, add an image above every paragraph" put every image directly beneath the chapter title. The model had no way to say "next to this paragraph". generate_image, create_diagram, add_reference and create_child_note now take three optional placement fields: - anchorText: the opening words of an existing paragraph, list item, quote or other block; the new block goes directly before or after it - position: "before" / "after" (default) relative to the anchor; with only a section, "after" (beneath the heading), "before" (above it) or "end" (after the section's last block). Synonyms like above/below are folded in. - section: narrows the anchor search to that section, or places relative to the heading on its own (the previous behaviour) Anchors are matched through the Markdown the model copies (emphasis, link syntax, escapes, bullets, trailing ellipsis), preferring a whole-block match, then a block that starts with the snippet, then one that contains it. A target that cannot be found degrades to the section, then the end of the note, and the run summary says so. create_diagram previously always appended to the end; it now honours placement too. The planner prompt gains a "Placing blocks" rule with the per-paragraph example, and both parsers (TS and Python) validate and label the new fields identically — checked with tools/plan_parse_diff (57/57 agree). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Fym1mUkgMQ1jAexC6D6zmK --- backend/app/assistant/executor.py | 232 +++++++++++++++++++--- backend/app/assistant/plan_parse.py | 43 +++- backend/app/assistant/plan_prompt.py | 35 +++- backend/tests/test_assistant_executor.py | 201 ++++++++++++++++++- backend/tests/test_plan_parse.py | 50 +++++ backend/tools/plan_parse_diff/corpus.json | 15 ++ frontend/src/services/aiPlan.test.ts | 45 +++++ frontend/src/services/aiPlan.ts | 96 +++++++-- 8 files changed, 660 insertions(+), 57 deletions(-) diff --git a/backend/app/assistant/executor.py b/backend/app/assistant/executor.py index 4005f19..b55b314 100644 --- a/backend/app/assistant/executor.py +++ b/backend/app/assistant/executor.py @@ -27,6 +27,7 @@ from sqlmodel import Session, select +from app.assistant.plan_parse import POSITION_ALIASES from app.blocks.markdown_blocks import markdown_to_blocks from app.models import Annotation, Category, Folder, Note, Recipe @@ -301,6 +302,193 @@ def search(exact: bool) -> int: return hit if hit != -1 else search(exact=False) +def section_end(blocks: Sequence[Any], start: int) -> int: + """Index just past the section headed at `start`: it runs until the next heading of + the same or higher level, or the end of the note.""" + found = section_heading(blocks[start]) + level = found[0] if found else 1 + for index in range(start + 1, len(blocks)): + info = section_heading(blocks[index]) + if info and info[0] <= level: + return index + return len(blocks) + + +# ─── placing a block next to a paragraph ───────────────────────────────────── + + +def _inline_text(content: Any) -> str: + """Plain text of a run of inline content, descending into links. + + `block_text` reads only top-level `text`, which skips a link's words entirely — + fine for headings, wrong for a paragraph the model quotes from its middle. + """ + if not isinstance(content, list): + return "" + out = [] + for node in content: + if not isinstance(node, dict): + continue + if node.get("type") == "link": + out.append(_inline_text(node.get("content"))) + else: + out.append(str(node.get("text") or "")) + return "".join(out) + + +def _subtree_text(block: Any) -> str: + """A block's own text followed by every nested child's, space-separated.""" + if not isinstance(block, dict): + return "" + parts = [_inline_text(block.get("content"))] + for child in block.get("children") or []: + parts.append(_subtree_text(child)) + return " ".join(p for p in parts if p) + + +def normalize_anchor(text: str) -> str: + """Reduce a quoted snippet, or a block's text, to a canonical form for matching. + + The model reads note bodies as Markdown, so the snippet it copies carries what the + stored block does not: link syntax, emphasis markers, a list bullet, backslash + escapes, and an ellipsis where it stopped quoting. Both sides go through this, so a + snippet matches whether or not the model kept any of it. + """ + out = text or "" + out = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", out) # an image is not text + out = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", out) # a link is its words + out = re.sub(r"\\([\\`*_{}\[\]()#+\-.!>~|])", r"\1", out) # Markdown escapes + out = re.sub(r"[‘’]", "'", out) + out = re.sub(r"[“”]", '"', out) + out = re.sub(r"[*_`~]+", "", out) + out = out.strip() + # A leading block marker: heading hashes, a quote, a bullet or checkbox, a number. + out = re.sub(r"^(?:#{1,6}\s+|>\s*|[-+]\s+(?:\[[ xX]\]\s+)?|\d+[.)]\s+)", "", out) + out = re.sub(r"^(?:…|\.\.\.)\s*", "", out) + out = re.sub(r"\s*(?:…|\.\.\.)$", "", out) + out = re.sub(r"\s+", " ", out) + out = re.sub(r"[.,:;!?]+$", "", out) + return out.strip().lower() + + +def find_anchor_index( + blocks: Sequence[Any], anchor: str, lo: int = 0, hi: Optional[int] = None +) -> int: + """Index of the top-level block in `blocks[lo:hi]` holding `anchor`, or -1. + + Tightest match first — the whole block, then a block that starts with the snippet, + then one containing it — so a snippet that happens to recur later in the section + still lands on the paragraph it was copied from. Nested blocks (a sub-bullet, a + toggle's body) resolve to their top-level ancestor: an image inserted among a list + item's children would render indented under that item, which nobody asked for. + """ + target = normalize_anchor(anchor) + if not target: + return -1 + hi = len(blocks) if hi is None else hi + + def own_text(block: Any) -> str: + return normalize_anchor(_inline_text(block.get("content"))) if isinstance(block, dict) else "" + + own = [(index, own_text(blocks[index])) for index in range(lo, hi)] + for test in ( + lambda text: text == target, + lambda text: text.startswith(target), + lambda text: target in text, + ): + for index, text in own: + if text and test(text): + return index + for index in range(lo, hi): + if target in normalize_anchor(_subtree_text(blocks[index])): + return index + return -1 + + +@dataclass +class Placement: + """Where to insert a block, and how to say so in the run summary.""" + + index: int + where: str = "" # " below “…”", " under “…”", or a fallback notice + + +def resolve_placement( + blocks: Sequence[Any], + *, + section: Optional[str] = None, + anchor: Optional[str] = None, + position: Optional[str] = None, +) -> Placement: + """Where a newly inserted block goes. + + `anchor` (a snippet of an existing paragraph, list item, quote…) is the precise + signal and wins: the block goes directly before or after it. `section` narrows that + search when both are given, and on its own places relative to the heading — beneath + it, above it, or after the section's last block. With neither, the end of the note. + + Before this, a block could only be placed under a heading, so "an image above every + paragraph in chapter 2" stacked every image directly beneath the chapter title. + + A target that cannot be found degrades rather than fails — to the section if that + was found, else the end of the note — and `where` says so, because the user is + better served by an image in roughly the right place than by a paid generation that + was thrown away. + """ + pos = POSITION_ALIASES.get((position or "").strip().lower(), "") + section = (section or "").strip() + anchor = (anchor or "").strip() + + heading_at = find_section_index(blocks, section) if section else -1 + lo, hi = 0, len(blocks) + if heading_at != -1: + lo, hi = heading_at + 1, section_end(blocks, heading_at) + + missing = "" + if anchor: + snippet = _truncate(anchor, 40) + hit = find_anchor_index(blocks, anchor, lo, hi) if heading_at != -1 else -1 + if hit == -1: + # A wrong section name should not cost a right anchor: the snippet is the + # more specific of the two, so look for it everywhere before giving up. + hit = find_anchor_index(blocks, anchor) + if hit != -1: + if pos == "before": + return Placement(hit, f" above “{snippet}”") + return Placement(hit + 1, f" below “{snippet}”") + missing = f"“{snippet}” not found" + + if heading_at != -1: + if missing: + return Placement(heading_at + 1, f" under “{section}” ({missing})") + if pos == "before": + return Placement(heading_at, f" above “{section}”") + if pos == "end": + return Placement(hi, f" at the end of “{section}”") + return Placement(heading_at + 1, f" under “{section}”") + + if section: + missing = f"{missing}; " if missing else "" + missing += f"section “{section}” not found" + if missing: + return Placement(len(blocks), f" ({missing} — added at the end)") + return Placement(len(blocks)) + + +def _truncate(text: str, max_len: int) -> str: + one_line = re.sub(r"\s+", " ", text).strip() + return f"{one_line[:max_len - 1]}…" if len(one_line) > max_len else one_line + + +def _placement_of(action: Dict[str, Any]) -> Dict[str, Optional[str]]: + """The placement fields an action carries, keyed for `resolve_placement`.""" + return { + "section": action.get("section"), + "anchor": action.get("anchorText"), + "position": action.get("position"), + } + + def collect_embeds(blocks: Sequence[Any]) -> List[Any]: """Child-note, reference and diagram blocks found anywhere in `blocks`. @@ -566,14 +754,7 @@ def _do_edit_section(self, action: Dict[str, Any]) -> ActionResult: ) # The section runs until the next heading of the same or higher level. - found = section_heading(blocks[start]) - level = found[0] if found else 1 - end = len(blocks) - for index in range(start + 1, len(blocks)): - info = section_heading(blocks[index]) - if info and info[0] <= level: - end = index - break + end = section_end(blocks, start) preserved = collect_embeds(blocks[start:end]) blocks[start:end] = [*new_blocks, *preserved] @@ -655,7 +836,8 @@ def _do_create_child_note(self, action: Dict[str, Any]) -> ActionResult: # block or the child is invisible in the UI (mirrors EditorView.insertEmptyChild). self._snapshot_once(parent) blocks = parse_blocks(parent.content) - blocks.append({ + placement = resolve_placement(blocks, **_placement_of(action)) + blocks.insert(placement.index, { "id": str(uuid.uuid4()), "type": "childNote", "props": {"childNoteId": child.id, "title": child.title}, @@ -665,7 +847,7 @@ def _do_create_child_note(self, action: Dict[str, Any]) -> ActionResult: return ActionResult( ok=True, - message=f"Created child note “{child.title}” under “{parent.title}”.", + message=f"Created child note “{child.title}” under “{parent.title}”{placement.where}.", notes_changed=True, touched_current_note=self._touches_current(parent.id), note_id=child.id, note_title=child.title, ) @@ -780,14 +962,17 @@ def _do_add_reference(self, action: Dict[str, Any]) -> ActionResult: "props": {"noteId": reference_id, "noteTitle": reference_title}, "children": [], } - section = action.get("insertAfterSection") - after = find_section_index(blocks, section) if section else -1 - blocks.insert(len(blocks) if after == -1 else after + 1, block) + # `section` too, for a plan that reached here without passing the validator, + # which is what folds it onto insertAfterSection. + placement = resolve_placement(blocks, **{ + **_placement_of(action), + "section": action.get("insertAfterSection") or action.get("section"), + }) + blocks.insert(placement.index, block) self._write_content(note, blocks) - where = f" under “{section}”" if section else "" return ActionResult( - ok=True, message=f"Added reference to “{reference_title}”{where}.", + ok=True, message=f"Added reference to “{reference_title}”{placement.where}.", notes_changed=True, touched_current_note=self._touches_current(note.id), note_id=note.id, note_title=note.title, ) @@ -884,7 +1069,8 @@ def _do_create_diagram(self, action: Dict[str, Any]) -> ActionResult: self._snapshot_once(note) blocks = parse_blocks(note.content) - blocks.append({ + placement = resolve_placement(blocks, **_placement_of(action)) + blocks.insert(placement.index, { "id": str(uuid.uuid4()), "type": "diagram", # Mirrors newDiagramId() in utils/diagram.ts — a stable handle edit_diagram @@ -894,7 +1080,7 @@ def _do_create_diagram(self, action: Dict[str, Any]) -> ActionResult: }) self._write_content(note, blocks) return ActionResult( - ok=True, message=f"Added diagram to “{note.title}”.", notes_changed=True, + ok=True, message=f"Added diagram to “{note.title}”{placement.where}.", notes_changed=True, touched_current_note=self._touches_current(note.id), note_id=note.id, note_title=note.title, ) @@ -966,18 +1152,12 @@ def _do_generate_image(self, action: Dict[str, Any]) -> ActionResult: "props": {"url": url, "name": action.get("alt") or "", "caption": "", "showPreview": True}, "children": [], } - section = action.get("section") - index = find_section_index(blocks, section) if section else -1 - placed = index != -1 - blocks.insert(len(blocks) if not placed else index + 1, block) + placement = resolve_placement(blocks, **_placement_of(action)) + blocks.insert(placement.index, block) self._write_content(note, blocks) - if section: - where = f" under “{section}”" if placed else f" (section “{section}” not found — added at the end)" - else: - where = "" return ActionResult( - ok=True, message=f"Generated image{where} in “{note.title}”.", notes_changed=True, + ok=True, message=f"Generated image{placement.where} in “{note.title}”.", notes_changed=True, touched_current_note=self._touches_current(note.id), note_id=note.id, note_title=note.title, ) diff --git a/backend/app/assistant/plan_parse.py b/backend/app/assistant/plan_parse.py index 5e99dee..84496ab 100644 --- a/backend/app/assistant/plan_parse.py +++ b/backend/app/assistant/plan_parse.py @@ -44,6 +44,16 @@ # Action types that are retrieval steps: resolved before the plan runs, never executed. RETRIEVAL_TYPES = frozenset({"find_notes", "web_search"}) +# What the model may write for a block's `position`, folded onto the three the +# executor knows. It is told "before"/"after", but "above"/"below" is how the user said +# it, and a placement dropped for a synonym is an image back under the heading. +# Mirrors POSITION_ALIASES in aiPlan.ts. +POSITION_ALIASES = { + "before": "before", "above": "before", + "after": "after", "below": "after", "under": "after", "beneath": "after", + "end": "end", +} + # ─── validation ────────────────────────────────────────────────────────────── @@ -53,6 +63,19 @@ def _as_string(value: Any) -> Optional[str]: return value if isinstance(value, str) else None +def _placement(raw: Dict[str, Any]) -> Dict[str, Any]: + """`anchorPlacement`: where a block-inserting action puts its block, beyond its + section — a quoted snippet of the block to sit next to, and which side of it.""" + out: Dict[str, Any] = {} + anchor_text = _as_string(raw.get("anchorText")) + if anchor_text and anchor_text.strip(): + out["anchorText"] = anchor_text + position = POSITION_ALIASES.get((_as_string(raw.get("position")) or "").strip().lower()) + if position: + out["position"] = position + return out + + def validate_action(raw: Any) -> Optional[Dict[str, Any]]: """One action, normalised — or None when it is unusable and should be dropped. @@ -183,6 +206,7 @@ def validate_action(raw: Any) -> Optional[Dict[str, Any]]: title = _as_string(raw.get("title")) if not parent_id or title is None: return None + section = _as_string(raw.get("section")) return { "type": "create_child_note", "parentId": parent_id, @@ -190,6 +214,8 @@ def validate_action(raw: Any) -> Optional[Dict[str, Any]]: "content": _as_string(raw.get("content")) or "", **spec_field, **ref_field, + **({"section": section} if section else {}), + **_placement(raw), **trailer, } @@ -259,10 +285,14 @@ def validate_action(raw: Any) -> Optional[Dict[str, Any]]: } # The TS assigns this unconditionally, so it is `undefined` when absent — # which JSON.stringify drops. Omitting the key is the same thing on the wire. + # `section` is accepted too: every other block-inserting action calls it that, + # and the instructions now do for this one as well. insert_after = _as_string(raw.get("insertAfterSection")) + if insert_after is None: + insert_after = _as_string(raw.get("section")) if insert_after is not None: action["insertAfterSection"] = insert_after - return {**action, **trailer} + return {**action, **_placement(raw), **trailer} if kind == "add_annotation": note_id = _as_string(raw.get("noteId")) @@ -307,7 +337,15 @@ def validate_action(raw: Any) -> Optional[Dict[str, Any]]: source = _as_string(raw.get("source")) if not note_id or not source or not source.strip(): return None - return {"type": "create_diagram", "noteId": note_id, "source": source, **trailer} + section = _as_string(raw.get("section")) + return { + "type": "create_diagram", + "noteId": note_id, + "source": source, + **({"section": section} if section else {}), + **_placement(raw), + **trailer, + } if kind == "edit_diagram": note_id = _as_string(raw.get("noteId")) @@ -332,6 +370,7 @@ def validate_action(raw: Any) -> Optional[Dict[str, Any]]: section = _as_string(raw.get("section")) if section: action["section"] = section + action.update(_placement(raw)) alt = _as_string(raw.get("alt")) if alt: action["alt"] = alt diff --git a/backend/app/assistant/plan_prompt.py b/backend/app/assistant/plan_prompt.py index aafaba8..3482fed 100644 --- a/backend/app/assistant/plan_prompt.py +++ b/backend/app/assistant/plan_prompt.py @@ -84,6 +84,24 @@ def detect_mermaid_kind(source: str) -> str: # ─── labels ────────────────────────────────────────────────────────────────── +def placement_suffix(action: Mapping[str, Any], section_key: str = "section") -> str: + """Where a block-inserting action puts its block, as a phrase for its label: the + quoted paragraph when it has one, else its section, else nothing (end of note).""" + position = action.get("position") + anchor = action.get("anchorText") + if anchor: + side = "above" if position == "before" else "below" + return f" {side} “{truncate(anchor, 40)}”" + section = action.get(section_key) + if not section: + return "" + if position == "before": + return f" above “{section}”" + if position == "end": + return f" at the end of “{section}”" + return f" under “{section}”" + + def default_action_label(action: Mapping[str, Any], label_map: Mapping[str, str]) -> str: """Human-readable label for one action. @@ -143,7 +161,11 @@ def name(value: Optional[str]) -> str: if kind == "create_child_note": title = action.get("title") or "Untitled" - return f"Create child note “{title}” under “{name(action.get('parentId'))}”" + placed = placement_suffix(action) + return ( + f"Create child note “{title}” under “{name(action.get('parentId'))}”" + f"{',' + placed if placed else ''}" + ) if kind == "move_note": folder_id = action.get("folderId") @@ -165,11 +187,10 @@ def name(value: Optional[str]) -> str: return f"Create folder “{action.get('name')}”" if kind == "add_reference": - under = action.get("insertAfterSection") - suffix = f" under “{under}”" if under else "" return ( f"Add reference to “{action.get('referenceTitle')}” " - f"in “{name(action.get('noteId'))}”{suffix}" + f"in “{name(action.get('noteId'))}”" + f"{placement_suffix(action, 'insertAfterSection')}" ) if kind == "add_annotation": @@ -184,14 +205,13 @@ def name(value: Optional[str]) -> str: if kind == "create_diagram": label = DIAGRAM_KIND_LABELS[detect_mermaid_kind(action.get("source") or "")].lower() - return f"Create {label} in “{name(action.get('noteId'))}”" + return f"Create {label} in “{name(action.get('noteId'))}”{placement_suffix(action)}" if kind == "edit_diagram": return f"Update diagram in “{name(action.get('noteId'))}”" if kind == "generate_image": - section = action.get("section") - where = f" under “{section}”" if section else "" + where = placement_suffix(action) prompt = truncate(action.get("prompt") or "", 60) return f"Generate image{where} in “{name(action.get('noteId'))}”: {prompt}" @@ -299,6 +319,7 @@ def build_generation_steps( "DIAGRAM_KIND_LABELS", "truncate", "detect_mermaid_kind", + "placement_suffix", "default_action_label", "action_spec", "action_needs_generation", diff --git a/backend/tests/test_assistant_executor.py b/backend/tests/test_assistant_executor.py index f21311f..31cb9f8 100644 --- a/backend/tests/test_assistant_executor.py +++ b/backend/tests/test_assistant_executor.py @@ -20,8 +20,8 @@ from app.assistant.executor import ( ActionResult, Cancelled, ExecContext, PlanExecutor, - build_result_summary, collect_embeds, find_section_index, normalize_heading, - section_heading, + build_result_summary, collect_embeds, find_anchor_index, find_section_index, + normalize_heading, resolve_placement, section_heading, ) from app.models import Annotation, Category, Folder, Note, NoteVersion, Recipe @@ -385,6 +385,203 @@ def test_add_reference_lands_under_a_named_section(session): assert kinds[1] == "noteReference" +# ─── placing a block next to a paragraph ───────────────────────────────────── + + +def chapters() -> list: + """Three chapters; the second has three paragraphs to anchor images to.""" + return [ + heading("Chapter 1", block_id="h1"), para("The voyage began at dawn.", "p1"), + heading("Chapter 2", block_id="h2"), + para("Storm clouds gathered over the harbour.", "p2"), + para("The crew argued about turning back.", "p3"), + para("By nightfall the sea was calm again.", "p4"), + heading("Chapter 3", block_id="h3"), para("Landfall.", "p5"), + ] + + +def outline(session, note_id="note-1") -> list: + """Each block as its text, or its type when it has none (images, embeds).""" + out = [] + for block in blocks_of(session, note_id): + text = "".join(c.get("text", "") for c in (block.get("content") or [])) + out.append(text or block["type"]) + return out + + +@pytest.fixture +def fake_images(monkeypatch): + urls = iter(f"/media/img-{i}.png" for i in range(100)) + monkeypatch.setattr(PlanExecutor, "_generate_image_url", lambda self, prompt: next(urls)) + + +def image(**fields) -> dict: + return {"type": "generate_image", "noteId": "note-1", "prompt": "a picture", **fields} + + +def test_an_image_above_every_paragraph_lands_above_each_one(session, fake_images): + # The reported bug: "in the second chapter, add an image above every paragraph" + # put every image directly beneath the chapter heading, because a heading was the + # only thing an image could be placed against. + make_note(session, blocks=chapters()) + results = executor(session).run({"actions": [ + image(section="Chapter 2", anchorText="Storm clouds gathered", position="before"), + image(section="Chapter 2", anchorText="The crew argued about", position="before"), + image(section="Chapter 2", anchorText="By nightfall the sea", position="before"), + ]}) + assert all(r.ok for r in results) + assert outline(session) == [ + "Chapter 1", "The voyage began at dawn.", + "Chapter 2", + "image", "Storm clouds gathered over the harbour.", + "image", "The crew argued about turning back.", + "image", "By nightfall the sea was calm again.", + "Chapter 3", "Landfall.", + ] + assert "above “Storm clouds gathered”" in results[0].message + + +def test_an_anchored_block_goes_below_its_paragraph_by_default(session, fake_images): + make_note(session, blocks=chapters()) + executor(session).run({"actions": [image(anchorText="The crew argued")]}) + order = outline(session) + assert order[order.index("The crew argued about turning back.") + 1] == "image" + + +def test_the_section_scopes_the_anchor_so_repeated_wording_elsewhere_loses(session, fake_images): + make_note(session, blocks=[ + heading("One", block_id="h1"), para("It was raining.", "p1"), + heading("Two", block_id="h2"), para("It was raining.", "p2"), + ]) + executor(session).run({"actions": [ + image(section="Two", anchorText="It was raining", position="before"), + ]}) + assert outline(session) == ["One", "It was raining.", "Two", "image", "It was raining."] + + +def test_a_wrong_section_does_not_cost_a_right_anchor(session, fake_images): + make_note(session, blocks=chapters()) + executor(session).run({"actions": [ + image(section="Chapter 9", anchorText="Landfall", position="before"), + ]}) + assert outline(session)[-2:] == ["image", "Landfall."] + + +def test_an_unfound_anchor_falls_back_under_its_section_and_says_so(session, fake_images): + make_note(session, blocks=chapters()) + results = executor(session).run({"actions": [ + image(section="Chapter 2", anchorText="Nothing like this", position="before"), + ]}) + assert results[0].ok + order = outline(session) + assert order[order.index("Chapter 2") + 1] == "image" + assert "not found" in results[0].message + + +def test_an_anchor_found_nowhere_appends_and_says_so(session, fake_images): + make_note(session, blocks=chapters()) + results = executor(session).run({"actions": [image(anchorText="Nothing like this")]}) + assert outline(session)[-1] == "image" + assert "added at the end" in results[0].message + + +@pytest.mark.parametrize("position, expected", [ + (None, ["Chapter 2", "image", "Storm clouds gathered over the harbour."]), + ("before", ["The voyage began at dawn.", "image", "Chapter 2"]), + ("end", ["By nightfall the sea was calm again.", "image", "Chapter 3"]), +]) +def test_a_section_alone_places_relative_to_its_heading(session, fake_images, position, expected): + make_note(session, blocks=chapters()) + fields = {"section": "Chapter 2"} + if position: + fields["position"] = position + executor(session).run({"actions": [image(**fields)]}) + order = outline(session) + at = order.index("image") + assert order[at - 1:at + 2] == expected + + +def test_a_diagram_can_be_placed_after_a_paragraph(session): + make_note(session, blocks=chapters()) + result = executor(session).run({"actions": [{ + "type": "create_diagram", "noteId": "note-1", "source": "flowchart TD\nA-->B", + "section": "Chapter 1", "anchorText": "The voyage began", + }]}) + assert result[0].ok + assert outline(session)[:3] == ["Chapter 1", "The voyage began at dawn.", "diagram"] + + +def test_a_diagram_with_no_placement_still_goes_at_the_end(session): + make_note(session, blocks=chapters()) + executor(session).run({"actions": [ + {"type": "create_diagram", "noteId": "note-1", "source": "flowchart TD\nA-->B"}, + ]}) + assert outline(session)[-1] == "diagram" + + +def test_a_reference_can_be_placed_before_a_paragraph(session): + make_note(session, blocks=chapters()) + make_note(session, note_id="note-2", title="Target") + executor(session, valid_note_ids={"note-1", "note-2"}).run({"actions": [{ + "type": "add_reference", "noteId": "note-1", + "referenceNoteId": "note-2", "referenceTitle": "Target", + "insertAfterSection": "Chapter 2", "anchorText": "The crew argued", "position": "before", + }]}) + order = outline(session) + assert order[order.index("The crew argued about turning back.") - 1] == "noteReference" + + +def test_a_child_note_can_be_placed_after_a_paragraph(session): + make_note(session, blocks=chapters()) + executor(session).run({"actions": [{ + "type": "create_child_note", "parentId": "note-1", "title": "Child", "content": "Hi", + "anchorText": "The voyage began", + }]}) + assert outline(session)[:3] == ["Chapter 1", "The voyage began at dawn.", "childNote"] + + +def test_find_anchor_prefers_the_whole_block_then_its_start_then_any_part(): + blocks = [para("Before the fox came.", "a"), para("The fox ran.", "b"), para("The fox", "c")] + assert find_anchor_index(blocks, "The fox") == 2 + assert find_anchor_index(blocks[:2], "The fox") == 1 + assert find_anchor_index(blocks[:1], "The fox") == 0 + + +def test_an_anchor_matches_through_the_markdown_the_model_copied(): + # The model reads the note as Markdown and quotes it as such: emphasis markers, a + # link's syntax, escapes, and an ellipsis where it stopped quoting. The stored block + # has none of that — and its link text lives inside the link node. + block = { + "id": "b1", "type": "paragraph", "props": {}, "children": [], + "content": [ + {"type": "text", "text": "Read ", "styles": {}}, + {"type": "text", "text": "this", "styles": {"bold": True}}, + {"type": "text", "text": " and ", "styles": {}}, + {"type": "link", "href": "https://x.test", + "content": [{"type": "text", "text": "the source", "styles": {}}]}, + {"type": "text", "text": " (1. edition) twice.", "styles": {}}, + ], + } + blocks = [para("Elsewhere.", "b0"), block] + assert find_anchor_index(blocks, "Read **this** and [the source](https://x.test) \\(1\\. edition…") == 1 + assert find_anchor_index(blocks, "- Read this and the source") == 1 + + +def test_an_anchor_in_a_nested_block_places_beside_its_top_level_ancestor(): + item = { + "id": "li", "type": "bulletListItem", "props": {}, + "content": [{"type": "text", "text": "Parent item", "styles": {}}], + "children": [para("A nested detail.", "nested")], + } + assert find_anchor_index([para("Intro.", "p0"), item], "nested detail") == 1 + + +def test_resolve_placement_folds_position_synonyms(): + blocks = chapters() + assert resolve_placement(blocks, anchor="The crew argued", position="above").index == 4 + assert resolve_placement(blocks, anchor="The crew argued", position="below").index == 5 + + def test_an_annotation_anchors_to_the_block_holding_the_quoted_snippet(session): make_note(session, blocks=[para("Alpha line.", "b1"), para("Beta line.", "b2")]) result = executor(session).run({"actions": [ diff --git a/backend/tests/test_plan_parse.py b/backend/tests/test_plan_parse.py index e874182..b035b3a 100644 --- a/backend/tests/test_plan_parse.py +++ b/backend/tests/test_plan_parse.py @@ -25,6 +25,7 @@ split_retrieval, validate_action, ) +from app.assistant.plan_prompt import default_action_label def envelope(*actions) -> str: @@ -261,6 +262,55 @@ def test_empty_trailers_are_dropped_rather_than_carried_as_blanks(): assert action == {"type": "create_note", "title": "t", "content": ""} +def test_block_inserting_actions_carry_an_anchor_and_a_position(): + for raw in ( + {"type": "generate_image", "noteId": "n1", "prompt": "p"}, + {"type": "create_diagram", "noteId": "n1", "source": "pie"}, + {"type": "add_reference", "noteId": "n1", "referenceNoteId": "n2", "referenceTitle": "T"}, + {"type": "create_child_note", "parentId": "n1", "title": "C"}, + ): + action = validate_action({**raw, "anchorText": "Storm clouds", "position": "before"}) + assert action["anchorText"] == "Storm clouds" + assert action["position"] == "before" + + +@pytest.mark.parametrize("said, kept", [ + ("above", "before"), ("Below", "after"), (" under ", "after"), ("beneath", "after"), + ("end", "end"), ("sideways", None), ("constructor", None), (3, None), +]) +def test_a_position_is_folded_onto_the_three_the_executor_knows(said, kept): + action = validate_action({"type": "generate_image", "noteId": "n1", "prompt": "p", "position": said}) + assert action.get("position") == kept + + +def test_a_blank_anchor_is_dropped_rather_than_carried(): + action = validate_action({"type": "generate_image", "noteId": "n1", "prompt": "p", "anchorText": " "}) + assert "anchorText" not in action + + +def test_add_reference_accepts_section_for_its_heading(): + # Every other block-inserting action calls it `section`, and so do the instructions. + action = validate_action({ + "type": "add_reference", "noteId": "n1", "referenceNoteId": "n2", + "referenceTitle": "T", "section": "Intro", + }) + assert action["insertAfterSection"] == "Intro" + assert "section" not in action + + +@pytest.mark.parametrize("fields, suffix", [ + ({}, ""), + ({"section": "Intro"}, " under “Intro”"), + ({"section": "Intro", "position": "before"}, " above “Intro”"), + ({"section": "Intro", "position": "end"}, " at the end of “Intro”"), + ({"section": "Intro", "anchorText": "Storm clouds", "position": "before"}, " above “Storm clouds”"), + ({"anchorText": "Storm clouds"}, " below “Storm clouds”"), +]) +def test_labels_say_where_a_block_will_land(fields, suffix): + action = {"type": "generate_image", "noteId": "n1", "prompt": "p", **fields} + assert default_action_label(action, {"n1": "Note"}) == f"Generate image{suffix} in “Note”: p" + + def test_an_unknown_action_type_is_dropped(): assert validate_action({"type": "teleport_note", "noteId": "n1"}) is None diff --git a/backend/tools/plan_parse_diff/corpus.json b/backend/tools/plan_parse_diff/corpus.json index 9dd27c3..3d65968 100644 --- a/backend/tools/plan_parse_diff/corpus.json +++ b/backend/tools/plan_parse_diff/corpus.json @@ -268,5 +268,20 @@ "name": "labels with unicode and whitespace runs", "why": "truncate collapses whitespace and cuts at a code point, not a UTF-16 unit", "raw": "{\"actions\": [{\"type\": \"respond\", \"text\": \"line one\\n\\n line two \\\\t with runs of space that carry on well past the eighty character mark\"}, {\"type\": \"create_note\", \"title\": \"\\ud83e\\udd8e Notes \\u2014 \\u00dcn\\u00efc\\u00f6d\\u00e9\", \"content\": \"x\"}]}" + }, + { + "name": "images anchored above each paragraph", + "why": "\"an image above every paragraph in chapter 2\" — one generate_image per paragraph, placed by a quoted snippet rather than stacked under the heading", + "raw": "{\"actions\": [{\"type\": \"generate_image\", \"noteId\": \"n1\", \"prompt\": \"storm over a harbour\", \"section\": \"Chapter 2\", \"anchorText\": \"Storm clouds gathered over the harbour\", \"position\": \"before\"}, {\"type\": \"generate_image\", \"noteId\": \"n1\", \"prompt\": \"sailors arguing on deck\", \"section\": \"Chapter 2\", \"anchorText\": \"The crew argued about turning back, some of them shouting over the wind\", \"position\": \"above\", \"alt\": \"The argument\"}]}" + }, + { + "name": "placement synonyms and junk", + "why": "the model writes the user's word (below, under) or nonsense for position; synonyms fold, junk is dropped, a blank anchor is not carried", + "raw": "{\"actions\": [{\"type\": \"create_diagram\", \"noteId\": \"n2\", \"source\": \"flowchart TD\\nA-->B\", \"anchorText\": \"The steps are\", \"position\": \"Below\"}, {\"type\": \"create_diagram\", \"noteId\": \"n2\", \"source\": \"pie\\n\\\"a\\\": 1\", \"section\": \"Results\", \"position\": \"end\"}, {\"type\": \"generate_image\", \"noteId\": \"n1\", \"prompt\": \"p\", \"section\": \"Intro\", \"anchorText\": \" \", \"position\": \"sideways\"}, {\"type\": \"generate_image\", \"noteId\": \"n1\", \"prompt\": \"p\", \"section\": \"Intro\", \"position\": \"constructor\"}]}" + }, + { + "name": "reference and child note placed by paragraph", + "why": "add_reference takes `section` like the others (folded onto insertAfterSection); create_child_note takes the same placement fields", + "raw": "{\"actions\": [{\"type\": \"add_reference\", \"noteId\": \"n1\", \"referenceNoteId\": \"n2\", \"referenceTitle\": \"Second note\", \"section\": \"Sources\", \"anchorText\": \"See also\", \"position\": \"before\"}, {\"type\": \"add_reference\", \"noteId\": \"n1\", \"referenceNoteId\": \"n3\", \"referenceTitle\": \"Third note\", \"insertAfterSection\": \"Sources\", \"section\": \"Ignored\"}, {\"type\": \"create_child_note\", \"parentId\": \"n1\", \"title\": \"Details\", \"content\": \"\", \"spec\": \"the details\", \"section\": \"Plan\", \"anchorText\": \"Phase two begins\", \"position\": \"after\"}, {\"type\": \"create_child_note\", \"parentId\": \"n1\", \"title\": \"Appendix\", \"content\": \"x\", \"section\": \"Plan\", \"position\": \"before\"}]}" } ] diff --git a/frontend/src/services/aiPlan.test.ts b/frontend/src/services/aiPlan.test.ts index 730ef08..65ed82e 100644 --- a/frontend/src/services/aiPlan.test.ts +++ b/frontend/src/services/aiPlan.test.ts @@ -129,3 +129,48 @@ describe('webSearchContinuation', () => { expect(text).toContain('do not answer from memory') }) }) + +// Placing a block (image, diagram, reference, child note) next to a paragraph rather +// than only under a heading. Before this, "an image above every paragraph in chapter 2" +// stacked every image beneath the chapter title, because a heading was the only thing +// a block could be placed against. +describe('parsePlan — block placement', () => { + it('keeps the anchor and side on every block-inserting action', () => { + const raws = [ + { type: 'generate_image', noteId: 'n1', prompt: 'p' }, + { type: 'create_diagram', noteId: 'n1', source: 'pie' }, + { type: 'add_reference', noteId: 'n1', referenceNoteId: 'n2', referenceTitle: 'T' }, + { type: 'create_child_note', parentId: 'n1', title: 'C' }, + ] + for (const raw of raws) { + const action = firstAction(JSON.stringify({ ...raw, anchorText: 'Storm clouds', position: 'before' })) + expect(action).toMatchObject({ anchorText: 'Storm clouds', position: 'before' }) + } + }) + + it.each([ + ['above', 'before'], ['Below', 'after'], [' under ', 'after'], ['end', 'end'], + ['sideways', undefined], ['constructor', undefined], + ])('folds position %j onto %j', (said, kept) => { + const action = firstAction(JSON.stringify({ type: 'generate_image', noteId: 'n1', prompt: 'p', position: said })) + expect((action as { position?: string }).position).toBe(kept) + }) + + it('reads add_reference’s heading from `section` like every other block action', () => { + const action = firstAction('{"type":"add_reference","noteId":"n1","referenceNoteId":"n2","referenceTitle":"T","section":"Intro"}') + expect(action).toMatchObject({ insertAfterSection: 'Intro' }) + expect(action).not.toHaveProperty('section') + }) + + it.each([ + [{}, ''], + [{ section: 'Intro' }, ' under “Intro”'], + [{ section: 'Intro', position: 'before' }, ' above “Intro”'], + [{ section: 'Intro', position: 'end' }, ' at the end of “Intro”'], + [{ section: 'Intro', anchorText: 'Storm clouds', position: 'before' }, ' above “Storm clouds”'], + [{ anchorText: 'Storm clouds' }, ' below “Storm clouds”'], + ])('labels %j as landing%s', (fields, suffix) => { + const action = { type: 'generate_image', noteId: 'n1', prompt: 'p', ...fields } as PlanAction + expect(defaultActionLabel(action, new Map([['n1', 'Note']]))).toBe(`Generate image${suffix} in “Note”: p`) + }) +}) diff --git a/frontend/src/services/aiPlan.ts b/frontend/src/services/aiPlan.ts index b64623f..e48a162 100644 --- a/frontend/src/services/aiPlan.ts +++ b/frontend/src/services/aiPlan.ts @@ -12,6 +12,16 @@ export interface ContextCategory { id: string; label: string } export interface ContextRecipe { id: string; name: string; tags: string[]; prompt: string } +// Where an action that inserts ONE block (image, diagram, note reference, child note) +// puts it, beyond its section. `anchorText` quotes the start of an existing paragraph, +// list item, quote… and the block goes directly `before` or `after` (default) it. With +// only a section, `position` is relative to the heading: `after` (directly beneath it, +// the default), `before` (just above it) or `end` (after the section's last block). +// Without these a block could only go under a heading, so "an image above every +// paragraph" stacked every image beneath the chapter title. +export type PlacementPosition = 'before' | 'after' | 'end' +export interface Placement { anchorText?: string; position?: PlacementPosition } + // All note `content` is MARKDOWN — converted to BlockNote blocks by the executor // via the live editor's tryParseMarkdownToBlocks(). The model never emits BlockNote JSON. // `spec` (optional, content-bearing actions) defers the body: the planner describes what the @@ -39,24 +49,24 @@ export type PlanAction = | { type: 'edit_section'; noteId: string; section: string; content: string; spec?: string; description?: string } | { type: 'append_note'; noteId: string; content: string; spec?: string; description?: string } | { type: 'rename_note'; noteId: string; title: string; description?: string } - | { type: 'create_child_note'; parentId: string; title: string; content: string; spec?: string; ref?: string; description?: string } + | ({ type: 'create_child_note'; parentId: string; title: string; content: string; spec?: string; ref?: string; section?: string; description?: string } & Placement) | { type: 'move_note'; noteId: string; folderId: string | null; description?: string } | { type: 'set_tags'; noteId: string; tags: string[]; mode: 'replace' | 'add'; description?: string } | { type: 'set_category'; noteId: string; categoryId: string; description?: string } | { type: 'create_folder'; name: string; parentFolderId?: string | null; ref?: string; description?: string } - | { type: 'add_reference'; noteId: string; referenceNoteId: string; referenceTitle: string; insertAfterSection?: string; description?: string } + | ({ type: 'add_reference'; noteId: string; referenceNoteId: string; referenceTitle: string; insertAfterSection?: string; description?: string } & Placement) | { type: 'add_annotation'; noteId: string; anchorText: string; text: string; description?: string } | { type: 'edit_annotation'; noteId: string; annotationId: string; text: string; description?: string } | { type: 'delete_annotation'; noteId: string; annotationId: string; description?: string } // Diagrams are custom blocks that can't be expressed in Markdown, so they use their own // actions carrying raw Mermaid diagram source text (not `content`). - | { type: 'create_diagram'; noteId: string; source: string; description?: string } + | ({ type: 'create_diagram'; noteId: string; source: string; section?: string; description?: string } & Placement) | { type: 'edit_diagram'; noteId: string; diagramId: string; source: string; description?: string } - // Generates an image via fal.ai from `prompt` and inserts it into the note. `section` - // (optional): a heading to place the image directly beneath (else appended at the end). + // Generates an image via fal.ai from `prompt` and inserts it into the note, placed by + // `section` / `anchorText` / `position` (see Placement; else appended at the end). // `alt` (optional): a short caption for the image block. The prompt is authored by the // model from the article content — it is NOT the user's raw request. - | { type: 'generate_image'; noteId: string; prompt: string; section?: string; alt?: string; description?: string } + | ({ type: 'generate_image'; noteId: string; prompt: string; section?: string; alt?: string; description?: string } & Placement) // Recipes: saved, reusable prompts the user runs later from the composer's picker or by // voice ("run the summary recipe") instead of retyping them — see the "Recipes" rule below. | { type: 'create_recipe'; name: string; prompt: string; tags?: string[]; description?: string } @@ -117,18 +127,18 @@ Action types (every action MAY also include an optional "description": one short - edit_section: { "type":"edit_section", "noteId":"", "section":"", "content":"" } - append_note: { "type":"append_note", "noteId":"", "content":"" } - rename_note: { "type":"rename_note", "noteId":"", "title":"" } -- create_child_note: { "type":"create_child_note", "parentId":"", "title":"", "content":"<markdown>", "ref":"<optional local label>" } +- create_child_note: { "type":"create_child_note", "parentId":"<id>", "title":"<title>", "content":"<markdown>", "ref":"<optional local label>", "section":"<optional heading>", "anchorText":"<optional>", "position":"<optional>" } (placement: see "Placing blocks") - move_note: { "type":"move_note", "noteId":"<id>", "folderId":"<id>"|null } - set_tags: { "type":"set_tags", "noteId":"<id>", "tags":["..."], "mode":"replace"|"add" } - set_category: { "type":"set_category", "noteId":"<id>", "categoryId":"<id>" } - create_folder: { "type":"create_folder", "name":"<name>", "parentFolderId":"<id>"|null, "ref":"<optional local label>" } -- add_reference: { "type":"add_reference", "noteId":"<id>", "referenceNoteId":"<id>", "referenceTitle":"<title>", "insertAfterSection":"<optional heading>" } +- add_reference: { "type":"add_reference", "noteId":"<id>", "referenceNoteId":"<id>", "referenceTitle":"<title>", "section":"<optional heading>", "anchorText":"<optional>", "position":"<optional>" } (placement: see "Placing blocks") - add_annotation: { "type":"add_annotation", "noteId":"<id>", "anchorText":"<verbatim snippet of the block to attach to>", "text":"<markdown annotation>" } - edit_annotation: { "type":"edit_annotation", "noteId":"<id>", "annotationId":"<id>", "text":"<new markdown annotation>" } - delete_annotation: { "type":"delete_annotation", "noteId":"<id>", "annotationId":"<id>" } -- create_diagram: { "type":"create_diagram", "noteId":"<id>", "source":"<complete Mermaid diagram source>" } +- create_diagram: { "type":"create_diagram", "noteId":"<id>", "source":"<complete Mermaid diagram source>", "section":"<optional heading>", "anchorText":"<optional>", "position":"<optional>" } (placement: see "Placing blocks") - edit_diagram: { "type":"edit_diagram", "noteId":"<id>", "diagramId":"<id>", "source":"<complete replacement Mermaid diagram source>" } -- generate_image: { "type":"generate_image", "noteId":"<id>", "prompt":"<detailed text-to-image prompt>", "section":"<optional heading to insert under>", "alt":"<optional caption>" } +- generate_image: { "type":"generate_image", "noteId":"<id>", "prompt":"<detailed text-to-image prompt>", "section":"<optional heading>", "anchorText":"<optional>", "position":"<optional>", "alt":"<optional caption>" } (placement: see "Placing blocks") - create_recipe: { "type":"create_recipe", "name":"<short name>", "prompt":"<self-contained prompt text>", "tags":["optional", "lowercase", "tags"] } - update_recipe: { "type":"update_recipe", "recipeId":"<id>", "name":"<optional new name>", "prompt":"<optional new prompt>", "tags":["optional new tags"] } - delete_recipe: { "type":"delete_recipe", "recipeId":"<id>" } @@ -152,9 +162,14 @@ Rules: - To ADD content, use append_note or edit_note "amend". These keep ALL existing content, including embedded child notes, note references, links and images. - To CHANGE an existing section, use edit_section: set "section" to that section's heading text and "content" to the new Markdown for the whole section (include the heading). Only that section is rewritten; every other section is preserved untouched. - Use edit_note "replace" ONLY when the user explicitly asks to rewrite the ENTIRE note. It discards all other sections, formatting and embedded blocks, so avoid it for section-level changes. +- Placing blocks (generate_image, create_diagram, add_reference, create_child_note — IMPORTANT): each of these inserts ONE block into a note. Say where with these optional fields: + - "anchorText": the opening words (about 6–12, copied verbatim) of the existing paragraph, list item, quote or other block to put it next to. Use it WHENEVER the user places something relative to a paragraph or any other non-heading block ("above every paragraph", "after the paragraph about X", "below the list", "before the quote") — a heading alone cannot express that, and every block would end up stacked directly beneath the heading. Quote text that already exists in the note when the action runs: the body you were given, or text an earlier action in this plan writes inline in "content" (never a deferred "spec" body, whose words are not known yet). + - "position": with "anchorText", "before" puts the block directly above that paragraph and "after" (the default) directly below it. With only "section", "after" (the default) puts it directly beneath the heading, "before" just above the heading, and "end" after the last block of that section. + - "section": a heading's text. With "anchorText" it narrows the search to that section — set it whenever the paragraph sits in a known section/chapter, so the same wording elsewhere in the note cannot capture the block. On its own it places the block relative to that heading (see "position"). + With none of these the block is appended at the end of the note. For "one per paragraph" requests, emit one action PER PARAGRAPH, each with its own "anchorText" taken from that paragraph — e.g. "add an image above every paragraph in chapter 2" → one generate_image per paragraph of chapter 2, each with "section":"Chapter 2", "anchorText":"<that paragraph's opening words>", "position":"before". - Annotations: a note's existing annotations are listed under it as "Annotations on this note" with an "[annotation <id>]" and the snippet of the block they are anchored to. To edit/delete one, use its "<id>" as "annotationId". To add one, set "anchorText" to a short verbatim snippet of the block the annotation should attach to (it is matched against the note's block text). When asked to "read the annotations and revise the note", read these annotation texts and apply the implied edits with edit_section / edit_note / append_note actions. - Diagrams: use create_diagram to ADD a new diagram to a note, and edit_diagram to change an existing one. A note's existing diagrams are listed under it as "Diagrams on this note" with a "[diagram <id>]" tag and their current Mermaid source — use that "<id>" as "diagramId" for edit_diagram (which REPLACES the whole diagram, so "source" must be the complete new diagram, not a fragment). "source" must be complete, valid Mermaid syntax starting with the right header keyword for the kind: "flowchart TD" (or LR/BT/RL) for flow charts, "mindmap" for mind maps, "sequenceDiagram" for sequence diagrams, "classDiagram" for class diagrams, "stateDiagram-v2" for state diagrams, "erDiagram" for entity-relationship diagrams, "gantt" for Gantt charts, "pie" for pie charts, "timeline" for timelines. Node linking: in flowchart, classDiagram and stateDiagram-v2 ONLY, a node can link to another note or a URL by adding a line "click <nodeId> href \"/notes/<id>\"" (linking to a note id from the lists below) or "click <nodeId> href \"<url>\" \"_blank\"" (linking to the web) — do NOT add click/href lines for mindmap, sequenceDiagram, erDiagram, gantt, pie or timeline diagrams, since Mermaid does not support node links on those kinds (mindmap link support is a currently open Mermaid limitation). Only create or edit a diagram when the user explicitly asks for one (e.g. "make a mind map of this note", "add a step to the flow chart"). -- Images (generate_image): Use ONLY when the user explicitly asks to create/generate/add an image, picture, illustration or photo (e.g. "make an image for this article", "add a picture", "create an image for each chapter and put it under each title"). YOU author the "prompt": write a vivid, self-contained text-to-image prompt derived from the relevant article content (describe subject, setting, style, mood, composition) — do NOT just copy the user's request verbatim. Set "section" to a section/chapter heading's text to insert the image directly beneath that heading; omit "section" to append the image at the end of the note. For "an image for each chapter/section", emit ONE generate_image action per chapter — each with that chapter's heading text as "section" and its own prompt tailored to that chapter. Optionally set "alt" to a short caption. Generating images costs money, so create only the images the user asked for and no more. +- Images (generate_image): Use ONLY when the user explicitly asks to create/generate/add an image, picture, illustration or photo (e.g. "make an image for this article", "add a picture", "create an image for each chapter and put it under each title"). YOU author the "prompt": write a vivid, self-contained text-to-image prompt derived from the relevant article content (describe subject, setting, style, mood, composition) — do NOT just copy the user's request verbatim. Place it as described in "Placing blocks": "section" alone puts it directly beneath that heading, "anchorText" + "position" puts it above or below a specific paragraph, and neither appends it at the end of the note. For "an image for each chapter/section", emit ONE generate_image action per chapter — each with that chapter's heading text as "section" and its own prompt tailored to that chapter; for "an image for each paragraph", emit one per paragraph, each anchored to its paragraph. Optionally set "alt" to a short caption. Generating images costs money, so create only the images the user asked for and no more. - Recipes (create_recipe / update_recipe / delete_recipe): a Recipe is a saved, reusable prompt the user can run later — from a picker in the AI composer, or by voice ("run the summary recipe") — instead of retyping it each time. The user's existing recipes are listed below under "Recipes". Only create, update or delete a recipe when the user EXPLICITLY asks you to (e.g. "make a recipe that…", "save this as a recipe called…", "create a recipe for X", "rename/update/delete the X recipe") — never as a side effect of an unrelated request, and never in place of simply explaining how recipes work (use a "respond" action for that). When authoring "prompt": it will be sent later as a brand-new message with NO memory of the current conversation, so write it fully self-contained — never reference "this", "what we just discussed", or anything specific to the current chat. Use the placeholders {{title}} (the note open when the recipe is later run), {{selected text}} (the user's text selection at that time) and {{date}} (that day's date) so the recipe adapts to whatever it's run against, instead of hard-coding today's specifics. Give it a short, descriptive "name" and 0+ short lowercase "tags" for grouping (e.g. ["summary"]); omit "tags" if none apply. For update_recipe/delete_recipe, "recipeId" MUST be an id from the "Recipes" list below — never invent one — and update_recipe should omit any field the user isn't changing. - If the request targets a note that is not listed below, or you otherwise lack the context to fulfil it, return ONLY a single respond action that explains what the user needs to add to the context. Do not guess or fabricate. - Output ONLY the JSON object. No explanations and no code fences around it.` @@ -261,6 +276,28 @@ function asString(v: unknown): string | undefined { return typeof v === 'string' ? v : undefined } +// What the model may write for a block's `position`, folded onto the three the +// executor knows. It is told "before"/"after", but "above"/"below" is how the user said +// it, and a placement dropped for a synonym is an image back under the heading. A Map, +// not an object literal: an object's `['constructor']` would find Object's prototype. +// Mirrors POSITION_ALIASES in plan_parse.py. +const POSITION_ALIASES = new Map<string, PlacementPosition>([ + ['before', 'before'], ['above', 'before'], + ['after', 'after'], ['below', 'after'], ['under', 'after'], ['beneath', 'after'], + ['end', 'end'], +]) + +// Where a block-inserting action puts its block, beyond its section — a quoted snippet +// of the block to sit next to, and which side of it. +function anchorPlacement(a: Record<string, unknown>): Placement { + const anchorText = asString(a.anchorText) + const position = POSITION_ALIASES.get((asString(a.position) ?? '').trim().toLowerCase()) + return { + ...(anchorText && anchorText.trim() ? { anchorText } : {}), + ...(position ? { position } : {}), + } +} + function validateAction(raw: unknown): PlanAction | null { if (typeof raw !== 'object' || raw === null) return null const a = raw as Record<string, unknown> @@ -336,7 +373,8 @@ function validateAction(raw: unknown): PlanAction | null { const parentId = asString(a.parentId) const title = asString(a.title) if (!parentId || title === undefined) return null - return { type: 'create_child_note', parentId, title, content: asString(a.content) ?? '', ...sp, ...r, ...d } + const section = asString(a.section) + return { type: 'create_child_note', parentId, title, content: asString(a.content) ?? '', ...sp, ...r, ...(section ? { section } : {}), ...anchorPlacement(a), ...d } } case 'move_note': { const noteId = asString(a.noteId) @@ -364,8 +402,10 @@ function validateAction(raw: unknown): PlanAction | null { const referenceNoteId = asString(a.referenceNoteId) const referenceTitle = asString(a.referenceTitle) if (!noteId || !referenceNoteId || referenceTitle === undefined) return null - const insertAfterSection = asString(a.insertAfterSection) - return { type: 'add_reference', noteId, referenceNoteId, referenceTitle, insertAfterSection, ...d } + // `section` is accepted too: every other block-inserting action calls it that, and + // the instructions now do for this one as well. + const insertAfterSection = asString(a.insertAfterSection) ?? asString(a.section) + return { type: 'add_reference', noteId, referenceNoteId, referenceTitle, insertAfterSection, ...anchorPlacement(a), ...d } } case 'add_annotation': { const noteId = asString(a.noteId) @@ -389,7 +429,8 @@ function validateAction(raw: unknown): PlanAction | null { const noteId = asString(a.noteId) const source = asString(a.source) if (!noteId || !source?.trim()) return null - return { type: 'create_diagram', noteId, source, ...d } + const section = asString(a.section) + return { type: 'create_diagram', noteId, source, ...(section ? { section } : {}), ...anchorPlacement(a), ...d } } case 'edit_diagram': { const noteId = asString(a.noteId) @@ -404,7 +445,7 @@ function validateAction(raw: unknown): PlanAction | null { if (!noteId || !prompt?.trim()) return null const section = asString(a.section) const alt = asString(a.alt) - return { type: 'generate_image', noteId, prompt, ...(section ? { section } : {}), ...(alt ? { alt } : {}), ...d } + return { type: 'generate_image', noteId, prompt, ...(section ? { section } : {}), ...anchorPlacement(a), ...(alt ? { alt } : {}), ...d } } case 'create_recipe': { const name = asString(a.name) @@ -686,6 +727,18 @@ function truncate(text: string, max = 80): string { return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine } +// Where a block-inserting action puts its block, as a phrase for its label: the quoted +// paragraph when it has one, else its section, else nothing (end of note). +function placementSuffix(action: Placement, section: string | undefined): string { + if (action.anchorText) { + return ` ${action.position === 'before' ? 'above' : 'below'} “${truncate(action.anchorText, 40)}”` + } + if (!section) return '' + if (action.position === 'before') return ` above “${section}”` + if (action.position === 'end') return ` at the end of “${section}”` + return ` under “${section}”` +} + // Human-readable label for a plan action, used in the confirmation preview. // `labelMap` resolves note/folder/category ids (and forward-ref labels) to names. export function defaultActionLabel(action: PlanAction, labelMap: Map<string, string>): string { @@ -708,18 +761,21 @@ export function defaultActionLabel(action: PlanAction, labelMap: Map<string, str case 'edit_section': return `Update section “${action.section}” in “${name(action.noteId)}”` case 'append_note': return `Append to note “${name(action.noteId)}”` case 'rename_note': return `Rename “${name(action.noteId)}” → “${action.title}”` - case 'create_child_note': return `Create child note “${action.title || 'Untitled'}” under “${name(action.parentId)}”` + case 'create_child_note': { + const placed = placementSuffix(action, action.section) + return `Create child note “${action.title || 'Untitled'}” under “${name(action.parentId)}”${placed ? `,${placed}` : ''}` + } case 'move_note': return `Move “${name(action.noteId)}” to ${action.folderId ? `folder “${name(action.folderId)}”` : 'the root'}` case 'set_tags': return `${action.mode === 'add' ? 'Add tags to' : 'Set tags on'} “${name(action.noteId)}”: ${action.tags.join(', ')}` case 'set_category': return `Set category of “${name(action.noteId)}” to “${name(action.categoryId)}”` case 'create_folder': return `Create folder “${action.name}”` - case 'add_reference': return `Add reference to “${action.referenceTitle}” in “${name(action.noteId)}”${action.insertAfterSection ? ` under “${action.insertAfterSection}”` : ''}` + case 'add_reference': return `Add reference to “${action.referenceTitle}” in “${name(action.noteId)}”${placementSuffix(action, action.insertAfterSection)}` case 'add_annotation': return `Annotate “${truncate(action.anchorText, 40)}” in “${name(action.noteId)}”` case 'edit_annotation': return `Edit annotation in “${name(action.noteId)}”` case 'delete_annotation': return `Delete annotation in “${name(action.noteId)}”` - case 'create_diagram': return `Create ${DIAGRAM_KIND_LABELS[detectMermaidKind(action.source)].toLowerCase()} in “${name(action.noteId)}”` + case 'create_diagram': return `Create ${DIAGRAM_KIND_LABELS[detectMermaidKind(action.source)].toLowerCase()} in “${name(action.noteId)}”${placementSuffix(action, action.section)}` case 'edit_diagram': return `Update diagram in “${name(action.noteId)}”` - case 'generate_image': return `Generate image${action.section ? ` under “${action.section}”` : ''} in “${name(action.noteId)}”: ${truncate(action.prompt, 60)}` + case 'generate_image': return `Generate image${placementSuffix(action, action.section)} in “${name(action.noteId)}”: ${truncate(action.prompt, 60)}` case 'create_recipe': return `Create recipe “${action.name}”` case 'update_recipe': return `Update recipe “${name(action.recipeId)}”` case 'delete_recipe': return `Delete recipe “${name(action.recipeId)}”`