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:
@@ -46,6 +46,50 @@ const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
|
||||
const searchQuery = ref("");
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// ─── Near-duplicate report ────────────────────────────────────────────────────
|
||||
// On demand, never automatic — a corpus-wide pairwise scan, and most visits to
|
||||
// this page aren't a tidy-up (same reasoning as SnippetListView's report).
|
||||
|
||||
interface DupMember {
|
||||
id: number; title: string;
|
||||
created_at?: string | null; updated_at?: string | null;
|
||||
task_kind?: string | null;
|
||||
}
|
||||
interface DupGroup {
|
||||
note_ids: number[];
|
||||
members: DupMember[];
|
||||
top_score: number;
|
||||
existing_supersessions?: { superseder_id: number; superseded_id: number }[];
|
||||
}
|
||||
|
||||
const dupGroups = ref<DupGroup[]>([]);
|
||||
const dupSuggestion = ref("");
|
||||
const dupLoading = ref(false);
|
||||
const dupChecked = ref(false);
|
||||
// The report follows the type filter: viewing tasks checks tasks. Anything
|
||||
// else (all / plan / process) checks notes — the kind with the most to find.
|
||||
const dupKind = computed(() => (activeType.value === "task" ? "task" : "note"));
|
||||
|
||||
async function loadDuplicates() {
|
||||
dupLoading.value = true;
|
||||
try {
|
||||
const data = await apiGet<{ groups: DupGroup[]; suggestion: string }>(
|
||||
`/api/notes/duplicates?kind=${dupKind.value}`
|
||||
);
|
||||
dupGroups.value = data.groups;
|
||||
dupSuggestion.value = data.suggestion;
|
||||
dupChecked.value = true;
|
||||
} catch {
|
||||
dupChecked.value = false;
|
||||
} finally {
|
||||
dupLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// A stale report is worse than none: switching the type filter changes which
|
||||
// kind the button checks, so the old kind's groups must not linger under it.
|
||||
watch(dupKind, () => { dupChecked.value = false; dupGroups.value = []; });
|
||||
|
||||
// ─── Type counts ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface KnowledgeCounts { note: number; task: number; plan: number; process: number; total: number }
|
||||
@@ -385,6 +429,51 @@ onUnmounted(() => {
|
||||
<Share2 :size="16" />
|
||||
Graph
|
||||
</button>
|
||||
<button
|
||||
class="btn-ghost btn-compact"
|
||||
:disabled="dupLoading"
|
||||
title="Find notes or tasks already recorded that closely resemble each other — the report proposes, it never changes anything"
|
||||
@click="loadDuplicates"
|
||||
>
|
||||
{{ dupLoading ? "Checking…" : "Find duplicates" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Near-duplicate report. A proposal surface only: unlike snippets
|
||||
(which merge losslessly), notes are never merged — the right fix is
|
||||
supersession, extraction into a reference note, or leaving parallel
|
||||
records alone, and choosing needs the records READ. That reading is
|
||||
the assistant's job; this panel shows the human what exists. -->
|
||||
<div v-if="dupChecked && !dupLoading" class="dup-panel">
|
||||
<p v-if="!dupGroups.length" class="dup-empty">
|
||||
No near-duplicate {{ dupKind }}s found — nothing recorded resembles
|
||||
anything else closely enough to flag.
|
||||
</p>
|
||||
<template v-else>
|
||||
<p class="dup-head">
|
||||
{{ dupGroups.length }} possible duplicate
|
||||
{{ dupGroups.length > 1 ? "sets" : "set" }} among your
|
||||
{{ dupKind }}s. {{ dupSuggestion }}
|
||||
</p>
|
||||
<div v-for="(g, i) in dupGroups" :key="i" class="dup-group">
|
||||
<div class="dup-members">
|
||||
<router-link
|
||||
v-for="m in g.members"
|
||||
:key="m.id"
|
||||
class="dup-member"
|
||||
:to="dupKind === 'task' ? `/tasks/${m.id}` : `/notes/${m.id}`"
|
||||
>
|
||||
#{{ m.id }} {{ m.title }}
|
||||
</router-link>
|
||||
</div>
|
||||
<span class="dup-score">{{ Math.round(g.top_score * 100) }}% alike</span>
|
||||
<span
|
||||
v-if="g.existing_supersessions?.length"
|
||||
class="dup-claimed"
|
||||
title="A supersession has already been declared inside this set — it is not an open question"
|
||||
>already ruled on</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Loading / empty -->
|
||||
@@ -952,4 +1041,64 @@ onUnmounted(() => {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
/* ── Near-duplicate report ──────────────────────────────────────────────────
|
||||
Mirrors SnippetListView's panel so the two reports read as one feature.
|
||||
Scoped styles can't be shared across SFCs; if a third view ever grows this
|
||||
panel, promote the family to components.css and record it (#2464's rule:
|
||||
two-or-more is when a recipe earns the shared sheet). */
|
||||
.dup-panel {
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-alt);
|
||||
}
|
||||
.dup-empty,
|
||||
.dup-head {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.dup-empty { margin-bottom: 0; }
|
||||
.dup-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.dup-members {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
flex: 1 1 20rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.dup-member {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.dup-member:hover { background: var(--color-hover); }
|
||||
.dup-score {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* A set someone already ruled on — quiet, not celebratory: it means "skip". */
|
||||
.dup-claimed {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.05rem 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user