"""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", ]