Files
FabledScribe/tests/test_lesson_document_shape.py
T
bvandeusenandClaude Opus 5 1361ed7200
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 51s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m31s
CI & Build / Build & push image (push) Successful in 25s
feat(lessons): the document shape is the stored record, and it travels (#3730)
Milestone 385 step 3 — the step where the kind either works or is cosmetic.

THE DOCUMENT, and why there is no `lesson_document()` beside `rule_document()`
in embeddings. The step expected one. The difference is where the sharp shape
LIVES. A rule keeps its trigger in a column and its title is a plain name, so
`{title} — {trigger}` has to be synthesised at embed time and exists nowhere
else. A snippet — the only sharp record in the corpus by #2485's measurement,
0.153 top-to-second against 0.010–0.023 — gets there the other way: its STORED
title is already the join and its stored body already opens with the trigger,
so the ordinary `title\nbody` join IS the sharp document. Step 1 chose the
snippet route and step 2 built it, so `lessons.lesson_document` composes what
is STORED and the generic chunker does the rest.

The consequence the step asked about: `chunk_document` is untouched, so
CHUNKER_VERSION does not move and NOTHING re-embeds. The step's "Re-embed"
section describes a change this design does not make.

THE NARRATIVE stays in the body, departing from the step's instruction to keep
it out. `rule_document` excludes `why` because long dated narrative made
sixteen dev-logs land on the centroid of "development" — but that finding
predates chunking (#280). A body over budget is now split, and every chunk is
prefixed with the title, which for a lesson carries the trigger. The story
occupies its own vectors instead of averaging itself into the trigger's, and
each of those is still anchored to when the lesson applies. A guard asserts
exactly that. Holding the story out would cost the reader the only part that
explains the insight, to buy a sharpness the chunker already provides.

GLOBAL IN THE SEARCH is the real new code: `GLOBAL_NOTE_TYPES` and
`include_global_kinds` on `semantic_search_notes`, widening the PROJECT filter
alone. Off by default, because two callers depend on that filter holding — the
near-duplicate gate compares a record only against its own project on purpose,
and a globally visible kind there would let a lesson block an unrelated note's
create on a project its author never touched. It composes with `note_type`
rather than overriding it, so narrowing to snippets does not quietly acquire
lessons, and it changes nothing about the ACL: `notes_visibility_clause` still
gates every row.

Wired into the explicit MCP search only — the operator asked, and there is no
budget to spend. The unasked-for injection arms are step 5's subject (#3732)
and the legibility of a lesson appearing on a foreign project is step 7's
(#3734), so neither is turned on here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
2026-09-18 16:13:15 -04:00

140 lines
6.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The document a lesson is embedded as (milestone 385 step 3).
WHY THIS IS THE STEP THAT DECIDES THE MILESTONE
Everything before this is storage. A lesson stored with a trigger but embedded
as ordinary prose is a note wearing a label: it would look right in every
listing and simply never be retrieved at the moment it applies, and nothing
anywhere would report that.
WHY THERE IS NO `lesson_document()` BESIDE `rule_document()`
The step anticipated one. There isn't, and the difference is where the sharp
shape LIVES rather than whether it exists.
A rule keeps its trigger in a column and its title is a plain name, so the
`{title}{trigger}` document has to be synthesised at embed time and exists
nowhere else — that is what `rule_document` is for. A snippet, which note #2485
measured as the only sharp record in the corpus (a 0.153 top-to-second gap
against 0.0100.023 for everything else), gets there the other way: its STORED
title is already the join and its stored body already opens with the trigger,
so the ordinary `title\\nbody` join is the sharp document. A lesson follows the
snippet, which is what step 1 decided and step 2 built.
The consequence worth stating: `chunk_document` is untouched, so
`CHUNKER_VERSION` does not move and nothing re-embeds. The step's "Re-embed"
section describes a change this design does not make.
These guards therefore assert the composed record, then assert that the generic
chunker turns it into the intended document — the two halves of the same claim.
No similarity number is asserted anywhere: a threshold pins the embedder's
behaviour rather than this code's, and breaks on a model change that is not a
regression.
"""
from __future__ import annotations
from scribe.services import lessons as lessons_svc
from scribe.services.embeddings import chunk_document, embedding_text
TRIGGER = "a test fails on code you believe is correct"
SUBJECT = "Suspect the guard before the code"
INSIGHT = "Check whether the assertion still describes the property it was written for."
def test_the_trigger_appears_twice_in_the_document():
"""THE guard. Purpose stated twice in a short document is the entire
measured cause of a snippet's sharpness, and it is the one property that
distinguishes a lesson's vector from a plain note's."""
title, body = lessons_svc.lesson_document(SUBJECT, TRIGGER, INSIGHT)
document = embedding_text(title, body)
assert document.count(TRIGGER) == 2
# Once in each half, not twice in one of them.
assert TRIGGER in title
assert TRIGGER in body
def test_the_document_leads_with_when_it_applies():
"""The title is `{what}{when}` and the body's FIRST line restates it, so
the opening of the document is about the situation rather than the topic.
A lesson buried behind a paragraph of narrative would rank on the
narrative."""
title, body = lessons_svc.lesson_document(SUBJECT, TRIGGER, INSIGHT)
assert title == f"{SUBJECT}{TRIGGER}"
assert body.splitlines()[0] == f"**When to apply:** {TRIGGER}"
def test_a_short_lesson_is_exactly_one_chunk():
"""`chunk_document`'s first contract line: a record inside the window
yields one chunk identical to the historical `title\\nbody`. A lesson that
split into several would spread the trigger's weight across vectors that
each carry less of it."""
title, body = lessons_svc.lesson_document(SUBJECT, TRIGGER, INSIGHT)
chunks = chunk_document(title, body)
assert len(chunks) == 1
assert chunks[0].count(TRIGGER) == 2
def test_a_long_lesson_keeps_the_trigger_on_every_chunk():
"""The narrative question, answered by the chunker rather than by holding
the story out of the record.
`rule_document` excludes a rule's `why` because long dated narrative made
sixteen dev-logs land on the centroid of "development". That finding
predates chunking (#280): a body over budget is now split, and EVERY chunk
is prefixed with the title — which for a lesson carries the trigger. So the
story occupies its own vectors instead of averaging itself into the
trigger's, and each of those vectors is still anchored to when the lesson
applies.
This is why the insight stays in the body where a reader can see it. Holding
it out would cost the reader the only part that explains the lesson, to buy
a sharpness the chunker already provides.
"""
narrative = "\n\n".join(
f"## Section {i}\n" + ("An unrelated sentence about deployment. " * 40)
for i in range(6)
)
title, body = lessons_svc.lesson_document(SUBJECT, TRIGGER, narrative)
chunks = chunk_document(title, body)
assert len(chunks) > 1, "the fixture must actually exceed the chunk budget"
assert all(TRIGGER in chunk for chunk in chunks)
def test_a_lesson_with_no_trigger_still_embeds():
"""Degrades to title + insight, the way a rule with no trigger does — less
sharply, and still findable. That is an argument for prompting hard for a
trigger at write time, not for padding the document with whatever text is
to hand."""
title, body = lessons_svc.lesson_document(SUBJECT, "", INSIGHT)
assert title == SUBJECT
assert body == INSIGHT
assert chunk_document(title, body) == [f"{SUBJECT}\n{INSIGHT}"]
def test_the_composed_body_is_the_one_the_reader_is_parsed_back_from():
"""`compose_body` writes the trigger line and `lesson_trigger` reads it. A
lesson whose mirror in `data` is missing still answers correctly, so the
two must agree on the exact markdown — which is why neither is written by
hand at a call site."""
from types import SimpleNamespace
_, body = lessons_svc.lesson_document(SUBJECT, TRIGGER, INSIGHT)
no_mirror = SimpleNamespace(data=None, body=body)
assert lessons_svc.lesson_trigger(no_mirror) == TRIGGER
def test_the_title_and_body_are_composed_by_one_call():
"""`lesson_document` returns both halves so they cannot be built apart. A
title carrying the trigger over a body that does not would embed as an
ordinary note, and every listing would still look correct."""
assert lessons_svc.lesson_document(SUBJECT, TRIGGER, INSIGHT) == (
lessons_svc.compose_title(SUBJECT, TRIGGER),
lessons_svc.compose_body(INSIGHT, TRIGGER),
)