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
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:
@@ -332,9 +332,9 @@ _READ_ONLY_TOOLS = frozenset({
|
||||
"list_rules", "list_tags", "list_tasks", "list_topics", "list_trash",
|
||||
"list_always_on_rules", "search",
|
||||
"get_system", "list_systems", "list_system_records",
|
||||
# Reports on the snippet corpus. Reads only — the merge it suggests is a
|
||||
# separate, explicitly-called write.
|
||||
"find_duplicate_snippets",
|
||||
# Reports on the corpus. Reads only — the merge or supersession each
|
||||
# suggests is a separate, explicitly-called write.
|
||||
"find_duplicate_snippets", "find_duplicate_records",
|
||||
# Snippets and processes are notes with a kind. A key that may read a note
|
||||
# but not a snippet inverts the sensitivity ordering: it exposes the
|
||||
# free-text records and withholds the structured ones (#2496).
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user