fix(lessons): a derived mirror survives the generic note door, by kind not by name (#3734)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 49s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 23s

Groundwork for step 7, and a data-integrity fix in its own right.

Two kinds keep a queryable mirror in `notes.data` derived from their body:
snippets and, since milestone 385, lessons. Every read prefers the mirror —
deliberately, because parsing markdown to answer what an index can answer is
how a hot path rots. So a write that moves the body must move the mirror.

#3128 found that hole for snippets and plugged it with a hard-coded
`if note.note_type == SNIPPET_NOTE_TYPE`. The plug was correct and did not
generalise: lessons arrived with the same design and none of the protection,
which is precisely the "don't add a fourth instance" defect #3734 was told to
avoid.

The cost is higher for a lesson. A stale snippet mirror reports the wrong
path. A stale lesson mirror reports the wrong TRIGGER, and the trigger is the
whole retrieval story — the lesson goes on firing for the situation it used
to name while displaying the one it now names. Silent, and confident.

So `update_note` now dispatches through `_mirror_recomposers()`, a
note_type -> recomposer table. A kind with a derived mirror is covered by
registering it, not by someone remembering to widen an if.

`lessons.recompose_data` is the lesson's entry. It recovers the subject with
`embeddings.untrigger_title` — new, and deliberately placed beside the join it
inverts rather than in the caller that wanted it, because a separator spelled
in two files is a separator that will one day be changed in one of them
(#3207). `TRIGGER_SEP` is now the one spelling, and `parse_snippet_fields`
uses it too; it had the third copy inline.

The two inverses stay distinct on purpose: a snippet partitions at the first
separator (its name is a symbol), a lesson strips an exact known suffix (its
subject may legitimately contain a dash). Different algorithms, one constant,
so they cannot disagree about where the seam is.

Provenance is DROPPED when the body drops it, which is the opposite call from
a snippet's `verification` — that is carried because it was never in the body
to delete. The body is the authority; carrying a value the reader just removed
is the failure the recompose exists to prevent.

Tests: test_snippet_mirror_generic_door.py becomes
test_derived_mirror_generic_door.py, since the concern is now plural. The
registry property is asserted directly (every kind with a mirror is in the
table; the dispatch names no kind inline), plus the lesson cases and the
join/inverse round-trip. `fake_lesson` moves to tests/helpers.py — it existed
in test_lesson_surfacing.py and a second copy was about to be written — and
gains the explicit `None`s `fake_snippet` carries, because update_note reads
`verify_with` and a MagicMock is truthy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-19 13:58:54 -04:00
co-authored by Claude Opus 5
parent 1ade956cd5
commit 1252d0e305
8 changed files with 462 additions and 137 deletions
+34 -1
View File
@@ -203,6 +203,12 @@ def embedding_text(title: str | None, body: str | None) -> str:
return f"{title}\n{body}".strip() if body else title
# The join between a situation-keyed record's subject and its trigger. A
# CONSTANT because `untrigger_title` below has to spell the same thing to undo
# it, and two literals that must match are one edit away from not matching.
TRIGGER_SEP = ""
def trigger_title(subject: str | None, trigger: str | None) -> str:
"""`{subject}{trigger}` — the title half of a situation-keyed document.
@@ -226,10 +232,37 @@ def trigger_title(subject: str | None, trigger: str | None) -> str:
subject = (subject or "").strip()
trigger = (trigger or "").strip()
if subject and trigger:
return f"{subject}{trigger}"
return f"{subject}{TRIGGER_SEP}{trigger}"
return subject or trigger
def untrigger_title(title: str | None, trigger: str | None) -> str:
"""The subject back out of a `trigger_title` — the inverse of the join.
Kept HERE, beside the join, for the reason the join itself was
consolidated: a separator spelled in two files is a separator that will one
day be changed in one of them. #3207 records the shape — derive it before
the third copy — and an inverse written in a caller is that third copy
wearing a different name.
Needs the trigger passed in rather than guessing at the separator, because
a subject may legitimately contain an em dash. Given the trigger, the
suffix is exact and the split cannot be wrong.
Degrades to the whole title when the suffix is absent — a record written
before the join existed, or one with no trigger yet, still answers with
something a human recognises rather than with "".
"""
title = (title or "").strip()
trigger = (trigger or "").strip()
if not trigger:
return title
suffix = f"{TRIGGER_SEP}{trigger}"
if title.endswith(suffix):
return title[: -len(suffix)].strip()
return title
# --- chunking (#280): the document shape ------------------------------------
#
# bge-small reads at most 512 tokens and fastembed silently truncates the rest,