From 4ae18a9dd947501ac1c4477c0377f9c5e6f33841 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 23 Sep 2026 17:27:22 -0400 Subject: [PATCH] feat(snippets): a snippet has notes; when_to_use is the situation it is ranked on (#4378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A snippet had no field for prose, so what a session learned about one went into when_to_use — the trigger joined onto every chunk it is embedded as. A sweep found write-ups of up to 3 KB there, headings and all. - notes: stored after the code under `## Notes`, parsed back from the body, carried by every path that rebuilds it (update, merge, un-merge). A snippet with no notes composes the body it always did. - create/update_snippet (MCP) take notes and return trigger_advice when when_to_use is long, headed or multi-paragraph. Advice, not a refusal. - Tool docs, the reusing-code skill and the editor hint describe the trigger as the situation and point the explanation at notes. - Editor gains a Notes field; the detail view renders it as markdown. Co-Authored-By: Claude Opus 5.5 --- frontend/src/api/snippets.ts | 4 + frontend/src/views/SnippetDetailView.vue | 17 +++ frontend/src/views/SnippetEditorView.vue | 21 +++- plugin/.claude-plugin/plugin.json | 2 +- plugin/skills/reusing-code/SKILL.md | 8 +- src/scribe/mcp/tools/snippets.py | 44 +++++-- src/scribe/routes/snippets.py | 7 +- src/scribe/services/snippets.py | 77 ++++++++++-- tests/test_snippet_notes.py | 154 +++++++++++++++++++++++ 9 files changed, 313 insertions(+), 21 deletions(-) create mode 100644 tests/test_snippet_notes.py diff --git a/frontend/src/api/snippets.ts b/frontend/src/api/snippets.ts index 95274a8..801f9d4 100644 --- a/frontend/src/api/snippets.ts +++ b/frontend/src/api/snippets.ts @@ -30,6 +30,9 @@ export interface SnippetFields { * no `locations`/`tags` predates that attribution and cannot be un-merged. */ merged_from: { id: number; locations?: SnippetLocation[]; tags?: string[] }[]; code: string; + /** Free text that is not the situation — why, history, caveats (#4378). + * Kept out of `when_to_use`, which the snippet is ranked on. */ + notes: string; } /** A full snippet record: the note dict plus the parsed `snippet` sub-object, @@ -110,6 +113,7 @@ export interface SnippetInput { language?: string; signature?: string; when_to_use?: string; + notes?: string; locations?: SnippetLocation[]; tags?: string[]; project_id?: number | null; diff --git a/frontend/src/views/SnippetDetailView.vue b/frontend/src/views/SnippetDetailView.vue index f7d2720..37455fa 100644 --- a/frontend/src/views/SnippetDetailView.vue +++ b/frontend/src/views/SnippetDetailView.vue @@ -9,6 +9,7 @@ import { } from "@/api/snippets"; import { useToastStore } from "@/stores/toast"; import ConfirmDialog from "@/components/ConfirmDialog.vue"; +import { renderMarkdown } from "@/utils/markdown"; const route = useRoute(); const router = useRouter(); @@ -184,6 +185,11 @@ async function confirmDelete() {
{{ snippet.snippet.code }}
+
+

Notes

+
+
+
{{ t }}
@@ -343,6 +349,17 @@ async function confirmDelete() { cursor: help; } +.notes { + margin-top: 1.25rem; +} +.notes-heading { + margin: 0 0 0.5rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--fs-text-tertiary); +} + .code-block { border: 1px solid var(--fs-border-color); border-radius: var(--fs-radius-lg); diff --git a/frontend/src/views/SnippetEditorView.vue b/frontend/src/views/SnippetEditorView.vue index c535302..6a0e200 100644 --- a/frontend/src/views/SnippetEditorView.vue +++ b/frontend/src/views/SnippetEditorView.vue @@ -29,6 +29,7 @@ interface FormState { language: string; signature: string; when_to_use: string; + notes: string; } const blankLocation = (): SnippetLocation => ({ repo: "", path: "", symbol: "" }); @@ -39,6 +40,7 @@ const form = ref({ language: "", signature: "", when_to_use: "", + notes: "", }); // A snippet that unified several one-offs carries several locations; a fresh one // starts with a single blank row. @@ -126,6 +128,7 @@ async function load() { language: f.language, signature: f.signature, when_to_use: f.when_to_use, + notes: f.notes ?? "", }; locations.value = f.locations?.length ? f.locations.map((l) => ({ ...l })) @@ -169,6 +172,7 @@ async function save() { language: form.value.language.trim(), signature: form.value.signature.trim(), when_to_use: form.value.when_to_use.trim(), + notes: form.value.notes.trim(), locations: cleanLocations(), tags: parseTags(), project_id: projectId.value, @@ -243,7 +247,10 @@ function cancel() { placeholder="Debounce a reactive ref that updates too often" @keydown.escape="cancel" /> -

Shown in the recall menu — keep it sharp.

+

+ The situation it is for, in a sentence or two — the snippet is ranked + on this. Why it's shaped this way belongs in Notes. +

@@ -303,6 +310,18 @@ function cancel() { >
+
+ + +

Markdown. Shown with the snippet, after the code.

+
+
dict: """Record a shape in the project's pattern library, so every later instance starts from it instead of re-deriving it. @@ -136,8 +137,16 @@ async def create_snippet( language: Language/format, e.g. "python", "vue", "sql". Becomes a tag and the code-fence language. signature: One-line signature/interface, e.g. "debounce(fn, ms) -> fn". - when_to_use: One line on when to reach for it — this becomes part of the - title, so it's what a recall menu shows. Keep it sharp. + when_to_use: The situation to reach for it in — a sentence or two, e.g. + "Debouncing a reactive input before it triggers a fetch." This is + what the snippet is RANKED on: it is joined onto the name in every + vector the snippet is embedded as, so each extra paragraph blurs the + one situation it should surface for. Several distinct situations + are fine; the explanation is not — that goes in `notes`. + notes: Everything worth saying that is not the situation: why it is + shaped this way, what it replaced, caveats, history. Stored after + the code under its own heading and shown with the snippet. When a + later session learns something about the snippet, it goes here. repo/path/symbol: Canonical location of the reference implementation. locations: Several locations at once, as [{"repo","path","symbol"}, ...], when you already know the thing lives in more than one place. Takes @@ -154,6 +163,10 @@ async def create_snippet( later. Optional, but pass it whenever you're recording from a checkout. + When `when_to_use` reads like a write-up — long, several paragraphs, or + headed — the response carries `trigger_advice`: move the explanation into + `notes` with update_snippet. + Returns the created snippet (including a parsed `snippet` field), OR — when a duplicate already exists and force is false — {"duplicate": true, "existing_id": ..., "message": ...} and nothing is created. When that happens @@ -182,7 +195,7 @@ async def create_snippet( body = snippets_svc.compose_body( code=code, language=language, signature=signature, when_to_use=when_to_use, repo=repo, path=path, symbol=symbol, - locations=locations, + locations=locations, notes=notes, ) if not force: dup = await dedup_svc.find_duplicate_note( @@ -201,12 +214,15 @@ async def create_snippet( uid, name=name, code=code, language=language, signature=signature, when_to_use=when_to_use, repo=repo, path=path, symbol=symbol, locations=locations, tags=tags, project_id=project_id or None, - commit_sha=commit_sha, + commit_sha=commit_sha, notes=notes, ) if system_ids: await systems_svc.set_record_systems(uid, note.id, system_ids) data = snippets_svc.snippet_to_dict(note) await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None) + advice = snippets_svc.trigger_advice(when_to_use) + if advice: + data["trigger_advice"] = advice return data @@ -437,6 +453,7 @@ async def update_snippet( project_id: int = 0, system_ids: list[int] | None = None, commit_sha: str = "", + notes: str | None = None, ) -> dict: """Update a snippet. Only the fields you pass change. @@ -446,6 +463,12 @@ async def update_snippet( worse than none, so correcting downward has to be possible. Args: + when_to_use: The situation to reach for it in — a sentence or two. It + is what the snippet is ranked on, so correct it toward the + situation; an explanation belongs in `notes`. + notes: Replaces the free-text notes (why, history, caveats). Pass the + whole text — read the current notes from get_snippet first when + adding to them. locations: Replace the whole location set, as [{"repo","path","symbol"}, ...]. Pass [] to clear every location. The single repo/path/symbol args instead overlay onto the FIRST location, leaving the rest. @@ -476,7 +499,7 @@ async def update_snippet( signature=signature, when_to_use=when_to_use, repo=repo, path=path, symbol=symbol, locations=locations, tags=tags, project_id=project, - commit_sha=commit_sha or None, + commit_sha=commit_sha or None, notes=notes, ) except PermissionError as exc: # Readable but not writable — surface the real reason, not "not found". @@ -493,6 +516,11 @@ async def update_snippet( await systems_tools.attach_systems( uid, note.user_id, data, snippet_id, note.project_id ) + # Advised on the trigger as it now STANDS, not only when this call set it: + # an edit to anything else is the moment someone is already in the record. + advice = snippets_svc.trigger_advice(data["snippet"].get("when_to_use")) + if advice: + data["trigger_advice"] = advice return data diff --git a/src/scribe/routes/snippets.py b/src/scribe/routes/snippets.py index e748b94..e48f90b 100644 --- a/src/scribe/routes/snippets.py +++ b/src/scribe/routes/snippets.py @@ -32,7 +32,10 @@ logger = logging.getLogger(__name__) snippets_bp = Blueprint("snippets", __name__, url_prefix="/api/snippets") # Fields the create/update payload may carry, mapped straight to the service. -_STR_FIELDS = ("name", "code", "language", "signature", "when_to_use", "repo", "path", "symbol") +_STR_FIELDS = ( + "name", "code", "language", "signature", "when_to_use", "repo", "path", "symbol", + "notes", +) async def _load_snippet(uid: int, snippet_id: int): @@ -106,6 +109,7 @@ async def create_snippet_route(): path=data.get("path", ""), symbol=data.get("symbol", ""), locations=data.get("locations"), + notes=data.get("notes", ""), ), project_id=project_id, is_task=False, @@ -139,6 +143,7 @@ async def create_snippet_route(): locations=data.get("locations"), tags=data.get("tags"), project_id=project_id, + notes=data.get("notes", ""), ) if data.get("system_ids") is not None: await systems_svc.set_record_systems(uid, note.id, data["system_ids"]) diff --git a/src/scribe/services/snippets.py b/src/scribe/services/snippets.py index fbc4d7a..d6dfc45 100644 --- a/src/scribe/services/snippets.py +++ b/src/scribe/services/snippets.py @@ -10,12 +10,11 @@ form and the thing that gets embedded, so a snippet inherits everything a note has (embeddings, ACL, project/System association, dedup) and, crucially, becomes eligible for semantic recall the moment it's embedded: - - ``title`` = ``"{name} — {when_to_use}"``. The title is exactly what the - title-first auto-inject surfaces, so this one line self-describes the snippet - in a recall menu. + - ``title`` = ``name``. The trigger joins it only in the embedded document + (``embeddings.document_title``, milestone 427). - ``tags`` = ``[language, "snippet", *caller_tags]``. - ``body`` = templated markdown (When to use / Signature / Location, then a - fenced code block). + fenced code block, then an optional ``## Notes`` section). Since migration 0070 the same fields are ALSO written to ``notes.data`` (see ``compose_data``) — a queryable mirror, not a second source of truth: it carries @@ -45,6 +44,10 @@ logger = logging.getLogger(__name__) SNIPPET_NOTE_TYPE = "snippet" SNIPPET_TAG = "snippet" +# The body section free-text notes live under (#4378). A heading rather than a +# `**Notes:**` line because notes are paragraphs, and a heading is the boundary +# `embeddings.chunk_document` splits at. +NOTES_HEADING = "## Notes" # Sentinel for "argument not supplied" on update, so None stays available as a # real value meaning "clear this". Needed for project_id, where 0 is not a valid @@ -192,10 +195,19 @@ def compose_body( symbol: str = "", locations: list[dict] | None = None, merged_from: list[int] | None = None, + notes: str = "", ) -> str: """Render structured fields into the snippet body markdown. Empty fields are omitted so the body stays clean. + ``notes`` is the free text a snippet had no home for (#4378): why it is + shaped this way, what superseded what, the caveats. Without it that prose + went into ``when_to_use`` — the trigger, which is joined onto the title of + EVERY chunk the snippet is embedded as, so a 3 KB write-up there blurred + the one line the snippet is ranked on. It goes AFTER the code under its own + heading: short, it shares the snippet's one chunk; long, the chunker splits + it off at the heading into vectors of its own. + Locations: pass ``locations`` (a list of {repo,path,symbol}) for the general multi-location case; the single ``repo``/``path``/``symbol`` params remain as a back-compat shorthand for one location and are used only when ``locations`` @@ -224,9 +236,10 @@ def compose_body( ) fence_lang = (language or "").strip().lower() code_block = f"```{fence_lang}\n{(code or '').rstrip()}\n```" + tail = f"\n\n{NOTES_HEADING}\n\n{notes.strip()}\n" if (notes or "").strip() else "\n" if header: - return "\n\n".join(header) + "\n\n" + code_block + "\n" - return code_block + "\n" + return "\n\n".join(header) + "\n\n" + code_block + tail + return code_block + tail # --- parse: note -> structured fields (best-effort, never raises) ------------ @@ -240,6 +253,7 @@ _LOCS_RE = re.compile( _MERGED_RE = re.compile(r"^\*\*Merged from:\*\*\s*(.+?)\s*$", re.MULTILINE) _CODE_RE = re.compile(r"```([\w+.#-]*)\n(.*?)\n```", re.DOTALL) _ID_RE = re.compile(r"#(\d+)") +_NOTES_RE = re.compile(r"^## Notes[ \t]*\n(.*)\Z", re.MULTILINE | re.DOTALL) def _parse_location_str(s: str) -> dict | None: @@ -289,6 +303,7 @@ def parse_snippet_fields( "locations": [], "merged_from": [], "code": "", + "notes": "", } m = _WHEN_RE.search(body) @@ -331,6 +346,11 @@ def parse_snippet_fields( if m: fields["language"] = m.group(1).strip() fields["code"] = m.group(2) + # Searched only AFTER the code, so a `## Notes` line inside the code + # (a markdown snippet) is never read as the notes section. + n = _NOTES_RE.search(body, m.end()) + if n: + fields["notes"] = n.group(1).strip() # Language fallback for a body whose code fence lost its language. Only the # FIRST tag can be trusted: compose_tags emits [language, "snippet", *caller], @@ -545,6 +565,40 @@ def compose_data( return out +# Past this, a trigger is advised to move its explanation into `notes` (#4378). +# The trigger is joined onto the title of EVERY chunk (`chunk_document`), so at +# 600 characters it is already over 40% of a 1,400-character chunk — each +# vector is then more about the trigger's prose than about the code or the +# section it carries. A situation stated in a sentence or two sits well under. +TRIGGER_ADVISE_CHARS = 600 + + +def trigger_advice(when_to_use: str | None) -> str | None: + """A nudge when `when_to_use` reads like a write-up rather than a situation, + or None. Advice, not a refusal: the write has already happened, and a long + trigger can be deliberate — several distinct situations, each one a moment + the snippet should surface. What it catches is the explanation that had + nowhere else to go before `notes` existed.""" + text = (when_to_use or "").strip() + if not text: + return None + reasons = [] + if len(text) > TRIGGER_ADVISE_CHARS: + reasons.append(f"it is {len(text)} characters") + if re.search(r"^#{1,6}\s", text, re.MULTILINE): + reasons.append("it has headings") + elif "\n\n" in text: + reasons.append("it runs to several paragraphs") + if not reasons: + return None + return ( + f"when_to_use reads like a write-up ({', '.join(reasons)}). It is joined " + "onto every chunk this snippet is ranked by, so keep it to the situation " + "the snippet is for — a sentence or two — and move the explanation " + "(why, history, caveats) into `notes` with update_snippet." + ) + + def recompose_data(note) -> dict: """Rebuild a snippet's `data` mirror from its own body, title and tags. @@ -718,6 +772,7 @@ async def create_snippet( tags: list[str] | None = None, project_id: int | None = None, commit_sha: str = "", + notes: str = "", ): """Create a snippet note (embedded on create for immediate recall). Returns the created Note. Pass ``locations`` for the multi-location case; the single @@ -732,7 +787,7 @@ async def create_snippet( title=name.strip(), body=compose_body( code=code, language=language, signature=signature, - when_to_use=when_to_use, locations=locations, + when_to_use=when_to_use, locations=locations, notes=notes, ), note_type=SNIPPET_NOTE_TYPE, tags=compose_tags(language, tags), @@ -822,6 +877,7 @@ async def update_snippet( tags: list[str] | None = None, project_id: int | None | object = UNSET, commit_sha: str | None = None, + notes: str | None = None, ): """Partial update: only fields passed (not None) change. Re-serializes the merged field set back into title/body/tags. Returns the Note, or None if the @@ -858,7 +914,7 @@ async def update_snippet( cur = snippet_fields(note) overlay = { "name": name, "code": code, "language": language, - "signature": signature, "when_to_use": when_to_use, + "signature": signature, "when_to_use": when_to_use, "notes": notes, } merged = {**cur, **{k: v for k, v in overlay.items() if v is not None}} @@ -894,6 +950,7 @@ async def update_snippet( # Carried, never set here: an ordinary edit must not erase the record # of what was folded in, and only a merge may add to it. merged_from=merged.get("merged_from"), + notes=merged.get("notes") or "", ), # Re-derived from the same merged field set as the body, so an edit can't # leave the indexed mirror describing the previous version. @@ -1459,6 +1516,9 @@ async def merge_snippets(user_id: int, target_id: int, source_ids: list[int]): code=tgt_fields["code"], language=tgt_fields["language"], signature=tgt_fields["signature"], when_to_use=tgt_fields["when_to_use"], locations=locations, merged_from=merged_from, + # The survivor's own notes; a source's go to the trash with it and + # come back on un-merge, like its code. + notes=tgt_fields.get("notes") or "", ), tags=compose_tags(tgt_fields["language"], extra_tags), # The survivor's location set grew, so its mirror has to grow with it — @@ -1579,6 +1639,7 @@ async def unmerge_snippet(user_id: int, survivor_id: int, source_id: int): code=fields["code"], language=fields["language"], signature=fields["signature"], when_to_use=fields["when_to_use"], locations=kept_locations, merged_from=remaining, + notes=fields.get("notes") or "", ), tags=compose_tags(fields["language"], kept_extra), data=compose_data( diff --git a/tests/test_snippet_notes.py b/tests/test_snippet_notes.py new file mode 100644 index 0000000..1bb21a3 --- /dev/null +++ b/tests/test_snippet_notes.py @@ -0,0 +1,154 @@ +"""A snippet's notes — the explanation that had nowhere to go (#4378). + +Before this field, a snippet had name, code, signature, locations and +`when_to_use`, and nothing for prose. What a session learned about a snippet +went into `when_to_use` — the trigger, joined onto the title of every chunk the +snippet is embedded as — so a multi-paragraph write-up there blurred the one +situation it is ranked on. + +The rules pinned here, each with a way to rot silently: + + - Notes live in the BODY, after the code, under `## Notes`, and read back + from it. A `## Notes` line inside the code is code, not the section. + - A snippet with no notes composes the body it always did, byte for byte — + no re-embed for the corpus that has none. + - Every path that rebuilds the body CARRIES the notes. `update_snippet`, + merge and un-merge all compose the body from scratch, so a field one of + them forgets is erased by any edit to something else. + - `trigger_advice` speaks for a write-up and stays quiet for a situation. +""" +import pytest +import pytest_asyncio + +from tests.helpers import ensure_user + +from scribe.services import snippets as s + +CODE = "def helper():\n return 1" +NOTES = "Shaped this way because the caller owns the session.\n\nSuperseded #12." + + +# --- unit: the body convention ----------------------------------------------- + +def test_notes_follow_the_code_under_their_heading(): + body = s.compose_body(code=CODE, language="python", when_to_use="a helper", notes=NOTES) + code_at = body.index("```python") + notes_at = body.index(s.NOTES_HEADING) + assert code_at < notes_at + assert body.rstrip().endswith("Superseded #12.") + + +def test_notes_round_trip_through_the_body(): + body = s.compose_body(code=CODE, language="python", notes=NOTES) + got = s.parse_snippet_fields("helper", body, ["python", "snippet"]) + assert got["notes"] == NOTES + assert got["code"] == CODE + + +def test_a_snippet_without_notes_composes_the_body_it_always_did(): + """No trailing section, no extra blank line: the corpus that has no notes + must embed exactly as before.""" + body = s.compose_body(code=CODE, language="python", when_to_use="a helper") + assert body == "**When to use:** a helper\n\n```python\n" + CODE + "\n```\n" + assert s.parse_snippet_fields("helper", body)["notes"] == "" + + +def test_a_notes_heading_inside_the_code_is_code(): + md = "# Title\n\n## Notes\n\nthis is markdown being recorded" + body = s.compose_body(code=md, language="markdown") + got = s.parse_snippet_fields("md_template", body) + assert got["notes"] == "" + assert got["code"] == md + + +def test_notes_after_code_that_itself_contains_the_heading(): + md = "## Notes\ninside" + body = s.compose_body(code=md, language="markdown", notes="the real notes") + got = s.parse_snippet_fields("md_template", body) + assert got["notes"] == "the real notes" + assert got["code"] == md + + +def test_long_notes_are_chunked_apart_from_the_code(): + """The heading is the chunker's split point, so a long explanation gets + vectors of its own instead of averaging into the code's.""" + from scribe.services.embeddings import chunk_document + + long_notes = "\n\n".join(["An explanatory paragraph about the history. " * 12] * 4) + body = s.compose_body(code=CODE, language="python", when_to_use="a helper", + notes=long_notes) + chunks = chunk_document("helper — a helper", body) + assert len(chunks) > 1 + assert "```python" in chunks[0] + assert "```python" not in chunks[-1] + + +# --- unit: the advice --------------------------------------------------------- + +def test_a_situation_draws_no_advice(): + assert s.trigger_advice("Debouncing a reactive input before it fetches.") is None + assert s.trigger_advice("") is None + assert s.trigger_advice(None) is None + + +def test_a_long_trigger_is_advised_toward_notes(): + advice = s.trigger_advice("x" * (s.TRIGGER_ADVISE_CHARS + 1)) + assert advice and "notes" in advice + assert f"{s.TRIGGER_ADVISE_CHARS + 1} characters" in advice + + +def test_a_headed_or_multi_paragraph_trigger_is_advised_even_when_short(): + assert "headings" in s.trigger_advice("When adding a record.\n\n## Why\nbecause") + assert "paragraphs" in s.trigger_advice("When adding a record.\n\nAlso, history.") + + +# --- integration: every body rebuild carries the notes ------------------------ + + +@pytest_asyncio.fixture +async def user_id(_dispose_engine): + from scribe.models import async_session + + async with async_session() as session: + uid = (await ensure_user(session, "snippet_notes_itest")).id + await session.commit() + return uid + + +async def _fields(uid, note_id): + return s.snippet_fields(await s.get_snippet(uid, note_id)) + + +@pytest.mark.integration +async def test_an_edit_to_another_field_keeps_the_notes(user_id): + note = await s.create_snippet( + user_id, name="notes_keep", code=CODE, language="python", + when_to_use="a helper", notes=NOTES, + ) + assert (await _fields(user_id, note.id))["notes"] == NOTES + + await s.update_snippet(user_id, note.id, when_to_use="a sharper situation") + got = await _fields(user_id, note.id) + assert got["notes"] == NOTES + assert got["when_to_use"] == "a sharper situation" + + # And an empty string clears them, like every other field. + await s.update_snippet(user_id, note.id, notes="") + assert (await _fields(user_id, note.id))["notes"] == "" + + +@pytest.mark.integration +async def test_a_merge_keeps_the_survivors_notes(user_id): + survivor = await s.create_snippet( + user_id, name="notes_merge_a", code=CODE, language="python", + repo="R", path="a.py", symbol="helper", notes=NOTES, + ) + source = await s.create_snippet( + user_id, name="notes_merge_b", code=CODE + " # variant", language="python", + repo="R", path="b.py", symbol="helper", + ) + await s.merge_snippets(user_id, survivor.id, [source.id]) + assert (await _fields(user_id, survivor.id))["notes"] == NOTES + + await s.unmerge_snippet(user_id, survivor.id, source.id) + assert (await _fields(user_id, survivor.id))["notes"] == NOTES -- 2.54.0