diff --git a/alembic/versions/0109_embeddings_record_their_model.py b/alembic/versions/0109_embeddings_record_their_model.py new file mode 100644 index 0000000..334b298 --- /dev/null +++ b/alembic/versions/0109_embeddings_record_their_model.py @@ -0,0 +1,42 @@ +"""embeddings_record_their_model — a vector says whose space it lives in (#4132) + +Revision ID: 0109 +Revises: 0108 +Create Date: 2026-09-23 + +Every embedding table stamps `chunker_version`, so a change to the document +shape is caught per row and re-embedded. None stamped the MODEL. The column is +`vector(384)` — a width, not an identity — so swapping bge-small for any other +384-dim model would write a second geometry beside the first with no error, +and search would go on ranking by cosines between the two, which mean nothing. + +`embedding_model` is the other half of `calibration_stamp()`, stored per row on +all four tables. The startup backfill now re-embeds on either half moving. + +THE BACKFILL LITERAL IS SAFE BECAUSE NO INSTALL HAS EVER CHANGED MODELS. The +name is hardcoded in `services/embeddings.py`, and every vector ever written was +written by it — so stamping every existing row with that name states a fact, +not a guess. It is frozen here rather than imported: a migration records what +was true when it ran, and a later model change must not rewrite history. +""" +from alembic import op + +revision = "0109" +down_revision = "0108" +branch_labels = None +depends_on = None + +_TABLES = ("note_embeddings", "rule_embeddings", "milestone_embeddings", "system_embeddings") +_MODEL = "BAAI/bge-small-en-v1.5" + + +def upgrade() -> None: + for table in _TABLES: + op.execute(f"ALTER TABLE {table} ADD COLUMN embedding_model text") + op.execute(f"UPDATE {table} SET embedding_model = '{_MODEL}'") + op.execute(f"ALTER TABLE {table} ALTER COLUMN embedding_model SET NOT NULL") + + +def downgrade() -> None: + for table in _TABLES: + op.execute(f"ALTER TABLE {table} DROP COLUMN embedding_model") diff --git a/alembic/versions/0110_task_claims.py b/alembic/versions/0110_task_claims.py new file mode 100644 index 0000000..3b95078 --- /dev/null +++ b/alembic/versions/0110_task_claims.py @@ -0,0 +1,38 @@ +"""task_claims — a task can say a session is working it (milestone 381 step 2) + +Revision ID: 0110 +Revises: 0109 +Create Date: 2026-09-23 + +`status` is durable and nothing clears it, so `in_progress` cannot also mean +"someone is on this now". The claim is that second meaning, stored as WHO and +WHEN rather than as a flag, so it can be read as dead without anything having +cleared it (`services/task_claims.py` has the semantics). + +Four nullable columns, no backfill: no existing row has ever been claimed, and +inventing a claim for one would assert attention nobody observed. No CHECK +enum is touched. +""" +import sqlalchemy as sa +from alembic import op + +revision = "0110" +down_revision = "0109" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("notes", sa.Column( + "claimed_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, + )) + op.add_column("notes", sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("notes", sa.Column("claim_touched_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("notes", sa.Column("claim_session", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("notes", "claim_session") + op.drop_column("notes", "claim_touched_at") + op.drop_column("notes", "claimed_at") + op.drop_column("notes", "claimed_by") diff --git a/frontend/src/assets/sweep-shared.css b/frontend/src/assets/sweep-shared.css new file mode 100644 index 0000000..a2f81df --- /dev/null +++ b/frontend/src/assets/sweep-shared.css @@ -0,0 +1,137 @@ +/* The sweep-row visual language (#3207). + * + * A "sweep" is a pane that lists records in the order they most need a human + * — the note verification sweep, the rule sweep, preference drift — as one + * raised row per record: an italic title that opens it, an age or date pushed + * right, a small grid of facts, and a row of plain buttons. It was written + * out four times, scoped, before this sheet existed. + * + * Imported UNSCOPED, once, from main.ts, so every + * class is prefixed `sweep-`: `.row-title`, `.lede`, `.age` and `.actions` + * also exist, scoped, in views that have nothing to do with sweeps, and an + * unprefixed global would leak into all of them. What a pane does + * differently stays in its own scoped block — spacing remainders, a `code` + * style — rather than growing a modifier here per pane. + */ + +/* The pane itself: header, filters, rows and footnote stacked. */ +.sweep { display: flex; flex-direction: column; gap: var(--fs-space-3); } + +.sweep-lede { + margin: 0; + max-width: 62ch; + font-size: var(--fs-size-body-sm); + color: var(--fs-text-secondary); + line-height: var(--fs-leading-body); +} + +.sweep-filters { + display: flex; + gap: var(--fs-space-5); + align-items: center; + flex-wrap: wrap; +} +.sweep-filter { + display: flex; + align-items: center; + gap: var(--fs-space-2); + font-size: 0.82rem; + color: var(--fs-text-secondary); +} +.sweep-filter input[type="checkbox"] { accent-color: var(--fs-accent); } + +.sweep-state { + margin: 0; + font-size: var(--fs-size-body-sm); + color: var(--fs-text-secondary); +} +.sweep-state.empty { color: var(--fs-text-tertiary); } + +.sweep-rows { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--fs-space-3); +} +.sweep-row { + background: var(--fs-surface-raised); + border-radius: var(--fs-radius-md); + padding: var(--fs-space-3); +} +.sweep-row-head { + display: flex; + align-items: baseline; + gap: var(--fs-space-2); + flex-wrap: wrap; +} +.sweep-row-title { + background: none; + border: none; + padding: 0; + cursor: pointer; + font-family: Fraunces, serif; + font-style: italic; + font-size: 1.02rem; + color: var(--fs-text-primary); + text-align: left; +} +.sweep-row-title:hover { text-decoration: underline; } + +/* The ORDER carries urgency — the top of a sweep is the thing that most needs + a look. No red/amber ramp: it would restate the ordering and force an + invented "stale after N days" threshold. "Never" is marked because it is + categorically DIFFERENT from a date, not a worse one. */ +.sweep-age { + margin-left: auto; + font-size: 0.78rem; + color: var(--fs-text-secondary); + font-variant-numeric: tabular-nums; +} +.sweep-age.unchecked { font-style: italic; color: var(--fs-text-tertiary); } + +.sweep-check { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.15rem var(--fs-space-3); + margin: var(--fs-space-3) 0 0; +} +.sweep-check dt { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--fs-text-tertiary); +} +.sweep-check dd { + margin: 0; + font-size: 0.82rem; + color: var(--fs-text-primary); + min-width: 0; +} + +.sweep-actions { + display: flex; + gap: var(--fs-space-2); + margin-top: var(--fs-space-3); +} +.sweep-actions button { + cursor: pointer; + font: inherit; + font-size: 0.78rem; + background: var(--fs-surface-page); + color: var(--fs-text-primary); + border: 1px solid var(--fs-border-color); + border-radius: var(--fs-radius-md); + padding: 0.25rem 0.6rem; +} +.sweep-actions button:hover:not(:disabled) { background: var(--fs-surface-hover); } +.sweep-actions button:disabled { opacity: var(--fs-disabled-opacity); cursor: default; } + +.sweep-footnote { + margin: 0; + max-width: 62ch; + font-size: 0.78rem; + color: var(--fs-text-tertiary); + line-height: 1.45; +} diff --git a/frontend/src/components/NoteSweepPane.vue b/frontend/src/components/NoteSweepPane.vue index cd18eb9..f7d4d2a 100644 --- a/frontend/src/components/NoteSweepPane.vue +++ b/frontend/src/components/NoteSweepPane.vue @@ -83,42 +83,42 @@ defineExpose({ reload });

Due for verification

-

+

Notes that assert a fact about something outside your control — what a service does, how a tool behaves. Most notes are decisions and never appear here; they have no truth value to go stale.

-
-