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
+40
View File
@@ -317,6 +317,46 @@ def compose_data(
return data
def recompose_data(note) -> dict:
"""Rebuild a lesson's `data` mirror from its own title and body.
For the GENERIC note door. `update_lesson` composes the mirror itself from
the merged field set and never needs this; a plain `update_note(body=...)`
has no idea the mirror exists and would leave it behind.
THE COST OF LEAVING IT BEHIND IS HIGHER HERE THAN FOR A SNIPPET. A stale
snippet mirror reports the wrong path. A stale lesson mirror reports the
wrong TRIGGER — and `lesson_trigger` prefers the mirror, so the lesson goes
on being retrieved for the situation it used to name while displaying the
one it now names. The trigger is the entire retrieval story (step 3), so
that is not a degraded record; it is a record that fires at the wrong
moment and looks right when it does.
The body is the authority and the mirror is derived — already this file's
rule. This is its enforcement on the path that bypasses `update_lesson`.
The subject comes back out of the title through `untrigger_title`, the
inverse of the join that composed it, rather than by splitting on a
separator spelled a second time here.
"""
from scribe.services.embeddings import untrigger_title
body = getattr(note, "body", None) or ""
trigger_match = _BODY_TRIGGER_RE.search(body)
trigger = trigger_match.group(1).strip() if trigger_match else ""
what = untrigger_title(getattr(note, "title", None), trigger)
# Sources through the normal read, which already falls back body →
# arose_from_id. A body edit that drops the provenance line should drop
# the mirror's copy too: the body is the authority, and carrying a value
# the reader just deleted is the failure this function exists to prevent.
sources_match = _BODY_SOURCES_RE.search(body)
sources = (
normalize_sources(_ID_RE.findall(sources_match.group(1)))
if sources_match else []
)
return compose_data(what, trigger, sources)
async def create_lesson(
user_id: int,
*,