CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m4s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 44s
Step 2 of the Scribe -> Grimoire rename. - src/scribe -> src/grimoire, and every import, patch target, module path, hypercorn entry point and `python -m grimoire.restore` with it; pyproject, uv.lock and the frontend package are named grimoire. - Image git.fabledsword.com/bvandeusen/fabledgrimoire; CI's Postgres service runs as grimoire / grimoire_test. - Compose files default the DB user, database and (prod) volume to grimoire, each overridable from .env, so an install created as Scribe keeps its data by setting POSTGRES_USER, POSTGRES_DB and PGDATA_VOLUME. - Backups are written as grimoire-<ts>.tar with format grimoire-backup-tar. Archives named scribe-* with the scribe-backup-tar manifest still restore and are still counted at the destination (folder and S3), with tests. - Exports, the MCP auth realm, the ASGI scope key and the persistent logger carry the new name. Kept on purpose: the secret-sealing key label stays "scribe.secrets.v1", because every stored secret was derived under it. Plugin identity (plugin name, hooks, env vars, .scribe marker, skills) is step 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
128 lines
5.8 KiB
Python
128 lines
5.8 KiB
Python
"""The `lesson` kind — its trigger, its title, and its place in the vocabulary.
|
|
|
|
WHY THIS EXISTS
|
|
|
|
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, so it lives in `notes.data` with a mirror in the title and the
|
|
head of the body — the shape snippets 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, through ONE join —
|
|
a copy per kind is a fix that has to be found in three places. 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 grimoire.services import knowledge as knowledge_svc
|
|
from grimoire.services import lessons as lessons_svc
|
|
from grimoire.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
|
|
# The two note kinds reach it through the EMBEDDED title,
|
|
# from their own mirror key — never a hand-rolled join.
|
|
from grimoire.services.embeddings import document_title
|
|
assert document_title(subject, "lesson", {"when_to_apply": trigger}) == expected
|
|
assert document_title(subject, "snippet", {"when_to_use": 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.lesson_document(" debounce ")[0] == "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 — 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. The copy is pinned
|
|
here instead — the one thing a literal costs is that it can drift, and
|
|
this is what stops it.
|
|
"""
|
|
from grimoire.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.
|
|
|
|
`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
|