Files
praxis/db/store.py
T
Praxis CI f2a12f9fed docs(milestone): complete v0.4-operator-tier — v0.1.9 tagged, milestone release, merged to main
v0.4 (Operator Tier — Cohort Dashboard + Auth + Postgres) milestone complete.

Phases:
  ✓ P0  pre-execution (planning)        → v0.1.6
  ✓ P1  operator foundation (Postgres+auth+VC migration) → v0.1.7
  ✓ P2  cohort dashboard + aggregation   → v0.1.8
  ✓ P3  final review + ship              → v0.1.9 (= v0.4 milestone release)

Requirements covered (8/8):
  REQ-MT-01 (Postgres store), REQ-MT-02 (aggregation pipeline),
  REQ-AUTH-01 (operator auth), REQ-DASH-01 (cohort dashboard),
  REQ-NFR-AUTH-01 (auth NFRs), REQ-NFR-MT-01 (Postgres-in-LXC),
  REQ-NFR-DASH-01 (k-anonymity ≥10), REQ-NFR-DASH-02 (freshness ≤24h)

Grill MUSTs honored (6/6): G-008, G-011, G-027, G-031, G-038, G-041

Tests: 317 pytest pass, 36 skip (Postgres-requiring), 0 fail; 17/17 vitest pass
Review: APPROVE_WITH_NOTES (6/6 personas, 0 P0, 8 P1+ carry-forward)
Audit: HEALTHY (reconstruction PASS, 8/8 REQ, 6/6 grill)

---ci---
project: praxis
phase: 3
milestone: v0.4
status: complete
phase_role: final
milestone_complete: true
milestone_merged_to_main: true
tag: v0.1.9
requirements:
  covered: [REQ-MT-01, REQ-MT-02, REQ-AUTH-01, REQ-DASH-01, REQ-NFR-AUTH-01, REQ-NFR-MT-01, REQ-NFR-DASH-01, REQ-NFR-DASH-02]
  partial: []
---/ci---
2026-08-04 11:58:44 +00:00

422 lines
15 KiB
Python

