96 lines
3.3 KiB
Python
96 lines
3.3 KiB
Python
"""V017 - per-turn router decision records.
|
|
|
|
Creates ``router_decisions``: one row per routed user message, written
|
|
best-effort by ``persistence/router_decision_writer.py``. The table is the
|
|
durable source for the router's sticky/anti-downgrade history
|
|
(``RoutingHistoryStore`` is rehydrated from it at gateway boot) and for
|
|
operator audit of routing behavior.
|
|
|
|
Privacy contract: the table has **no free-text columns**. ``probs`` is a JSON
|
|
array of numbers; ``flags`` is a JSON array of enum tokens; ``trail`` is a
|
|
JSON array of stage entries containing only enum tokens, booleans, and
|
|
numbers. Prompt text never reaches this table — the writer sanitizes every
|
|
JSON column and ``tests/test_persistence/test_router_decision_writer.py``
|
|
enforces the bar.
|
|
|
|
Downgrade story (SchemaAheadError, refusal-by-design): boot only migrates
|
|
forward. An older build whose migration set does not include V017 refuses to
|
|
boot against a database that records it (``assert_schema_not_ahead`` in
|
|
``persistence/migrator.py``). Operators who must run such a build first roll
|
|
back with yoyo, which invokes this module's ``rollback_step`` (drops the
|
|
index and the table) and removes V017 from the ledger.
|
|
|
|
The ``session_key`` column is intentionally not FK-protected: the ``sessions``
|
|
table is created lazily by ``SessionStorage.connect()`` rather than by yoyo
|
|
(V001 docstring precedent). Cleanup is the explicit purge in
|
|
``SessionStorage.delete_session`` plus the writer's write-time retention
|
|
pruning.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from yoyo import step
|
|
|
|
__depends__: set[str] = {"V016__meta_skill_runs_triggered_by_manual_command"}
|
|
|
|
|
|
CREATE_ROUTER_DECISIONS = """
|
|
CREATE TABLE router_decisions (
|
|
decision_id TEXT PRIMARY KEY,
|
|
session_key TEXT NOT NULL,
|
|
turn_index INTEGER,
|
|
ts_ms INTEGER NOT NULL,
|
|
classifier TEXT,
|
|
proposed_tier TEXT,
|
|
confidence REAL,
|
|
probs TEXT,
|
|
flags TEXT,
|
|
final_tier TEXT,
|
|
provider TEXT,
|
|
model TEXT,
|
|
thinking_level TEXT,
|
|
source TEXT,
|
|
trail TEXT,
|
|
baseline_model TEXT,
|
|
savings_pct REAL,
|
|
executed_kind TEXT
|
|
CHECK(executed_kind IN ('single', 'ensemble')),
|
|
ensemble_profile TEXT,
|
|
fallback_hops INTEGER NOT NULL DEFAULT 0
|
|
)
|
|
"""
|
|
|
|
CREATE_INDEX = (
|
|
"CREATE INDEX idx_router_decisions_session_ts"
|
|
" ON router_decisions(session_key, ts_ms)"
|
|
)
|
|
|
|
|
|
def _table_exists(conn, table: str) -> bool:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
|
(table,),
|
|
)
|
|
return cur.fetchone() is not None
|
|
|
|
|
|
def apply_step(conn) -> None:
|
|
if _table_exists(conn, "router_decisions"):
|
|
return
|
|
cur = conn.cursor()
|
|
cur.execute(CREATE_ROUTER_DECISIONS)
|
|
cur.execute(CREATE_INDEX)
|
|
|
|
|
|
def rollback_step(conn) -> None:
|
|
# Rollback drops the index and the table (V010 precedent). Decision
|
|
# records are observability, not conversation state — dropping them is
|
|
# safe; the in-process RoutingHistoryStore simply starts cold again.
|
|
cur = conn.cursor()
|
|
cur.execute("DROP INDEX IF EXISTS idx_router_decisions_session_ts")
|
|
cur.execute("DROP TABLE IF EXISTS router_decisions")
|
|
|
|
|
|
steps = [step(apply_step, rollback_step)]
|