"""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")