fix(dedup): compare the artefact, not the prose describing it
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / integration (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 25s

The snippet gate was reading the wrong field, and #2464's UI recipes made it
measurable in both directions at once:

  .btn-danger vs .btn-danger-outline   0.92   siblings, BLOCKED
  .btn-primary re-recorded verbatim
  under a different name              <0.90   a literal copy, ALLOWED

The second is what settles it. Identical code at an identical repo·path·symbol
sailed through because the description differed, while two deliberately
parallel variants were refused because theirs did not. A snippet's embedded
document is mostly prose ABOUT the code, so no threshold fixes this: lowering
it blocks more siblings, raising it admits more copies.

So structure decides. Two exact signals, both index-served off the notes.data
mirror that already exists, no migration and no backfill:

  location  the same named thing in the same file. Requires BOTH path and
            symbol — a path alone is a directory of artefacts, and matching on
            it would refuse every second recipe from one stylesheet.
  code      byte-identical code anywhere, via the same fingerprint the drift
            check uses.

The semantic arm survives as a backstop for a genuine reword that shares
neither, raised to 0.96 so it sits above the 0.92 band where real variants
live. Structural hits say what they matched instead of hedging with "similar",
and point at merge rather than update — two records of one artefact is what
merge exists to fold back together.

find_duplicate_snippets gets the same correction: pairs where both snippets
name a symbol, name DIFFERENT symbols, and hold different code are variants,
not copies. Without it a design system's button family reports as one merge
set — eight recipes, every direct pair over the floor, top score 0.92, one
click from collapsing a component family. The cost is real and stated in the
code: a helper recorded twice under two names no longer reports. That trade
favours the report being usable, and same-symbol and unnamed duplicates — how
re-recording usually looks — still surface. The filter fails open, so a lookup
failure degrades to the old unfiltered report rather than to a reassuring
empty one.

resolve_locations extracted: compose_body, create_snippet and now the gate each
had their own copy of the repo/path/symbol shorthand fallback, and the gate is
the one where a disagreement would mean matching a location the record won't be
stored with. Applied to both create surfaces (#33) — the web UI must not be the
way to record what the agent was stopped from writing.

Refs #2518, #2464
This commit is contained in:
2026-08-06 11:31:02 -04:00
parent c18139622c
commit 24d071619b
5 changed files with 433 additions and 26 deletions
+23 -5
View File
@@ -96,6 +96,27 @@ def _normalize_locations(locations: list[dict] | None) -> list[dict]:
return out
def resolve_locations(
repo: str = "", path: str = "", symbol: str = "",
locations: list[dict] | None = None,
) -> list[dict]:
"""The location list a caller meant, from either calling convention.
`locations` is the general form (one entry per call site); repo/path/symbol
are the single-location shorthand and apply only when `locations` was not
given — passing both is not a merge, it is the caller having decided.
Extracted because compose_body, create_snippet and the dedup gate must all
read the shorthand the SAME way. They each had their own copy of the
`if locations is None` fallback, which is fine until one of them gains a
rule the others don't — and the gate (#2518) is the one where a disagreement
would mean comparing a location the record won't actually be stored with.
"""
if locations is None:
locations = [{"repo": repo, "path": path, "symbol": symbol}]
return _normalize_locations(locations)
def _location_str(loc: dict) -> str:
"""`repo` · `path` · `symbol` — only the non-empty parts."""
parts = [(loc.get(k) or "").strip() for k in ("repo", "path", "symbol")]
@@ -186,9 +207,7 @@ def compose_body(
a back-compat shorthand for one location and are used only when ``locations``
is not given.
"""
if locations is None:
locations = [{"repo": repo, "path": path, "symbol": symbol}]
locs = _normalize_locations(locations)
locs = resolve_locations(repo, path, symbol, locations)
header: list[str] = []
if (when_to_use or "").strip():
@@ -601,8 +620,7 @@ async def create_snippet(
"""Create a snippet note (embedded on create for immediate recall). Returns
the created Note. Pass ``locations`` for the multi-location case; the single
``repo``/``path``/``symbol`` are the one-location shorthand."""
if locations is None:
locations = [{"repo": repo, "path": path, "symbol": symbol}]
locations = resolve_locations(repo, path, symbol, locations)
note = await notes_svc.create_note(
user_id,
title=compose_title(name, when_to_use),