diff --git a/alembic/versions/0071_note_usage_events.py b/alembic/versions/0071_note_usage_events.py new file mode 100644 index 0000000..c9ac3cb --- /dev/null +++ b/alembic/versions/0071_note_usage_events.py @@ -0,0 +1,72 @@ +"""add note_usage_events — did anyone actually open what we surfaced? + +Revision ID: 0071 +Revises: 0070 +Create Date: 2026-07-28 + +`retrieval_logs` records what the ranker returned and with what scores, which is +the right substrate for tuning a similarity threshold. It cannot answer the +different question the snippet corpus needs: was a surfaced snippet ever pulled +in full? A snippet nobody opens still competes for the injection budget on every +turn, so the surfaced:pulled ratio is what makes dead weight visible. + +Two reasons this is its own table rather than columns on `notes` or rows in +`retrieval_logs`: + + - Counters on `notes` would answer "how many" but not "when, from where, and + by which arm" — and the place arm vs semantic arm comparison is precisely + what was missing (the write-path place arm surfaced snippets while leaving + no trace anywhere). + - Folding un-scored surfacing into `retrieval_logs` would corrupt the score + distribution that table exists to capture. Location hits have no score. + +Grain is one row per note per event, which is what the per-snippet readout needs +and what `retrieval_logs.result_ids` (a JSONB array, one row per *call*) cannot +be indexed at. + +FK-free on note_id and user_id, matching retrieval_logs and app_logs: telemetry +should outlive what it describes. Deleting a note must not erase the evidence +that it was surfaced forty times and opened none. + +Downgrade drops the table outright. The data is purely observational — nothing +reads it for correctness, so losing it costs history and no behavior. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0071" +down_revision = "0070" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "note_usage_events", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("note_id", sa.Integer(), nullable=False), + sa.Column("event", sa.Text(), nullable=False), + sa.Column("source", sa.Text(), nullable=False), + ) + # Every readout is "these note ids, split by event", so the composite is the + # one that actually gets used; the others serve pruning and per-user views. + op.create_index( + "ix_note_usage_note_event", "note_usage_events", ["note_id", "event"] + ) + op.create_index("ix_note_usage_created_at", "note_usage_events", ["created_at"]) + op.create_index("ix_note_usage_user_id", "note_usage_events", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_note_usage_user_id", table_name="note_usage_events") + op.drop_index("ix_note_usage_created_at", table_name="note_usage_events") + op.drop_index("ix_note_usage_note_event", table_name="note_usage_events") + op.drop_table("note_usage_events") diff --git a/frontend/src/api/snippets.ts b/frontend/src/api/snippets.ts index fa7ef03..0fae70b 100644 --- a/frontend/src/api/snippets.ts +++ b/frontend/src/api/snippets.ts @@ -20,9 +20,13 @@ export interface SnippetFields { path: string; symbol: string; locations: SnippetLocation[]; - /** Ids of the snippets folded into this one by merge, oldest first. Read-only: - * merge is the only thing that adds to it, and an edit carries it forward. */ - merged_from: number[]; + /** The snippets folded into this one by merge, oldest first. Read-only: merge + * is the only thing that adds to it, and an edit carries it forward. + * + * Each entry records what THAT source contributed — never what the survivor + * already had — which is what lets un-merge subtract exactly. An entry with + * no `locations`/`tags` predates that attribution and cannot be un-merged. */ + merged_from: { id: number; locations?: SnippetLocation[]; tags?: string[] }[]; code: string; } @@ -46,6 +50,29 @@ export interface Snippet { owner?: string | null; } +/** How often a record was put in front of an agent versus actually opened. + * A high `surfaced_count` with `pull_count: 0` is dead weight — it occupies a + * slot in every future auto-inject menu while never being used. */ +export interface SnippetUsage { + surfaced_count: number; + pull_count: number; + last_surfaced_at: string | null; + last_pulled_at: string | null; +} + +/** Result of the last drift check — does the recorded location and code still + * match source? The check runs agent-side (Scribe has no checkout); this is the + * remembered verdict. `current` is false once the snippet has been edited since + * the check, at which point the verdict describes code that's no longer there. */ +export interface SnippetVerification { + status: "ok" | "missing" | "moved" | "changed" | "unverified"; + current: boolean; + checked_at: string | null; + detail?: string | null; + path?: string | null; + needs_attention?: boolean; +} + /** Lightweight list item from the knowledge preview feed. Note: the `snippet` * field here is a truncated *body preview* (the knowledge feed's naming), not * the parsed fields above. */ @@ -61,6 +88,11 @@ export interface SnippetListItem { * them, not one of your own. Absent means it's yours. */ shared?: boolean; owner?: string | null; + /** Always present from the backend, zero-filled for records with no events. */ + usage?: SnippetUsage; + /** Present on the detail record; the list feed carries it when a check has + * been recorded. */ + verification?: SnippetVerification; } /** Create/update payload — discrete fields the backend serializes into the @@ -91,6 +123,8 @@ export async function listSnippets( repo?: string; path?: string; symbol?: string; + /** Drift check: "attention" | "ok" | "unverified" | "drifted" | a status. */ + verification?: string; } = {}, ): Promise<{ snippets: SnippetListItem[]; total: number }> { const qs = new URLSearchParams(); @@ -100,6 +134,7 @@ export async function listSnippets( if (params.repo) qs.set("repo", params.repo); if (params.path) qs.set("path", params.path); if (params.symbol) qs.set("symbol", params.symbol); + if (params.verification) qs.set("verification", params.verification); const query = qs.toString(); return apiGet(`/api/snippets${query ? `?${query}` : ""}`); } @@ -123,6 +158,45 @@ 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. */ +export async function verifySnippet( + id: number, + verdict: { status: string; detail?: string; path?: string }, +): Promise { + return apiPost(`/api/snippets/${id}/verify`, verdict); +} + +/** Pull one source back out of a merged survivor: restores it and strips exactly + * what it contributed. Also repairs a half-undone merge — a source restored + * from the trash by hand leaves the survivor still claiming its call sites. */ +export async function unmergeSnippet( + survivorId: number, + sourceId: number, +): Promise<{ survivor: Snippet; restored: Snippet | null }> { + return apiPost(`/api/snippets/${survivorId}/unmerge`, { source_id: sourceId }); +} + /** Unify `sourceIds` into the canonical snippet `targetId`. Returns the merged * survivor plus `merged_ids` — the sources actually folded in and trashed. */ export async function mergeSnippets( 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. +

+