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
197 lines
9.5 KiB
Python
197 lines
9.5 KiB
Python
"""Lesson service — a transferable insight, retrievable by situation.
|
|
|
|
A *lesson* is a Note with ``note_type='lesson'``: a better way to think about a
|
|
problem, or a solution that transfers, recorded so a later session meets it at
|
|
the moment it applies — and **without binding the reader**.
|
|
|
|
WHY THE KIND EXISTS (milestone 385, from note #3727)
|
|
|
|
Rules were the only surface that is global AND situation-keyed, so an agent
|
|
holding a transferable insight had one door, and that door binds. The observed
|
|
symptom was sessions offering rule proposals for things that should not be
|
|
rules.
|
|
|
|
The gap is a document-shape fact, not a threshold:
|
|
|
|
- a note is embedded as ``title\\nbody`` and is findable by **what it is
|
|
about**;
|
|
- a rule is embedded as ``{title} — {trigger}`` with ``When to apply:``
|
|
repeated at the head of the body, so the trigger appears twice in a short
|
|
document and dominates the vector — findable by **when it applies**.
|
|
|
|
No tuning reaches across that: the field the query would match on is simply not
|
|
in a note's document. So a lesson carries a trigger and is embedded like a rule,
|
|
while staying a note in every other respect.
|
|
|
|
WHERE THE TRIGGER LIVES (decision #4157, milestone 385 step 1)
|
|
|
|
In ``notes.data`` under ``when_to_apply``, written through a named parameter and
|
|
mirrored into the title and the head of the body — the shape snippets already
|
|
use for ``when_to_use``. Not a column on ``notes``.
|
|
|
|
That decision was measured rather than assumed. The whole snippet corpus —
|
|
164 of 164 — carries a ``when_to_use`` with **no guard anywhere**, which refutes
|
|
the premise that an unenforced field gets skipped. What it does NOT show is that
|
|
an agent types a title convention correctly: ``compose_title`` builds the title
|
|
from the parameter, so what is at 100% is a named structured field. A column
|
|
would have bought enforceability at the price of deciding, for every note kind
|
|
at once, a question nothing had measured.
|
|
|
|
The mirror is what makes the vector sharp, and it is why nothing re-embeds:
|
|
``chunk_document`` is untouched, so ``CHUNKER_VERSION`` does not move. The
|
|
trigger reaches the document by being in the text, exactly as a snippet's is.
|
|
|
|
WHAT A LESSON INHERITS, AND THE CELLS LEFT EMPTY ON PURPOSE (#3163)
|
|
|
|
A new kind inherits the note machinery wholesale, and #3163 asks which parts it
|
|
should NOT get — so that an empty cell is a decision rather than an oversight.
|
|
|
|
Inherited, all deliberately:
|
|
|
|
- **versions** — a lesson is reworded as understanding improves, and what it
|
|
used to say is worth as much as any note's history.
|
|
- **supersession** — the event this most needs. A lesson replaced by a better
|
|
lesson is precisely what ``note_supersessions`` models, and the demotion
|
|
penalty already exists.
|
|
- **trash**, **the share ACL**, **tags**, **project and System tagging**,
|
|
**chunked embeddings**, **the near-duplicate gate**.
|
|
|
|
NOT inherited, and each for a stated reason:
|
|
|
|
- **status / task_kind / milestone_id** — a lesson is not work. ``is_task`` is
|
|
``status is not None``, so a lesson that acquired a status would become a
|
|
task and appear in open-work listings. This is the one cell where filling it
|
|
in by accident silently changes what the record IS.
|
|
- **recurrence** — task-only, and a lesson does not recur.
|
|
- **verify_with / expires_when** — available, because they are generic note
|
|
fields, but not part of a lesson's contract and not asked for on create. The
|
|
milestone-312 distinction is why: those mark a record that asserts a FACT
|
|
about someone else's software and can go false unwatched. A lesson is closer
|
|
to a norm — "a better way to think about this" has no truth value that rots
|
|
on its own. A lesson that does assert such a fact can still carry them.
|
|
|
|
WHY THERE IS NO MIGRATION
|
|
|
|
``note_type`` carries **no CHECK constraint** — only ``task_kind`` does
|
|
(``notes_task_kind_check``, migrations 0056 / 0065). Migration 0036 added
|
|
``note_type`` as plain ``Text`` with a server default and nothing has gated it
|
|
since. So rule 36 has nothing to expand here, and the failure it guards against
|
|
— a value the database refuses on an instance predating its migration — cannot
|
|
arise for this column.
|
|
|
|
The real vocabulary is ``services.knowledge._FACETS``, which is where a kind
|
|
becomes reachable on the browse surface and validated at the door. That is one
|
|
table feeding both dialects of the type filter, so adding a kind there is a
|
|
single edit — a property #3161 recommended and that landed before this.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
LESSON_NOTE_TYPE = "lesson"
|
|
|
|
# The key in `notes.data`. Named for the field it mirrors on `rules`, because it
|
|
# answers the same question and a reader who knows one should not have to learn
|
|
# a second word for it.
|
|
TRIGGER_KEY = "when_to_apply"
|
|
|
|
# The body's trigger line, and the pattern that reads it back. The body is the
|
|
# readable form and the thing that gets embedded; `data` is the queryable
|
|
# mirror. Reads prefer the mirror and fall back to this, which is the discipline
|
|
# `snippet_fields` follows and the reason a row written before the mirror
|
|
# existed is still readable.
|
|
_BODY_TRIGGER_RE = re.compile(r"^\*\*When to apply:\*\*\s*(.+?)\s*$", re.M)
|
|
|
|
|
|
def lesson_trigger(note) -> str:
|
|
"""When this lesson applies, or "" — the mirror first, then the body.
|
|
|
|
Prefers `data` for the same reason every snippet read does: it is indexed,
|
|
and parsing a body to answer a question the database can answer is how a
|
|
hot path ends up regexing markdown. The fallback is not dead code — it is
|
|
what makes a lesson readable if the mirror is ever absent, and an absent
|
|
mirror must degrade to the right answer rather than to silence.
|
|
"""
|
|
data = getattr(note, "data", None) or {}
|
|
from_mirror = (data.get(TRIGGER_KEY) or "").strip() if isinstance(data, dict) else ""
|
|
if from_mirror:
|
|
return from_mirror
|
|
match = _BODY_TRIGGER_RE.search(getattr(note, "body", None) or "")
|
|
return match.group(1).strip() if match else ""
|
|
|
|
|
|
def compose_title(what: str, when_to_apply: str = "") -> str:
|
|
"""`{what} — {when it applies}`, the half of the document that ranks.
|
|
|
|
Built HERE rather than asked of the caller, and that distinction is the
|
|
whole evidence base for this design: the snippet corpus is at 100% on its
|
|
trigger because a service composes the title from a named parameter, not
|
|
because agents type separators reliably. A caller made to spell the
|
|
convention is the option milestone 385 step 1 rejected.
|
|
|
|
The join is `embeddings.trigger_title` — shared with rules and snippets, so
|
|
the three kinds that rank on a trigger cannot drift apart in how they say
|
|
so.
|
|
"""
|
|
from scribe.services.embeddings import trigger_title
|
|
|
|
return trigger_title(what, when_to_apply)
|
|
|
|
|
|
def compose_body(insight: str, when_to_apply: str = "") -> str:
|
|
"""The lesson body — the trigger line first, the insight after.
|
|
|
|
The mirror of `compose_title` on the other half of the document, and the
|
|
reason the pair is what makes a lesson findable: `chunk_document` joins
|
|
them as `{title}\\n{body}`, so a lesson composed here states WHEN IT
|
|
APPLIES in the title and again in the first line of the body. That is the
|
|
twice-in-a-short-document shape note #2485 measured as the only sharp one
|
|
in the corpus, reached the way a snippet reaches it — by being in the text
|
|
— rather than by a second document builder at embed time.
|
|
|
|
`**When to apply:**` rather than plain text: the body is the READABLE
|
|
form, `data` is the queryable mirror, and `_BODY_TRIGGER_RE` reads this
|
|
line back when the mirror is missing. Its markdown must therefore match
|
|
what that pattern expects, which is why neither is written by hand
|
|
anywhere else.
|
|
|
|
The insight goes in the body rather than being held out of the document.
|
|
`rule_document` excludes a rule's `why` because long dated narrative made
|
|
sixteen dev-logs land on the centroid of "development" — but that finding
|
|
predates chunking (#280). A body over the budget is now split into several
|
|
chunks, EACH prefixed with the title, so a lesson's story no longer
|
|
averages itself into its trigger: it occupies its own vectors, and every
|
|
one of them still carries the trigger in its prefix. Holding it out would
|
|
cost the reader the only part that explains the insight and would buy a
|
|
sharpness the chunker already provides.
|
|
"""
|
|
lines = []
|
|
trigger = (when_to_apply or "").strip()
|
|
if trigger:
|
|
lines.append(f"**When to apply:** {trigger}")
|
|
insight = (insight or "").strip()
|
|
if insight:
|
|
lines.append(insight)
|
|
return "\n\n".join(lines)
|
|
|
|
|
|
def lesson_document(
|
|
what: str, when_to_apply: str = "", insight: str = "",
|
|
) -> tuple[str, str]:
|
|
"""The (title, body) a lesson is STORED — and therefore embedded — as.
|
|
|
|
One call so the two halves cannot be composed apart. A lesson whose title
|
|
carried the trigger and whose body did not would embed as an ordinary
|
|
note wearing a label, and nothing would report it: the record would look
|
|
right in every listing and simply never be retrieved at the moment it
|
|
applies.
|
|
|
|
Deliberately returns what is STORED, not a separate embed-time shape.
|
|
Rules need `rule_document` because a rule keeps its trigger in a column
|
|
and its title is a plain name, so the sharp document has to be synthesised
|
|
for the ranker and exists nowhere else. A lesson follows the snippet
|
|
instead — the stored record IS the sharp document — which is why nothing
|
|
re-embeds and `CHUNKER_VERSION` does not move.
|
|
"""
|
|
return compose_title(what, when_to_apply), compose_body(insight, when_to_apply)
|