refactor(notes): a snippet's and lesson's stored title is its name; the trigger joins it only in the embedded document (milestone 427)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / integration (push) Successful in 52s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / Python tests (push) Successful in 1m35s
CI & Build / Build & push image (push) Successful in 32s

The title was `subject — trigger` because the stored title WAS the
embedded one, and the join is what makes these kinds rank on the
situation they apply to (#2485). Every surface that shows a title then
showed the trigger too -- menus, lists and search rows ran to kilobytes.

- embeddings.document_title(title, note_type, data, body) joins the
  trigger from `data` (body fallback) at embed time. Idempotent: an
  un-migrated composed title comes out the same, never doubled. The
  embed path, the startup backfill and the dedup gate's semantic signal
  all use it, so the embedded text -- and every vector -- is unchanged.
- Writers store the subject: snippet create/update (service, REST, MCP)
  and lesson_document. Both compose_title helpers are removed.
- Readers: dedup takes `data`; the menus strip the embedded title from a
  passage; list rows project `when_to_use`, which SnippetListView reads.
- 0108 rewrites existing rows on an exact `' — ' || <own trigger>`
  suffix with raw SQL, leaving updated_at alone so the backfill does not
  re-embed the corpus for identical vectors. Downgrade recomposes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 16:48:28 -04:00
co-authored by Claude Opus 5.5
parent bb632c4196
commit 66e21a6c60
24 changed files with 358 additions and 189 deletions
+1
View File
@@ -168,6 +168,7 @@ async def create_lesson(
dup = await dedup_svc.find_duplicate_note(
uid, title, body, project_id=project_id or None,
is_task=False, note_type=lessons_svc.LESSON_NOTE_TYPE,
data=lessons_svc.compose_data(what, when_to_apply),
)
if dup is not None:
return dedup_svc.duplicate_response(dup, "lesson")
+4 -1
View File
@@ -176,7 +176,9 @@ async def create_snippet(
raise ValueError("create_snippet requires a non-empty name and code")
uid = current_user_id()
title = snippets_svc.compose_title(name, when_to_use)
# The NAME is the title (milestone 427); the trigger rides in `data` and
# joins the title only in the embedded document.
title = name.strip()
body = snippets_svc.compose_body(
code=code, language=language, signature=signature,
when_to_use=when_to_use, repo=repo, path=path, symbol=symbol,
@@ -190,6 +192,7 @@ async def create_snippet(
# location and code before it compares prose (#2518).
code=code,
locations=snippets_svc.resolve_locations(repo, path, symbol, locations),
data=snippets_svc.compose_data(name=name, when_to_use=when_to_use),
)
if dup is not None:
return dedup_svc.duplicate_response(dup, "snippet")
+1
View File
@@ -149,6 +149,7 @@ async def create_lesson_route():
project_id=project_id,
is_task=False,
note_type=lessons_svc.LESSON_NOTE_TYPE,
data=lessons_svc.compose_data(what, when_to_apply),
)
if dup is not None:
return jsonify(dedup_svc.duplicate_response(dup, "lesson")), 409
+4 -1
View File
@@ -96,7 +96,7 @@ async def create_snippet_route():
if not data.get("force"):
dup = await dedup_svc.find_duplicate_note(
uid,
snippets_svc.compose_title(name, data.get("when_to_use", "")),
name.strip(),
snippets_svc.compose_body(
code=data.get("code", ""),
language=data.get("language", ""),
@@ -119,6 +119,9 @@ async def create_snippet_route():
data.get("repo", ""), data.get("path", ""), data.get("symbol", ""),
data.get("locations"),
),
data=snippets_svc.compose_data(
name=name, when_to_use=data.get("when_to_use", ""),
),
)
if dup is not None:
return jsonify(dedup_svc.duplicate_response(dup, "snippet")), 409
+11 -1
View File
@@ -248,6 +248,7 @@ async def find_duplicate_note(
note_type: str = "note",
code: str = "",
locations: list[dict] | None = None,
data: dict | None = None,
) -> DuplicateMatch | None:
"""Best near-duplicate of (title, body) within the same owner + project +
kind, or None. Title match first (cheap, exact), then — for snippets — the
@@ -258,6 +259,11 @@ async def find_duplicate_note(
`code` and `locations` are the snippet's structured fields. They are ignored
for every other kind, and passing them is what lets the gate compare
ARTEFACTS rather than descriptions of artefacts (#2518).
`data` is the candidate's structured mirror. For a snippet or lesson it
carries the trigger, which the TITLE no longer does (milestone 427): the
title check compares names, and the semantic check rebuilds the embedded
document from `data`.
"""
norm = " ".join((title or "").split()).lower()
@@ -309,7 +315,11 @@ async def find_duplicate_note(
# section. Capped so one pathological paste can't turn a save into
# dozens of searches — a duplicate past the cap is the duplicate
# report's job, not the gate's.
for query in embeddings_svc.chunk_document(title, body)[:_GATE_MAX_CHUNKS]:
# The EMBEDDED title (milestone 427): a snippet or lesson is stored
# under its name and embedded under `name — trigger`, so the query
# document is built the way the corpus was, from `data`.
doc_title = embeddings_svc.document_title(title, note_type, data, body)
for query in embeddings_svc.chunk_document(doc_title, body)[:_GATE_MAX_CHUNKS]:
# Scope the semantic check the same way as the title check: a record
# in project P compares only to P; a project-less (orphan) record
# compares only to other orphans (orphan_only), NOT across every
+57 -6
View File
@@ -214,10 +214,12 @@ TRIGGER_SEP = " — "
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.
ONE definition, because this join had three — rules, snippets, and a
fourth for lessons (milestone 385) — 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. Since milestone 427 it builds EMBEDDED titles only: `rule_document`
for rules and `document_title` for snippets and lessons. No stored title
carries it.
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
@@ -265,6 +267,51 @@ def untrigger_title(title: str | None, trigger: str | None) -> str:
return title
# The `data` key each trigger-keyed note kind mirrors its trigger under. Rules
# are not here: they keep the trigger in a column and `rule_document` builds
# their document from it.
_TRIGGER_DATA_KEYS = {"snippet": "when_to_use", "lesson": "when_to_apply"}
def document_title(
title: str | None, note_type: str | None, data: dict | None = None,
body: str | None = None,
) -> str | None:
"""The title a note is EMBEDDED under — its stored title, plus its trigger.
Milestone 427. A snippet's or lesson's STORED title is its subject alone;
the trigger lives in `data` (decision #4157). It still has to reach the
vector — the `subject — trigger` join is what makes these kinds rank on
the situation they apply to (#2485) — so it is joined HERE, at embed time,
rather than being carried in a title every listing then has to show.
IDEMPOTENT, and that is what makes the migration safe: a title that is
already composed (a row not yet migrated, an old backup restored) is
untriggered first, so it comes out the same and never doubled. The text is
byte-identical to what these kinds were embedded as before, so no vector
moves and the floors tuned against them stay calibrated.
`body` is the fallback when the mirror is missing, read by the kind's own
parser — the same degrade-to-the-body each kind's reader already has.
Every other kind, and a record with no trigger, keeps its title as-is.
"""
key = _TRIGGER_DATA_KEYS.get(note_type or "")
if key is None:
return title
trigger = ((data or {}).get(key) or "").strip() if isinstance(data, dict) else ""
if not trigger and body:
from types import SimpleNamespace
if note_type == "lesson":
from scribe.services.lessons import lesson_trigger
trigger = lesson_trigger(SimpleNamespace(data=None, body=body))
else:
from scribe.services.snippets import parse_snippet_fields
trigger = parse_snippet_fields(title or "", body).get("when_to_use", "")
if not trigger:
return title
return trigger_title(untrigger_title(title, trigger), trigger)
# --- chunking (#280): the document shape ------------------------------------
#
# bge-small reads at most 512 tokens and fastembed silently truncates the rest,
@@ -1081,10 +1128,14 @@ async def backfill_note_embeddings() -> None:
)
success = 0
for note_id in notes_to_embed:
row = await _current_row((Note.user_id, Note.title, Note.body), Note.id, note_id)
row = await _current_row(
(Note.user_id, Note.title, Note.body, Note.note_type, Note.data), Note.id, note_id,
)
if row is None:
continue # deleted between the scan and here
user_id, title, body = row
user_id, title, body, note_type, data = row
# The EMBEDDED title, as the write path builds it (milestone 427).
title = document_title(title, note_type, data, body)
if not chunk_document(title, body):
continue
await upsert_note_embedding(note_id, user_id, title, body)
+5
View File
@@ -267,6 +267,11 @@ def _note_to_item(note: Note, chunks: dict[int, dict] | None = None) -> dict:
trigger = (note.data or {}).get("when_to_apply") if note.data else None
if trigger:
item["when_to_apply"] = trigger
# A snippet's, for the same reason — and since milestone 427 the title no
# longer carries it, so without this a list shows names with no situation.
usage = (note.data or {}).get("when_to_use") if note.data else None
if usage:
item["when_to_use"] = usage
verdict = (note.data or {}).get("verification") if note.data else None
if verdict and verdict.get("status"):
+28 -44
View File
@@ -26,20 +26,23 @@ 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``.
mirrored into the head of the body — the shape snippets already use for
``when_to_use``. Not a column on ``notes``. Since milestone 427 it is NOT in the
stored title: the title is the lesson's subject, and the trigger joins it only
in the embedded document (``embeddings.document_title``).
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
an agent types a title convention correctly: the service composed 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.
The trigger in the document is what makes the vector sharp. It reaches it
twice — in the embedded title, joined at embed time, and in the body's first
line — which is the text these kinds were always embedded as, so nothing
re-embeds and ``CHUNKER_VERSION`` does not move.
WHAT A LESSON INHERITS, AND THE CELLS LEFT EMPTY ON PURPOSE (#3163)
@@ -207,36 +210,17 @@ def sole_source(sources: list[int] | None) -> int | None:
return ids[0] if len(ids) == 1 else None
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 = "", learned_from: list[int] | None = None,
) -> 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.
The trigger's home in the text of the document, and half of what makes a
lesson findable: `chunk_document` joins `{embedded title}\\n{body}`, and
the embedded title is `what — when it applies` (`embeddings.document_title`,
milestone 427), so the document 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.
`**When to apply:**` rather than plain text: the body is the READABLE
form, `data` is the queryable mirror, and `_BODY_TRIGGER_RE` reads this
@@ -275,23 +259,23 @@ def lesson_document(
what: str, when_to_apply: str = "", insight: str = "",
learned_from: list[int] | None = None,
) -> tuple[str, str]:
"""The (title, body) a lesson is STORED — and therefore embedded — as.
"""The (title, body) a lesson is STORED 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.
One call so the two halves cannot be composed apart. The body's first line
carries the trigger; a lesson without it (and without the `data` mirror)
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.
The title is the SUBJECT alone (milestone 427). It used to carry the
trigger too, so the stored record was itself the sharp document — and every
listing, menu and search row then showed a title that ran to kilobytes.
The trigger now joins the title at embed time (`embeddings.document_title`,
reading `data`), producing the same text as before, so nothing re-embeds
and `CHUNKER_VERSION` does not move.
"""
return (
compose_title(what, when_to_apply),
(what or "").strip(),
compose_body(insight, when_to_apply, learned_from),
)
+9 -3
View File
@@ -115,11 +115,17 @@ def embed_note(note) -> None:
try:
import asyncio
from scribe.services.embeddings import upsert_note_embedding
from scribe.services.embeddings import document_title, upsert_note_embedding
# Chunking and the empty-record gate live inside upsert_note_embedding —
# one path for every writer (#280).
# one path for every writer (#280). The title is the EMBEDDED one: a
# snippet's or lesson's trigger joins its name here, not in the stored
# title (milestone 427).
asyncio.create_task(
upsert_note_embedding(note.id, note.user_id, note.title, note.body)
upsert_note_embedding(
note.id, note.user_id,
document_title(note.title, note.note_type, note.data, note.body),
note.body,
)
)
except RuntimeError:
pass # no running loop — a sync caller, not a failure
+19 -5
View File
@@ -27,7 +27,11 @@ from scribe.services import projects as projects_svc
from scribe.services import shape_ledger as shape_ledger_svc
from scribe.services import snippets as snippets_svc
from scribe.services.access import label_shared_items, owner_names_for
from scribe.services.embeddings import semantic_search_notes, semantic_search_rules
from scribe.services.embeddings import (
document_title,
semantic_search_notes,
semantic_search_rules,
)
from scribe.services.lessons import LESSON_NOTE_TYPE
from scribe.services.note_usage import record_surfaced
from scribe.services.rule_usage import record_rule_surfaced
@@ -88,8 +92,10 @@ def _menu_name(title: str | None, note_type: str | None, data=None, body: str |
def _menu_passage(title: str | None, chunk_text: str | None, name: str = "") -> str:
"""The matched chunk on one line, without the title it was embedded under.
Every chunk is `title\nsection` (`embeddings.embedding_text`), so the title
prefix is stripped exactly. A chunk that WAS only the title — a short
Every chunk is `title\nsection` (`embeddings.embedding_text`), and `title`
here must be the EMBEDDED one (`embeddings.document_title`) — for a snippet
or lesson that is `name — trigger`, not the stored name — so the prefix is
stripped exactly. A chunk that WAS only the title — a short
record, or the head chunk of one — matched on the title, and for a
trigger-keyed kind the part of it the name line no longer shows is the
trigger: that is returned, because it is precisely what matched.
@@ -1235,7 +1241,10 @@ async def build_autoinject_hint(
# queries and so are not in this search's report. No fallback to the
# body's opening: on a menu that would be a line of preamble dressed as
# a reason, and a reader cannot tell the two apart once indented alike.
passage = _menu_passage(note.title, (menu_chunks.get(nid) or {}).get("text"), name)
passage = _menu_passage(
document_title(note.title, note.note_type, note.data, note.body),
(menu_chunks.get(nid) or {}).get("text"), name,
)
if passage:
lines.append(f"> ↳ {passage}")
@@ -2324,6 +2333,10 @@ async def build_write_path_hint(
# (#4364): the line is built from facts, not from
# re-reading its own marker.
"name": _menu_name(note.title, note.note_type, note.data, note.body),
# What its chunks are prefixed with, for stripping.
"doc_title": document_title(
note.title, note.note_type, note.data, note.body,
),
"seen": int(note.id) in excluded,
# Carried, not re-read off the rendered marker. The
# marker is prose assembled for a human and it already
@@ -2510,7 +2523,8 @@ async def build_write_path_hint(
if item.get("seen"):
continue
passage = _menu_passage(
item.get("title"), (wp_chunks.get(int(item["id"])) or {}).get("text"),
item.get("doc_title") or item.get("title"),
(wp_chunks.get(int(item["id"])) or {}).get("text"),
item.get("name") or "",
)
if passage:
+2 -14
View File
@@ -54,18 +54,6 @@ UNSET: object = object()
# --- serialize: structured fields -> note (title/body/tags) ------------------
def compose_title(name: str, when_to_use: str = "") -> str:
"""`name — when to use` (or just `name` when no usage note is given).
The join itself lives in `embeddings.trigger_title`, which rules and
lessons build their titles from too. Kept as a named function here because
it is this module's public vocabulary and callers say `compose_title`.
"""
from scribe.services.embeddings import trigger_title
return trigger_title(name, when_to_use)
def compose_tags(language: str = "", tags: list[str] | None = None) -> list[str]:
"""Language (lowercased) first, then the `snippet` marker, then caller tags —
de-duplicated, order preserved."""
@@ -741,7 +729,7 @@ async def create_snippet(
locations = resolve_locations(repo, path, symbol, locations)
note = await notes_svc.create_note(
user_id,
title=compose_title(name, when_to_use),
title=name.strip(),
body=compose_body(
code=code, language=language, signature=signature,
when_to_use=when_to_use, locations=locations,
@@ -898,7 +886,7 @@ async def update_snippet(
provenance = cur.get("provenance")
fields: dict = {
"title": compose_title(merged["name"], merged["when_to_use"]),
"title": (merged["name"] or "").strip(),
"body": compose_body(
code=merged["code"], language=merged["language"],
signature=merged["signature"], when_to_use=merged["when_to_use"],