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..e912d4c 100644 --- a/frontend/src/api/snippets.ts +++ b/frontend/src/api/snippets.ts @@ -46,6 +46,16 @@ 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; +} + /** 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 +71,8 @@ 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; } /** Create/update payload — discrete fields the backend serializes into the diff --git a/frontend/src/views/SnippetListView.vue b/frontend/src/views/SnippetListView.vue index 8fbed78..2af502f 100644 --- a/frontend/src/views/SnippetListView.vue +++ b/frontend/src/views/SnippetListView.vue @@ -126,6 +126,40 @@ function splitTitle(title: string): { name: string; when: string } { function languageOf(tags: string[]): string { return tags.find((t) => t && t !== "snippet") ?? ""; } + +/** A snippet that has been offered repeatedly and never opened. The threshold + * is 3 rather than 1 because one or two surfacings is noise — the record may + * simply not have come up in a relevant context yet. */ +function isDeadWeight(s: SnippetListItem): boolean { + const u = s.usage; + return !!u && u.pull_count === 0 && u.surfaced_count >= 3; +} + +/** Short badge text, or "" to render nothing. A record nobody has surfaced yet + * gets no badge at all: "0 / 0" would read as a verdict when it's an absence + * of evidence. */ +function usageBadge(s: SnippetListItem): string { + const u = s.usage; + if (!u || u.surfaced_count === 0) return ""; + return `${u.pull_count}/${u.surfaced_count} used`; +} + +function usageTitle(s: SnippetListItem): string { + const u = s.usage; + if (!u) return ""; + const last = u.last_pulled_at + ? `Last opened ${new Date(u.last_pulled_at).toLocaleDateString()}.` + : "Never opened."; + const verdict = isDeadWeight(s) + ? " Offered repeatedly without ever being opened — consider rewriting its" + + " “when to reach for it” so it says when, or deleting it. It takes a slot" + + " in every future auto-inject menu." + : ""; + return ( + `Surfaced to an agent ${u.surfaced_count}×, opened in full ` + + `${u.pull_count}×. ${last}${verdict}` + ); +}