feat(dedup): the duplicate report reaches notes and tasks, with per-kind cures
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Successful in 17s
CI & Build / Python tests (push) Failing after 30s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Build & push image (push) Skipped

Step 5 of #278, folding in #2534. The operator's no-gate decision for the web
UI (#2482 — "an llm attached to this surface is the corrections system") has a
precondition nobody had built: the corrector has to be able to SEE what needs
correcting. find_duplicate_snippets had no equivalent for notes or tasks, so a
duplicate note was only ever noticed by accident.

find_duplicate_records(kind="snippet"|"note"|"task") — the same indexed
self-join, parameterised. Tasks are notes with a status, not a note_type, so
the kind split is a status predicate; mixing them would propose folding a
to-do into a write-up. find_duplicate_snippets stays as a wrapper because both
surfaces and SnippetListView consume it by name.

What differs by kind is the CURE, and the report says so in a `suggestion`
field rather than leaving the caller to guess:

  snippet  merge — lossless, the survivor keeps every call site
  note     NEVER merge. A correction pair → supersedes on the newer; state
           smeared across dated records → extract to the System's reference
           note; genuinely parallel → leave alone. Choosing needs the records
           READ, which is the agent's job — so non-snippet groups carry
           `members` with dates and any `existing_supersessions` already
           declared inside the group. A pair someone ruled on is not an open
           question.
  task     usually the same work opened twice — keep the one with the history,
           cancel the other with a pointer.

The snippet sibling filter stays snippet-only: it keys on symbol/code_sha,
which other kinds don't carry — and for them a look-alike is a finding.

Surfaces: MCP find_duplicate_records (classified into _READ_ONLY_TOOLS — the
completeness test would have caught the omission), REST /api/notes/duplicates,
and a KnowledgeView panel mirroring SnippetListView's — links only, no merge
button, because for notes the report proposes and the correction is a read-
and-decide act. The panel follows the type filter and clears when it changes,
so a note report can't linger under a task view.

Correcting the task's own premise: it claimed the snippet report had "no view
consuming it" — stale; SnippetListView has consumed it since it shipped. The
UI gap was only ever notes/tasks.

Answers the question carried from #2482: yes, the update routes on BOTH
surfaces can turn a record into a duplicate — the gate is create-time by
design. This report is the mechanism that catches it after the fact, which is
the model the operator chose.

Refs #278, #2547
This commit is contained in:
2026-08-08 18:51:49 -04:00
parent 3f1523b19f
commit d7039dc17c
6 changed files with 418 additions and 35 deletions
+33
View File
@@ -231,6 +231,38 @@ async def update_note(
return data
async def find_duplicate_records(kind: str = "note", threshold: float = 0.0) -> dict:
"""Notes or tasks already recorded that closely resemble each other.
The create gate PREVENTS a duplicate arriving through an agent; the web UI
deliberately has no gate (a human mid-thought must not be blocked by a 409),
which makes YOU the corrections system — and a corrector has to be able to
SEE what needs correcting. This is the finder. Run it when tidying a
project's records, or when you suspect the same ground was covered twice.
Args:
kind: "note" (documents) or "task". Snippets have their own report,
find_duplicate_snippets, whose groups propose a lossless merge.
threshold: Similarity floor, 0-1. 0 uses the configured setting.
READ THE `suggestion` FIELD BEFORE ACTING, because the right fix differs by
what the records ARE, not how alike they score. For notes: a correction
pair → declare `supersedes` on the newer; state smeared across dated
records → extract it into the System's reference note and leave these as
history; genuinely parallel records → leave them alone. NEVER merge notes.
Each group carries `members` with dates and any `existing_supersessions`
already declared inside it — a pair someone has ruled on is not an open
question.
"""
uid = current_user_id()
if kind not in ("note", "task"):
raise ValueError('kind must be "note" or "task" — snippets have their '
"own report, find_duplicate_snippets")
return await dedup_svc.find_duplicate_records(
uid, kind=kind, threshold=threshold if threshold > 0 else None,
)
async def delete_note(note_id: int) -> dict:
"""Move a Scribe note to the trash (recoverable). Restore via restore(batch_id)."""
uid = current_user_id()
@@ -247,6 +279,7 @@ def register(mcp) -> None:
get_note,
create_note,
update_note,
find_duplicate_records,
delete_note,
):
mcp.tool(name=fn.__name__)(fn)