Files
FabledScribe/tests/test_lesson_kind.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

127 lines
5.8 KiB
Python

"""The `lesson` kind — its trigger, its title, and its place in the vocabulary.
WHY THIS EXISTS (milestone 385 step 2, #3729)
A lesson is a note that must be findable by WHEN IT APPLIES rather than by what
it is about. That is a document-shape fact: the trigger has to reach the
embedded text, and decision #4157 put it in `notes.data` with a mirror in the
title and the head of the body — the shape snippets already use.
THE ONE THAT MATTERS MOST
`test_one_join_builds_every_trigger_title`. Three kinds now rank on a
`{subject}{trigger}` title: rules, snippets and lessons. That join had three
implementations before this step and would have had four; #3207 records what
that costs. The guard is behavioural rather than `assert a is b`, because the
three are reached through different public names and a test that compared
identities would pass on a re-implementation that merely re-exported.
WHAT IS DELIBERATELY NOT TESTED HERE
That the database accepts `note_type='lesson'`. `note_type` carries no CHECK —
only `task_kind` does — so there is nothing to assert against in a unit test,
and asserting the absence would pin the schema's current shape rather than the
behaviour that matters. The real guard is in the integration lane, where a
lesson is written and read back: it holds whether or not a constraint exists,
and goes red the day one is added without this value.
"""
from types import SimpleNamespace
from scribe.services import knowledge as knowledge_svc
from scribe.services import lessons as lessons_svc
from scribe.services import snippets as snippets_svc
from scribe.services.embeddings import trigger_title
def _note(data=None, body=""):
return SimpleNamespace(data=data, body=body)
def test_the_trigger_is_read_from_the_indexed_mirror():
note = _note(data={"when_to_apply": "a stack trace, and it is still broken"})
assert lessons_svc.lesson_trigger(note) == "a stack trace, and it is still broken"
def test_the_body_answers_when_the_mirror_is_absent():
"""Not dead code. A row written before the mirror existed is still readable,
and an absent mirror has to degrade to the right answer rather than to
silence — the discipline `snippet_fields` follows for the same reason."""
note = _note(body="**When to apply:** about to reach for a second hypothesis\n\nbody")
assert lessons_svc.lesson_trigger(note) == "about to reach for a second hypothesis"
def test_the_mirror_wins_when_both_are_present():
note = _note(
data={"when_to_apply": "from the mirror"},
body="**When to apply:** from the body",
)
assert lessons_svc.lesson_trigger(note) == "from the mirror"
def test_no_trigger_reads_as_empty_rather_than_raising():
"""A lesson with no trigger is unreachable, not broken. Whatever refuses to
write one belongs on the write path; a reader's job is to say so plainly."""
assert lessons_svc.lesson_trigger(_note()) == ""
assert lessons_svc.lesson_trigger(_note(data={}, body="no trigger line here")) == ""
def test_one_join_builds_every_trigger_title():
"""THE GUARD. Rules, snippets and lessons rank on the same title shape.
Behavioural on purpose — see the module docstring. Each of the three is
called through the name its own callers use, so a fourth hand-rolled copy
fails here even though it would look correct in isolation.
"""
subject, trigger = "Pace hard debugging", "a stack trace and it is still broken"
expected = f"{subject}{trigger}"
assert trigger_title(subject, trigger) == expected
assert lessons_svc.compose_title(subject, trigger) == expected
assert snippets_svc.compose_title(subject, trigger) == expected
def test_a_subject_with_no_trigger_degrades_to_the_subject():
"""It still embeds, just less sharply — an argument for backfilling
triggers, not for padding the title with whatever text is to hand."""
assert trigger_title("debounce", "") == "debounce"
assert lessons_svc.compose_title(" debounce ") == "debounce"
assert trigger_title("", "when it applies") == "when it applies"
def test_the_kind_is_in_the_browse_vocabulary():
"""A kind the browse surface does not know is a record nobody can filter to.
Asserted through the public table rather than a literal, because the door's
validation, the counts and both dialects of the type filter are all
generated from it (#3161) — so this is the one place that decides.
"""
assert lessons_svc.LESSON_NOTE_TYPE in knowledge_svc.FACET_TYPES
assert lessons_svc.LESSON_NOTE_TYPE in knowledge_svc.NON_TASK_FACETS
def test_the_global_kinds_list_names_the_lesson_and_nothing_else():
"""`embeddings.GLOBAL_NOTE_TYPES` spells "lesson" as a literal because
`services.lessons` imports `trigger_title` from that module, so importing
it back would close a cycle (milestone 385 step 3). The copy is pinned
here instead — the one thing a literal costs is that it can drift, and
this is what stops it.
"""
from scribe.services.embeddings import GLOBAL_NOTE_TYPES
assert GLOBAL_NOTE_TYPES == (lessons_svc.LESSON_NOTE_TYPE,)
def test_a_lesson_is_not_a_task_on_either_arm_of_the_filter():
"""The cell left empty on purpose (#3163).
`is_task` IS `status is not None`, so a lesson that acquired a status would
become a task and start appearing in open-work listings — the one way
filling a field in by accident changes what the record IS.
"""
assert knowledge_svc.facet_is_task(lessons_svc.LESSON_NOTE_TYPE) is False
lesson = SimpleNamespace(note_type="lesson", is_task=False, task_kind="work")
assert knowledge_svc.matches_facet(lesson, "lesson") is True
assert knowledge_svc.matches_facet(lesson, "task") is False
# And it does not answer to another kind's facet.
assert knowledge_svc.matches_facet(lesson, "note") is False