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
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:
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user