feat(lessons): the lesson kind, and one join for every trigger title (#3729)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m5s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 10s
CI & Build / integration (push) Successful in 50s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / Python tests (push) Failing after 1m5s
CI & Build / Build & push image (push) Skipped
Milestone 385 step 2, implementing decision #4157 from the step-1 spike. The kind: `note_type='lesson'`, a note findable by WHEN IT APPLIES rather than by what it is about. The trigger lives in `notes.data.when_to_apply`, mirrored into the title and the head of the body — the shape snippets already use, and the reason nothing re-embeds: chunk_document is untouched, so CHUNKER_VERSION does not move. NO MIGRATION, and the step assumed there would be one. `note_type` carries no CHECK — only `task_kind` does (0056, 0065). Migration 0036 added it as plain Text with a server default and nothing has gated it since, so rule 36 has no whitelist to expand and #3128's failure mode (a value the database refuses) cannot arise for this column. The vocabulary that actually decides what a reader can reach is services.knowledge._FACETS, which since #3161 is one table feeding the door's validation, the counts and both dialects of the type filter — so the kind lands there in a single edit. ONE JOIN, not a fourth copy. `{subject} — {trigger}` had three implementations: rule_document, snippets.compose_title, and this step needed another. #3207 records what that costs, so the join moves to embeddings.trigger_title beside embedding_text and all three delegate. Behaviour is unchanged for rules and snippets; the guard calls each through its own public name, so a re-implementation fails it. The #3163 bill is stated in the service docstring rather than left to be inferred: versions, supersession, trash, the share ACL, tags, project and System tagging, chunked embeddings and the duplicate gate are all inherited; status/task_kind/milestone_id and recurrence are not, and verify_with/expires_when are available but outside the kind's contract. The status cell is the one that matters — `is_task` IS `status is not None`, so a lesson that acquired one would become a task. The integration guard asserts the WRITE rather than the constraint: it holds whether or not note_type is ever gated, and goes red only if it is gated without this value. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""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_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
|
||||
Reference in New Issue
Block a user