From 6db791965fa94bea430468459642cced855a2331 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 28 Jul 2026 18:32:40 -0400 Subject: [PATCH] =?UTF-8?q?feat(snippets):=20near-duplicate=20finder=20?= =?UTF-8?q?=E2=80=94=20surface=20the=20sets=20worth=20merging?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #231's premise was unifying reusable things already scattered as one-offs. The create gate PREVENTS a new duplicate and merge_snippets CURES one you point it at, but nothing FOUND the duplicates already in the record — someone had to notice them by hand, which is the exact failure the Drafter exists to remove. One indexed self-join over note_embeddings, not an N² Python scan: pgvector's cosine distance is the same operator semantic search uses, so a similarity floor is a distance ceiling and the work stays in Postgres. `left.note_id < right.note_id` yields each unordered pair once and drops the self-pair that would otherwise dominate the ranking. Pairs are collapsed into merge SETS by connected components. Transitive on purpose: A~B plus B~C puts all three together even when A and C don't directly clear the bar, which is what merge actually does (it folds every source into one survivor). The cost is that a chain of mild resemblances can rope in a member that isn't really alike — so the UI presents a set as a proposal, shows the members, and never merges without a confirm. Two scope decisions worth naming: - OWN snippets only. merge_snippets requires one owner across the set, so surfacing someone else's would propose a merge that cannot be performed. The report is bounded by what the operator can act on, not what they can see. - Threshold defaults to 0.82, LOOSER than the write gate's 0.90, and is a setting rather than a constant (rule #25). The gate blocks a create and has to be unforgiving of noise; this only suggests a merge under review, so it must reach further or it would never surface the pairs the gate already let through — which are precisely the ones that accumulated. Fixes a real bug in the merge flow while wiring the UI: selectedList filtered the selection against the CURRENT PAGE, and doMerge derives its source ids from that list. A corpus-wide suggested group with off-page members would have rendered incomplete and silently merged only the visible subset. A group under review is now the authority for that list. Refs #2088 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs --- frontend/src/api/snippets.ts | 19 +++ frontend/src/views/SettingsView.vue | 32 +++++ frontend/src/views/SnippetListView.vue | 162 ++++++++++++++++++++++- src/scribe/mcp/server.py | 3 + src/scribe/mcp/tools/snippets.py | 40 +++++- src/scribe/routes/snippets.py | 18 +++ src/scribe/services/dedup.py | 174 +++++++++++++++++++++++++ tests/test_mcp_tool_snippets.py | 1 + tests/test_routes_snippets.py | 7 +- tests/test_snippet_duplicates.py | 99 ++++++++++++++ 10 files changed, 547 insertions(+), 8 deletions(-) create mode 100644 tests/test_snippet_duplicates.py diff --git a/frontend/src/api/snippets.ts b/frontend/src/api/snippets.ts index 249bd30..26c9bcd 100644 --- a/frontend/src/api/snippets.ts +++ b/frontend/src/api/snippets.ts @@ -154,6 +154,25 @@ export async function deleteSnippet(id: number): Promise { return apiDelete(`/api/snippets/${id}`); } +/** A set of snippets that resemble each other closely enough to be worth + * merging. Grouping is transitive, so a set can hold members that don't + * directly resemble each other — read it as a proposal, not a verdict. */ +export interface DuplicateGroup { + note_ids: number[]; + snippets: { id: number; title: string }[]; + /** The strongest resemblance within the set — how confident the suggestion is. */ + top_score: number; +} + +/** Near-duplicates already in the record. The create gate prevents new ones and + * merge cures the ones you point it at; this is what finds them. */ +export async function findDuplicateSnippets( + threshold?: number, +): Promise<{ groups: DuplicateGroup[]; threshold: number }> { + const qs = threshold ? `?threshold=${threshold}` : ""; + return apiGet(`/api/snippets/duplicates${qs}`); +} + /** Record a drift-check verdict. The check itself runs where the code is — an * agent with the working tree — since Scribe has no checkout. This stores what * was found, and is how the UI clears a stale marker after a manual fix. */ diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 1321cc4..4d27527 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -23,6 +23,10 @@ const kbInjectEnabled = ref(true); const kbInjectThreshold = ref("0.55"); const kbInjectTopK = ref("3"); const kbWritePathEnabled = ref(true); +// Near-duplicate report floor. Deliberately looser than the 0.90 write-time +// gate: that one BLOCKS a create and must be unforgiving of noise, this one only +// suggests a merge the operator reviews (services/dedup.py). +const kbDuplicateThreshold = ref("0.82"); const savingKbInject = ref(false); const kbInjectSaved = ref(false); @@ -68,8 +72,13 @@ async function saveRetention() { async function saveKbInject() { const t = Math.min(1, Math.max(0, Number(kbInjectThreshold.value) || 0)); const k = Math.min(10, Math.max(1, Math.floor(Number(kbInjectTopK.value) || 1))); + // `|| 0.82` not `|| 0`: an unparseable value here should fall back to the + // default, not to 0 — a 0 floor would report every snippet as a duplicate of + // every other one. + const dupT = Math.min(1, Math.max(0, Number(kbDuplicateThreshold.value) || 0.82)); kbInjectThreshold.value = String(t); kbInjectTopK.value = String(k); + kbDuplicateThreshold.value = String(dupT); savingKbInject.value = true; kbInjectSaved.value = false; try { @@ -80,6 +89,7 @@ async function saveKbInject() { // Its own switch, but deliberately the same threshold/ceiling — see // WRITEPATH_ENABLED_KEY in services/plugin_context.py. kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false', + kb_duplicate_threshold: String(dupT), }); kbInjectSaved.value = true; setTimeout(() => (kbInjectSaved.value = false), 2000); @@ -464,6 +474,9 @@ onMounted(async () => { kbInjectTopK.value = allSettings.kb_autoinject_top_k; } kbWritePathEnabled.value = allSettings.kb_writepath_enabled !== "false"; + if (allSettings.kb_duplicate_threshold !== undefined) { + kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold; + } if (allSettings.notify_task_reminders !== undefined) { notifyTaskReminders.value = allSettings.notify_task_reminders !== "false"; } @@ -1211,6 +1224,25 @@ function formatUserDate(iso: string): string { edit. Off = prior art surfaces only on your own prompts.

+
+ + +

+ How alike two snippets must be before the Snippets page suggests merging + them. Lower = more suggestions, more false pairs. Looser than the 0.90 + used to block a duplicate at creation, because this only proposes a merge + you review — it never acts on its own. +

+