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. +

+