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
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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user