Merge pull request 'Snippets gain notes; when_to_use is the situation it is ranked on (#4378)' (#184) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 54s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m43s
CI & Build / Build & push image (push) Successful in 17s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / integration (push) Successful in 54s
CI & Build / TypeScript typecheck (push) Successful in 55s
CI & Build / Python tests (push) Successful in 1m43s
CI & Build / Build & push image (push) Successful in 17s
This commit was merged in pull request #184.
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user