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

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:
2026-09-18 15:30:49 -04:00
co-authored by Claude Opus 5
parent 7038e41ec7
commit 0ab15d7d80
8 changed files with 433 additions and 8 deletions
+28 -1
View File
@@ -203,6 +203,33 @@ def embedding_text(title: str | None, body: str | None) -> str:
return f"{title}\n{body}".strip() if body else title
def trigger_title(subject: str | None, trigger: str | None) -> str:
"""`{subject}{trigger}` — the title half of a situation-keyed document.
ONE definition, because this join had three. `rule_document` built it for
rules, `snippets.compose_title` for snippets, and milestone 385 needed a
fourth for lessons — the shape #3207 records, where a fix or an improvement
then has to be found in N places by someone who does not know N.
WHY THE JOIN MATTERS AT ALL, measured in note #2485: the snippet was the
only sharp record in the corpus — a 0.153 top-to-second gap against
0.0100.023 for everything else — and the cause was this title plus the
same trigger repeated in the body, so purpose appears twice in a short
document and dominates the vector. Every kind that must be findable by WHEN
IT APPLIES rather than what it is about is built on this line.
Either side alone is returned as-is: a record with no trigger yet degrades
to its subject and still embeds, just less sharply — which is an argument
for backfilling triggers, not for padding the title with whatever text is
to hand.
"""
subject = (subject or "").strip()
trigger = (trigger or "").strip()
if subject and trigger:
return f"{subject}{trigger}"
return subject or trigger
# --- chunking (#280): the document shape ------------------------------------
#
# bge-small reads at most 512 tokens and fastembed silently truncates the rest,
@@ -785,7 +812,7 @@ def rule_document(
if not trigger:
return name or None, body or None
return (
f"{name}{trigger}" if name else trigger,
trigger_title(name, trigger),
f"When to apply: {trigger}\n\n{body}" if body else f"When to apply: {trigger}",
)