"""Async SQLite store — learner state access layer (D-007, TASK-04-02).
Type-annotated async access via aiosqlite. Functions:
- start_session(learner_id, scenario_id) → session_id
- log_turn(session_id, seq, role, asr_text, tts_text, latency_ms)
- end_session(session_id, branch_path, outcome, cost_cents, cost_breakdown, debrief_text)
- update_progress(learner_id, scenario_id, outcome)
- get_session(session_id) + get_turns(session_id)
No auth — learner_id is the hardcoded 'learner-1' (D-007).
"""
from __future__ import annotations
import json
import os
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import aiosqlite
from db.migrate import apply_migrations
# G-102 FIX: read PRAXIS_DB_PATH from env so the Docker volume mount
# actually persists data (docker-compose.yml sets PRAXIS_DB_PATH=/app/data/praxis.db).
_DEFAULT_DB_PATH = os.environ.get("PRAXIS_DB_PATH", "praxis.db")
HARDCODED_LEARNER_ID = "learner-1"
@dataclass
class SessionRow:
id: str
learner_id: str
scenario_id: str
started_at: str
ended_at: str | None
branch_path_json: str | None
outcome: str | None
cost_estimated_cents: int | None
debrief_text: str | None
cost_breakdown_json: str | None
@property
def branch_path(self) -> list[str]:
if self.branch_path_json:
return json.loads(self.branch_path_json)
return []
@property
def cost_breakdown(self) -> dict[str, Any]:
if self.cost_breakdown_json:
return json.loads(self.cost_breakdown_json)
return {}
@dataclass
class TurnRow:
id: int
session_id: str
seq: int
role: str
asr_text: str | None
tts_text: str | None
latency_ms: float | None
created_at: str
class PraxisStore:
"""Async SQLite store for v0.1 learner state."""
def __init__(self, db_path: str | Path = _DEFAULT_DB_PATH) -> None:
self.db_path = str(db_path)
async def init(self) -> None:
"""Apply migrations (idempotent). Call once at startup."""
apply_migrations(self.db_path)
def _connect(self) -> aiosqlite.Connection:
return aiosqlite.connect(self.db_path)
async def start_session(self, learner_id: str, scenario_id: str) -> str:
"""Create a session row, return the new session id."""
session_id = f"sess-{uuid.uuid4().hex[:12]}"
async with self._connect() as db:
await db.execute(
"INSERT INTO sessions (id, learner_id, scenario_id) VALUES (?, ?, ?)",
(session_id, learner_id, scenario_id),
)
await db.commit()
return session_id
async def log_turn(
self,
session_id: str,
seq: int,
role: str,
asr_text: str | None = None,
tts_text: str | None = None,
latency_ms: float | None = None,
) -> None:
async with self._connect() as db:
await db.execute(
"INSERT INTO turns (session_id, seq, role, asr_text, tts_text, latency_ms) "
"VALUES (?, ?, ?, ?, ?, ?)",
(session_id, seq, role, asr_text, tts_text, latency_ms),
)
await db.commit()
async def end_session(
self,
session_id: str,
branch_path: list[str],
outcome: str,
cost_cents: int | None = None,
cost_breakdown: dict[str, Any] | None = None,
debrief_text: str | None = None,
) -> None:
async with self._connect() as db:
await db.execute(
"UPDATE sessions SET ended_at = datetime('now'), "
"branch_path_json = ?, outcome = ?, cost_estimated_cents = ?, "
"cost_breakdown_json = ?, debrief_text = ? WHERE id = ?",
(
json.dumps(branch_path),
outcome,
cost_cents,
json.dumps(cost_breakdown) if cost_breakdown else None,
debrief_text,
session_id,
),
)
await db.commit()
async def update_progress(
self, learner_id: str, scenario_id: str, outcome: str
) -> None:
async with self._connect() as db:
cur = await db.execute(
"SELECT attempts FROM progress WHERE learner_id = ? AND scenario_id = ?",
(learner_id, scenario_id),
)
row = await cur.fetchone()
if row:
await db.execute(
"UPDATE progress SET attempts = attempts + 1, last_outcome = ?, "
"updated_at = datetime('now') WHERE learner_id = ? AND scenario_id = ?",
(outcome, learner_id, scenario_id),
)
else:
await db.execute(
"INSERT INTO progress (learner_id, scenario_id, attempts, last_outcome) "
"VALUES (?, ?, 1, ?)",
(learner_id, scenario_id, outcome),
)
await db.commit()
async def get_session(self, session_id: str) -> SessionRow | None:
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute("SELECT * FROM sessions WHERE id = ?", (session_id,))
row = await cur.fetchone()
if row is None:
return None
return SessionRow(**dict(row))
async def get_turns(self, session_id: str) -> list[TurnRow]:
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT * FROM turns WHERE session_id = ? ORDER BY seq", (session_id,)
)
rows = await cur.fetchall()
return [TurnRow(**dict(r)) for r in rows]
async def get_learner(self, learner_id: str = HARDCODED_LEARNER_ID) -> dict | None:
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute("SELECT * FROM learner WHERE id = ?", (learner_id,))
row = await cur.fetchone()
return dict(row) if row else None
async def get_ability(self, learner_id: str, path: str) -> dict | None:
"""Return the learner_ability row for (learner_id, path) or None."""
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT learner_id, path, theta, sigma_sq, observations, updated_at "
"FROM learner_ability WHERE learner_id = ? AND path = ?",
(learner_id, path),
)
row = await cur.fetchone()
return dict(row) if row else None
async def upsert_ability(
self,
learner_id: str,
path: str,
theta: float,
sigma_sq: float,
observations: int,
) -> None:
"""Insert or update the learner_ability row for (learner_id, path)."""
async with self._connect() as db:
await db.execute(
"INSERT INTO learner_ability (learner_id, path, theta, sigma_sq, observations, updated_at) "
"VALUES (?, ?, ?, ?, ?, datetime('now')) "
"ON CONFLICT(learner_id, path) DO UPDATE SET "
"theta = excluded.theta, sigma_sq = excluded.sigma_sq, "
"observations = excluded.observations, updated_at = datetime('now')",
(learner_id, path, theta, sigma_sq, observations),
)
await db.commit()
async def get_progress(self, learner_id: str, path: str) -> dict | None:
"""Return the mastery_progress row for (learner_id, path) or None."""
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT learner_id, path, current_week, scenarios_passed_json, "
"mastery_score, gate_open, updated_at "
"FROM mastery_progress WHERE learner_id = ? AND path = ?",
(learner_id, path),
)
row = await cur.fetchone()
return dict(row) if row else None
async def upsert_progress(
self,
learner_id: str,
path: str,
current_week: int,
scenarios_passed: list[str],
mastery_score: float,
gate_open: bool,
) -> None:
"""Insert or update the mastery_progress row for (learner_id, path)."""
gate_int = 1 if gate_open else 0
async with self._connect() as db:
await db.execute(
"INSERT INTO mastery_progress "
"(learner_id, path, current_week, scenarios_passed_json, mastery_score, gate_open, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, datetime('now')) "
"ON CONFLICT(learner_id, path) DO UPDATE SET "
"current_week = excluded.current_week, "
"scenarios_passed_json = excluded.scenarios_passed_json, "
"mastery_score = excluded.mastery_score, gate_open = excluded.gate_open, "
"updated_at = datetime('now')",
(
learner_id,
path,
current_week,
json.dumps(scenarios_passed),
mastery_score,
gate_int,
),
)
await db.commit()
async def record_gate_event(
self,
learner_id: str,
path: str,
week: int,
scenarios_passed: list[str],
rubric_scores: list[dict],
mastery_score: float,
gate_open: bool,
) -> str:
"""Append a row to the mastery_gate_events audit log; return the event id."""
event_id = f"gate-{uuid.uuid4().hex[:12]}"
gate_int = 1 if gate_open else 0
async with self._connect() as db:
await db.execute(
"INSERT INTO mastery_gate_events "
"(id, learner_id, path, week, scenarios_passed_json, rubric_scores_json, "
"mastery_score, gate_open, recorded_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))",
(
event_id,
learner_id,
path,
week,
json.dumps(scenarios_passed),
json.dumps(rubric_scores),
mastery_score,
gate_int,
),
)
await db.commit()
return event_id
async def list_gate_events(
self, learner_id: str, path: str | None = None
) -> list[dict]:
"""Query mastery_gate_events by learner (optionally by path), oldest first."""
async with self._connect() as db:
db.row_factory = aiosqlite.Row
if path is None:
cur = await db.execute(
"SELECT * FROM mastery_gate_events WHERE learner_id = ? "
"ORDER BY recorded_at, id",
(learner_id,),
)
else:
cur = await db.execute(
"SELECT * FROM mastery_gate_events WHERE learner_id = ? AND path = ? "
"ORDER BY recorded_at, id",
(learner_id, path),
)
rows = await cur.fetchall()
return [dict(r) for r in rows]
async def init_issuer_key(
self, key_id: str, public_key: str, private_key_enc: bytes
) -> None:
async with self._connect() as db:
await db.execute(
"INSERT INTO issuer_keys (id, public_key, private_key_enc, status) "
"VALUES (?, ?, ?, 'active')",
(key_id, public_key, private_key_enc),
)
await db.commit()
async def get_active_signing_key_row(self) -> dict | None:
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT id, public_key, private_key_enc, status, created_at "
"FROM issuer_keys WHERE status = 'active' ORDER BY created_at DESC LIMIT 1"
)
row = await cur.fetchone()
return dict(row) if row else None
async def get_public_key_row(self, key_id: str) -> dict | None:
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT id, public_key, private_key_enc, status, created_at "
"FROM issuer_keys WHERE id = ?",
(key_id,),
)
row = await cur.fetchone()
return dict(row) if row else None
async def set_issuer_key_superseded(self, key_id: str) -> None:
async with self._connect() as db:
await db.execute(
"UPDATE issuer_keys SET status = 'superseded' WHERE id = ?",
(key_id,),
)
await db.commit()
async def insert_credential(
self,
cred_id: str,
learner_id: str,
payload_json: str,
signature_b64: str,
) -> None:
async with self._connect() as db:
await db.execute(
"INSERT INTO issued_credentials "
"(id, learner_id, vc_payload_json, signature_b64, status) "
"VALUES (?, ?, ?, ?, 'active')",
(cred_id, learner_id, payload_json, signature_b64),
)
await db.commit()
async def get_credential(self, cred_id: str) -> dict | None:
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT id, learner_id, vc_payload_json, signature_b64, status, issued_at "
"FROM issued_credentials WHERE id = ?",
(cred_id,),
)
row = await cur.fetchone()
return dict(row) if row else None
async def set_credential_status(self, cred_id: str, status: str) -> None:
async with self._connect() as db:
await db.execute(
"UPDATE issued_credentials SET status = ? WHERE id = ?",
(status, cred_id),
)
await db.commit()
async def get_status_list(self, list_id: str) -> dict | None:
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT id, bitstring, size, updated_at "
"FROM status_lists WHERE id = ?",
(list_id,),
)
row = await cur.fetchone()
return dict(row) if row else None
async def upsert_status_list(
self, list_id: str, bitstring: bytes, size: int
) -> None:
async with self._connect() as db:
await db.execute(
"INSERT INTO status_lists (id, bitstring, size, updated_at) "
"VALUES (?, ?, ?, datetime('now')) "
"ON CONFLICT(id) DO UPDATE SET "
"bitstring = excluded.bitstring, size = excluded.size, "
"updated_at = datetime('now')",
(list_id, bitstring, size),
)
await db.commit()
__all__ = [
"PraxisStore",
"SessionRow",
"TurnRow",
"HARDCODED_LEARNER_ID",
]