+ 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