Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 206 additions & 26 deletions backend/app/assistant/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`.

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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},
Expand All @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Expand All @@ -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,
)
Expand Down Expand Up @@ -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,
)
Expand Down
43 changes: 41 additions & 2 deletions backend/app/assistant/plan_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────

Expand All @@ -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.

Expand Down Expand Up @@ -183,13 +206,16 @@ 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,
"title": title,
"content": _as_string(raw.get("content")) or "",
**spec_field,
**ref_field,
**({"section": section} if section else {}),
**_placement(raw),
**trailer,
}

Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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"))
Expand All @@ -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
Expand Down
Loading
Loading