From 272b7dbddfd38ac83a7593f8898a630ec7d0afea Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 9 Aug 2026 10:33:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(dedup):=20per-kind=20duplicate-report=20fl?= =?UTF-8?q?oors=20=E2=80=94=20notes/tasks=20default=200.93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At chunk grain (#280) a note-pair's similarity is its closest chunk pair, so the shared 0.82 floor saturated the note/task reports with related families (38 note / 155 task groups against the 200-pair cap, measured 2026-08-09). Split kb_duplicate_threshold into per-kind settings keys with per-kind defaults: snippet 0.82 (single-chunk, scale unchanged), note/task 0.93 (points the report at genuinely-alike records). Settings UI grows the two new knobs; report entrypoints inherit the change via get_duplicate_threshold(user_id, kind). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs --- frontend/src/views/SettingsView.vue | 88 +++++++++++++++++++++++------ src/scribe/services/dedup.py | 40 +++++++++---- tests/test_snippet_duplicates.py | 45 +++++++++++---- 3 files changed, 135 insertions(+), 38 deletions(-) diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index e8ffb56..ace04fa 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -27,10 +27,14 @@ const kbWritePathEnabled = ref(true); // code embeddings sit on a much higher similarity floor than prose, so 0.55 let // unrelated code through (#2223). Shares top-k, not the threshold. const kbWritePathThreshold = ref("0.68"); -// 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"); +// Near-duplicate report floors, one per record kind (services/dedup.py). +// Snippets are single-chunk, so their floor sits below the 0.90 write-time +// gate and catches what it lets through. Notes/tasks are scored at chunk +// grain (#280) — related families clear 0.90 easily — so their floor sits +// above the gate to keep the report pointed at genuinely-alike records. +const kbDupThresholdSnippet = ref("0.82"); +const kbDupThresholdNote = ref("0.93"); +const kbDupThresholdTask = ref("0.93"); const savingKbInject = ref(false); const kbInjectSaved = ref(false); @@ -76,16 +80,20 @@ 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)); - // Same `|| default` reasoning as dupT: falling back to 0 would surface every + // `|| default` not `|| 0`: an unparseable value here should fall back to the + // per-kind default, not to 0 — a 0 floor would report every record as a + // duplicate of every other one. + const dupSnip = Math.min(1, Math.max(0, Number(kbDupThresholdSnippet.value) || 0.82)); + const dupNote = Math.min(1, Math.max(0, Number(kbDupThresholdNote.value) || 0.93)); + const dupTask = Math.min(1, Math.max(0, Number(kbDupThresholdTask.value) || 0.93)); + // Same `|| default` reasoning: falling back to 0 would surface every // snippet in the corpus on every edit, which is the failure this knob fixes. const wpT = Math.min(1, Math.max(0, Number(kbWritePathThreshold.value) || 0.68)); kbInjectThreshold.value = String(t); kbInjectTopK.value = String(k); - kbDuplicateThreshold.value = String(dupT); + kbDupThresholdSnippet.value = String(dupSnip); + kbDupThresholdNote.value = String(dupNote); + kbDupThresholdTask.value = String(dupTask); kbWritePathThreshold.value = String(wpT); savingKbInject.value = true; kbInjectSaved.value = false; @@ -99,7 +107,9 @@ async function saveKbInject() { // measurements that split them. kb_writepath_enabled: kbWritePathEnabled.value ? 'true' : 'false', kb_writepath_threshold: String(wpT), - kb_duplicate_threshold: String(dupT), + kb_duplicate_threshold_snippet: String(dupSnip), + kb_duplicate_threshold_note: String(dupNote), + kb_duplicate_threshold_task: String(dupTask), }); kbInjectSaved.value = true; setTimeout(() => (kbInjectSaved.value = false), 2000); @@ -487,8 +497,14 @@ onMounted(async () => { if (allSettings.kb_writepath_threshold !== undefined) { kbWritePathThreshold.value = allSettings.kb_writepath_threshold; } - if (allSettings.kb_duplicate_threshold !== undefined) { - kbDuplicateThreshold.value = allSettings.kb_duplicate_threshold; + if (allSettings.kb_duplicate_threshold_snippet !== undefined) { + kbDupThresholdSnippet.value = allSettings.kb_duplicate_threshold_snippet; + } + if (allSettings.kb_duplicate_threshold_note !== undefined) { + kbDupThresholdNote.value = allSettings.kb_duplicate_threshold_note; + } + if (allSettings.kb_duplicate_threshold_task !== undefined) { + kbDupThresholdTask.value = allSettings.kb_duplicate_threshold_task; } if (allSettings.notify_task_reminders !== undefined) { notifyTaskReminders.value = allSettings.notify_task_reminders !== "false"; @@ -1267,10 +1283,10 @@ function formatUserDate(iso: string): string { is for (#274). -->
- +
+ +
+ + +

+ The floor for the Knowledge page's note report. Notes are compared + section by section, so related records — a run of dev-logs, notes on + one topic — score high without being duplicates. Stricter than the + snippet floor on purpose; lower it to browse related families rather + than hunt true duplicates. +

+
+ +
+ + +

+ Same as the note floor, for the task report. Step tasks from different + milestones ("Verify on CI") legitimately resemble each other, so this + stays strict to keep the report pointed at work opened twice. +

+