Snippets gain notes; when_to_use is the situation it is ranked on (#4378) #184

Merged
bvandeusen merged 1 commits from dev into main 2026-09-23 18:00:36 -04:00
9 changed files with 313 additions and 21 deletions
+4
View File
@@ -30,6 +30,9 @@ export interface SnippetFields {
* no `locations`/`tags` predates that attribution and cannot be un-merged. */ * no `locations`/`tags` predates that attribution and cannot be un-merged. */
merged_from: { id: number; locations?: SnippetLocation[]; tags?: string[] }[]; merged_from: { id: number; locations?: SnippetLocation[]; tags?: string[] }[];
code: 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, /** A full snippet record: the note dict plus the parsed `snippet` sub-object,
@@ -110,6 +113,7 @@ export interface SnippetInput {
language?: string; language?: string;
signature?: string; signature?: string;
when_to_use?: string; when_to_use?: string;
notes?: string;
locations?: SnippetLocation[]; locations?: SnippetLocation[];
tags?: string[]; tags?: string[];
project_id?: number | null; project_id?: number | null;
+17
View File
@@ -9,6 +9,7 @@ import {
} from "@/api/snippets"; } from "@/api/snippets";
import { useToastStore } from "@/stores/toast"; import { useToastStore } from "@/stores/toast";
import ConfirmDialog from "@/components/ConfirmDialog.vue"; import ConfirmDialog from "@/components/ConfirmDialog.vue";
import { renderMarkdown } from "@/utils/markdown";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -184,6 +185,11 @@ async function confirmDelete() {
<pre><code>{{ snippet.snippet.code }}</code></pre> <pre><code>{{ snippet.snippet.code }}</code></pre>
</div> </div>
<section v-if="snippet.snippet.notes" class="notes">
<h2 class="notes-heading">Notes</h2>
<div class="prose" v-html="renderMarkdown(snippet.snippet.notes)" />
</section>
<div v-if="snippet.tags.length" class="tag-row"> <div v-if="snippet.tags.length" class="tag-row">
<span v-for="t in snippet.tags" :key="t" class="tag-pill">{{ t }}</span> <span v-for="t in snippet.tags" :key="t" class="tag-pill">{{ t }}</span>
</div> </div>
@@ -343,6 +349,17 @@ async function confirmDelete() {
cursor: help; 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 { .code-block {
border: 1px solid var(--fs-border-color); border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-lg); border-radius: var(--fs-radius-lg);
+20 -1
View File
@@ -29,6 +29,7 @@ interface FormState {
language: string; language: string;
signature: string; signature: string;
when_to_use: string; when_to_use: string;
notes: string;
} }
const blankLocation = (): SnippetLocation => ({ repo: "", path: "", symbol: "" }); const blankLocation = (): SnippetLocation => ({ repo: "", path: "", symbol: "" });
@@ -39,6 +40,7 @@ const form = ref<FormState>({
language: "", language: "",
signature: "", signature: "",
when_to_use: "", when_to_use: "",
notes: "",
}); });
// A snippet that unified several one-offs carries several locations; a fresh one // A snippet that unified several one-offs carries several locations; a fresh one
// starts with a single blank row. // starts with a single blank row.
@@ -126,6 +128,7 @@ async function load() {
language: f.language, language: f.language,
signature: f.signature, signature: f.signature,
when_to_use: f.when_to_use, when_to_use: f.when_to_use,
notes: f.notes ?? "",
}; };
locations.value = f.locations?.length locations.value = f.locations?.length
? f.locations.map((l) => ({ ...l })) ? f.locations.map((l) => ({ ...l }))
@@ -169,6 +172,7 @@ async function save() {
language: form.value.language.trim(), language: form.value.language.trim(),
signature: form.value.signature.trim(), signature: form.value.signature.trim(),
when_to_use: form.value.when_to_use.trim(), when_to_use: form.value.when_to_use.trim(),
notes: form.value.notes.trim(),
locations: cleanLocations(), locations: cleanLocations(),
tags: parseTags(), tags: parseTags(),
project_id: projectId.value, project_id: projectId.value,
@@ -243,7 +247,10 @@ function cancel() {
placeholder="Debounce a reactive ref that updates too often" placeholder="Debounce a reactive ref that updates too often"
@keydown.escape="cancel" @keydown.escape="cancel"
/> />
<p class="hint">Shown in the recall menu — keep it sharp.</p> <p class="hint">
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.
</p>
</div> </div>
<div class="field-row"> <div class="field-row">
@@ -303,6 +310,18 @@ function cancel() {
></textarea> ></textarea>
</div> </div>
<div class="field">
<label for="sn-notes">Notes</label>
<textarea
id="sn-notes"
v-model="form.notes"
class="fs-input input"
rows="5"
placeholder="Why it's shaped this way, what it replaced, caveats…"
></textarea>
<p class="hint">Markdown. Shown with the snippet, after the code.</p>
</div>
<div class="field"> <div class="field">
<label for="sn-tags">Tags</label> <label for="sn-tags">Tags</label>
<input <input
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scribe", "name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).", "description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.09.23.2008", "version": "2026.09.23.2127",
"author": { "author": {
"name": "Bryan Van Deusen" "name": "Bryan Van Deusen"
}, },
+6 -2
View File
@@ -72,8 +72,12 @@ through recall/auto-inject; this skill is the active reflex around that.
under-recording has no signal at all. The record is cheap — these fields: under-recording has no signal at all. The record is cheap — these fields:
- **name** — what it's called, e.g. `useDebouncedRef`. - **name** — what it's called, e.g. `useDebouncedRef`.
- **code** — the implementation. - **code** — the implementation.
- **when_to_use** — one sharp line on when to reach for it. This becomes part - **when_to_use** — the situation to reach for it in, in a sentence or two.
of the title, so it's what a later recall menu shows — make it earn the pull. The snippet is ranked on this, so state the moment it applies and nothing
else — make it earn the pull.
- **notes** — everything that isn't the situation: why it's shaped this way,
what it replaced, caveats. When you learn something about a snippet later,
add it here rather than to `when_to_use`.
- **language**, **signature**, and **location** (`repo` / `path` / `symbol`) - **language**, **signature**, and **location** (`repo` / `path` / `symbol`)
so the recorded copy points back at the canonical source. so the recorded copy points back at the canonical source.
- **project_id** / **system_ids** to associate it with the work it belongs to. - **project_id** / **system_ids** to associate it with the work it belongs to.
+36 -8
View File
@@ -65,9 +65,9 @@ async def list_snippets(
snippet that lives in repo A and, separately, at path B in another repo is snippet that lives in repo A and, separately, at path B in another repo is
not returned for repo=A + path=B. not returned for repo=A + path=B.
Returns {"snippets": [{id, title, tags, preview, usage}], "total": int}. The Returns {"snippets": [{id, title, when_to_use, tags, preview, usage}],
title reads "name — when to reach for it"; open one in full with "total": int}. The title is the snippet's name and `when_to_use` says when
get_snippet(id). to reach for it; open one in full with get_snippet(id).
`usage` is {surfaced_count, pull_count, last_surfaced_at, last_pulled_at}: `usage` is {surfaced_count, pull_count, last_surfaced_at, last_pulled_at}:
how often the entry has been put in front of an agent versus actually how often the entry has been put in front of an agent versus actually
@@ -109,6 +109,7 @@ async def create_snippet(
system_ids: list[int] | None = None, system_ids: list[int] | None = None,
force: bool = False, force: bool = False,
commit_sha: str = "", commit_sha: str = "",
notes: str = "",
) -> dict: ) -> dict:
"""Record a shape in the project's pattern library, so every later """Record a shape in the project's pattern library, so every later
instance starts from it instead of re-deriving it. 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 language: Language/format, e.g. "python", "vue", "sql". Becomes a tag and
the code-fence language. the code-fence language.
signature: One-line signature/interface, e.g. "debounce(fn, ms) -> fn". 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 when_to_use: The situation to reach for it in — a sentence or two, e.g.
title, so it's what a recall menu shows. Keep it sharp. "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. repo/path/symbol: Canonical location of the reference implementation.
locations: Several locations at once, as [{"repo","path","symbol"}, ...], locations: Several locations at once, as [{"repo","path","symbol"}, ...],
when you already know the thing lives in more than one place. Takes 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 later. Optional, but pass it whenever you're recording from a
checkout. 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 Returns the created snippet (including a parsed `snippet` field), OR — when a
duplicate already exists and force is false — {"duplicate": true, duplicate already exists and force is false — {"duplicate": true,
"existing_id": ..., "message": ...} and nothing is created. When that happens "existing_id": ..., "message": ...} and nothing is created. When that happens
@@ -182,7 +195,7 @@ async def create_snippet(
body = snippets_svc.compose_body( body = snippets_svc.compose_body(
code=code, language=language, signature=signature, code=code, language=language, signature=signature,
when_to_use=when_to_use, repo=repo, path=path, symbol=symbol, when_to_use=when_to_use, repo=repo, path=path, symbol=symbol,
locations=locations, locations=locations, notes=notes,
) )
if not force: if not force:
dup = await dedup_svc.find_duplicate_note( dup = await dedup_svc.find_duplicate_note(
@@ -201,12 +214,15 @@ async def create_snippet(
uid, name=name, code=code, language=language, signature=signature, uid, name=name, code=code, language=language, signature=signature,
when_to_use=when_to_use, repo=repo, path=path, symbol=symbol, when_to_use=when_to_use, repo=repo, path=path, symbol=symbol,
locations=locations, tags=tags, project_id=project_id or None, locations=locations, tags=tags, project_id=project_id or None,
commit_sha=commit_sha, commit_sha=commit_sha, notes=notes,
) )
if system_ids: if system_ids:
await systems_svc.set_record_systems(uid, note.id, system_ids) await systems_svc.set_record_systems(uid, note.id, system_ids)
data = snippets_svc.snippet_to_dict(note) data = snippets_svc.snippet_to_dict(note)
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None) 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 return data
@@ -437,6 +453,7 @@ async def update_snippet(
project_id: int = 0, project_id: int = 0,
system_ids: list[int] | None = None, system_ids: list[int] | None = None,
commit_sha: str = "", commit_sha: str = "",
notes: str | None = None,
) -> dict: ) -> dict:
"""Update a snippet. Only the fields you pass change. """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. worse than none, so correcting downward has to be possible.
Args: 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"}, locations: Replace the whole location set, as [{"repo","path","symbol"},
...]. Pass [] to clear every location. The single repo/path/symbol ...]. Pass [] to clear every location. The single repo/path/symbol
args instead overlay onto the FIRST location, leaving the rest. 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, signature=signature, when_to_use=when_to_use,
repo=repo, path=path, symbol=symbol, repo=repo, path=path, symbol=symbol,
locations=locations, tags=tags, project_id=project, 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: except PermissionError as exc:
# Readable but not writable — surface the real reason, not "not found". # Readable but not writable — surface the real reason, not "not found".
@@ -493,6 +516,11 @@ async def update_snippet(
await systems_tools.attach_systems( await systems_tools.attach_systems(
uid, note.user_id, data, snippet_id, note.project_id 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 return data
+6 -1
View File
@@ -32,7 +32,10 @@ logger = logging.getLogger(__name__)
snippets_bp = Blueprint("snippets", __name__, url_prefix="/api/snippets") snippets_bp = Blueprint("snippets", __name__, url_prefix="/api/snippets")
# Fields the create/update payload may carry, mapped straight to the service. # 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): async def _load_snippet(uid: int, snippet_id: int):
@@ -106,6 +109,7 @@ async def create_snippet_route():
path=data.get("path", ""), path=data.get("path", ""),
symbol=data.get("symbol", ""), symbol=data.get("symbol", ""),
locations=data.get("locations"), locations=data.get("locations"),
notes=data.get("notes", ""),
), ),
project_id=project_id, project_id=project_id,
is_task=False, is_task=False,
@@ -139,6 +143,7 @@ async def create_snippet_route():
locations=data.get("locations"), locations=data.get("locations"),
tags=data.get("tags"), tags=data.get("tags"),
project_id=project_id, project_id=project_id,
notes=data.get("notes", ""),
) )
if data.get("system_ids") is not None: if data.get("system_ids") is not None:
await systems_svc.set_record_systems(uid, note.id, data["system_ids"]) await systems_svc.set_record_systems(uid, note.id, data["system_ids"])
+69 -8
View File
@@ -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 has (embeddings, ACL, project/System association, dedup) and, crucially, becomes
eligible for semantic recall the moment it's embedded: eligible for semantic recall the moment it's embedded:
- ``title`` = ``"{name} — {when_to_use}"``. The title is exactly what the - ``title`` = ``name``. The trigger joins it only in the embedded document
title-first auto-inject surfaces, so this one line self-describes the snippet (``embeddings.document_title``, milestone 427).
in a recall menu.
- ``tags`` = ``[language, "snippet", *caller_tags]``. - ``tags`` = ``[language, "snippet", *caller_tags]``.
- ``body`` = templated markdown (When to use / Signature / Location, then a - ``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 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 ``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_NOTE_TYPE = "snippet"
SNIPPET_TAG = "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 # 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 # real value meaning "clear this". Needed for project_id, where 0 is not a valid
@@ -192,10 +195,19 @@ def compose_body(
symbol: str = "", symbol: str = "",
locations: list[dict] | None = None, locations: list[dict] | None = None,
merged_from: list[int] | None = None, merged_from: list[int] | None = None,
notes: str = "",
) -> str: ) -> str:
"""Render structured fields into the snippet body markdown. Empty fields are """Render structured fields into the snippet body markdown. Empty fields are
omitted so the body stays clean. 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 Locations: pass ``locations`` (a list of {repo,path,symbol}) for the general
multi-location case; the single ``repo``/``path``/``symbol`` params remain as multi-location case; the single ``repo``/``path``/``symbol`` params remain as
a back-compat shorthand for one location and are used only when ``locations`` 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() fence_lang = (language or "").strip().lower()
code_block = f"```{fence_lang}\n{(code or '').rstrip()}\n```" 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: if header:
return "\n\n".join(header) + "\n\n" + code_block + "\n" return "\n\n".join(header) + "\n\n" + code_block + tail
return code_block + "\n" return code_block + tail
# --- parse: note -> structured fields (best-effort, never raises) ------------ # --- 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) _MERGED_RE = re.compile(r"^\*\*Merged from:\*\*\s*(.+?)\s*$", re.MULTILINE)
_CODE_RE = re.compile(r"```([\w+.#-]*)\n(.*?)\n```", re.DOTALL) _CODE_RE = re.compile(r"```([\w+.#-]*)\n(.*?)\n```", re.DOTALL)
_ID_RE = re.compile(r"#(\d+)") _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: def _parse_location_str(s: str) -> dict | None:
@@ -289,6 +303,7 @@ def parse_snippet_fields(
"locations": [], "locations": [],
"merged_from": [], "merged_from": [],
"code": "", "code": "",
"notes": "",
} }
m = _WHEN_RE.search(body) m = _WHEN_RE.search(body)
@@ -331,6 +346,11 @@ def parse_snippet_fields(
if m: if m:
fields["language"] = m.group(1).strip() fields["language"] = m.group(1).strip()
fields["code"] = m.group(2) 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 # Language fallback for a body whose code fence lost its language. Only the
# FIRST tag can be trusted: compose_tags emits [language, "snippet", *caller], # FIRST tag can be trusted: compose_tags emits [language, "snippet", *caller],
@@ -545,6 +565,40 @@ def compose_data(
return out 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: def recompose_data(note) -> dict:
"""Rebuild a snippet's `data` mirror from its own body, title and tags. """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, tags: list[str] | None = None,
project_id: int | None = None, project_id: int | None = None,
commit_sha: str = "", commit_sha: str = "",
notes: str = "",
): ):
"""Create a snippet note (embedded on create for immediate recall). Returns """Create a snippet note (embedded on create for immediate recall). Returns
the created Note. Pass ``locations`` for the multi-location case; the single the created Note. Pass ``locations`` for the multi-location case; the single
@@ -732,7 +787,7 @@ async def create_snippet(
title=name.strip(), title=name.strip(),
body=compose_body( body=compose_body(
code=code, language=language, signature=signature, 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, note_type=SNIPPET_NOTE_TYPE,
tags=compose_tags(language, tags), tags=compose_tags(language, tags),
@@ -822,6 +877,7 @@ async def update_snippet(
tags: list[str] | None = None, tags: list[str] | None = None,
project_id: int | None | object = UNSET, project_id: int | None | object = UNSET,
commit_sha: str | None = None, commit_sha: str | None = None,
notes: str | None = None,
): ):
"""Partial update: only fields passed (not None) change. Re-serializes the """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 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) cur = snippet_fields(note)
overlay = { overlay = {
"name": name, "code": code, "language": language, "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}} 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 # 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. # of what was folded in, and only a merge may add to it.
merged_from=merged.get("merged_from"), 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 # Re-derived from the same merged field set as the body, so an edit can't
# leave the indexed mirror describing the previous version. # 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"], code=tgt_fields["code"], language=tgt_fields["language"],
signature=tgt_fields["signature"], when_to_use=tgt_fields["when_to_use"], signature=tgt_fields["signature"], when_to_use=tgt_fields["when_to_use"],
locations=locations, merged_from=merged_from, 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), tags=compose_tags(tgt_fields["language"], extra_tags),
# The survivor's location set grew, so its mirror has to grow with it — # 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"], code=fields["code"], language=fields["language"],
signature=fields["signature"], when_to_use=fields["when_to_use"], signature=fields["signature"], when_to_use=fields["when_to_use"],
locations=kept_locations, merged_from=remaining, locations=kept_locations, merged_from=remaining,
notes=fields.get("notes") or "",
), ),
tags=compose_tags(fields["language"], kept_extra), tags=compose_tags(fields["language"], kept_extra),
data=compose_data( data=compose_data(
+154
View File
@@ -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