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
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
140 lines
6.2 KiB
Python
140 lines
6.2 KiB
Python
"""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.010–0.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),
|
||
)
|