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
+25
View File
@@ -22,6 +22,7 @@ from scribe.services.notes import (
update_note,
)
from scribe.services.note_drafts import upsert_draft, get_draft, delete_draft
from scribe.services import dedup as dedup_svc
from scribe.services import supersession as supersession_svc
from scribe.services.note_usage import record_pulled
@@ -199,6 +200,30 @@ async def resolve_title_route():
return jsonify(note.to_dict())
@notes_bp.route("/duplicates", methods=["GET"])
@login_required
async def find_duplicate_records_route():
"""Near-duplicate notes or tasks, grouped, with the per-kind suggestion.
?kind=note|task, ?threshold= (0 = configured setting). Registered above the
`/<int:note_id>` routes on purpose — the literal path first, same reasoning
as /api/snippets/duplicates. Mirrors the MCP tool (#33): the web UI has no
dedup gate by design, so this report is the UI's only window onto what that
decision admits.
"""
uid = get_current_user_id()
kind = request.args.get("kind", "note")
if kind not in ("note", "task"):
return jsonify({"error": 'kind must be "note" or "task"'}), 400
try:
threshold = float(request.args.get("threshold", 0) or 0)
except (TypeError, ValueError):
threshold = 0.0
return jsonify(await dedup_svc.find_duplicate_records(
uid, kind=kind, threshold=threshold if threshold > 0 else None,
))
@notes_bp.route("/<int:note_id>", methods=["GET"])
@login_required
async def get_note_route(note_id: int):