"""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 __all__ = [ "PraxisStore", "SessionRow", "TurnRow", "HARDCODED_LEARNER_ID", ]