diff --git a/db/__init__.py b/db/__init__.py index e69de29..0d65b9b 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -0,0 +1,17 @@ +"""Praxis SQLite store package — async access layer (D-007).""" + +from db.store import ( + PraxisStore, + SessionRow, + TurnRow, + HARDCODED_LEARNER_ID, +) +from db.migrate import apply_migrations + +__all__ = [ + "PraxisStore", + "SessionRow", + "TurnRow", + "HARDCODED_LEARNER_ID", + "apply_migrations", +] \ No newline at end of file diff --git a/db/migrate.py b/db/migrate.py new file mode 100644 index 0000000..02edbd3 --- /dev/null +++ b/db/migrate.py @@ -0,0 +1,46 @@ +"""SQLite migration runner — applies db/migrations/*.sql in order.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +_DEFAULT_DB_PATH = Path("praxis.db") +_DEFAULT_MIGRATIONS_DIR = Path(__file__).resolve().parent / "migrations" + + +def apply_migrations( + db_path: Path | str | None = None, + migrations_dir: Path | None = None, +) -> list[str]: + """Apply all pending migrations in order. Returns the list of applied names. + + Uses a `_migrations` tracking table so re-running is idempotent. + """ + db = Path(db_path) if db_path else _DEFAULT_DB_PATH + mdir = migrations_dir or _DEFAULT_MIGRATIONS_DIR + + conn = sqlite3.connect(str(db)) + try: + conn.execute( + "CREATE TABLE IF NOT EXISTS _migrations (id TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')))" + ) + applied: list[str] = [] + for sql_path in sorted(mdir.glob("*.sql")): + mid = sql_path.stem + already = conn.execute( + "SELECT 1 FROM _migrations WHERE id = ?", (mid,) + ).fetchone() + if already: + continue + sql = sql_path.read_text(encoding="utf-8") + conn.executescript(sql) + conn.execute("INSERT INTO _migrations (id) VALUES (?)", (mid,)) + conn.commit() + applied.append(mid) + return applied + finally: + conn.close() + + +__all__ = ["apply_migrations"] \ No newline at end of file diff --git a/db/migrations/0001_init.sql b/db/migrations/0001_init.sql new file mode 100644 index 0000000..f608e52 --- /dev/null +++ b/db/migrations/0001_init.sql @@ -0,0 +1,46 @@ +-- Migration 0001 — initial schema for v0.1 learner state (D-007). +-- Creates learner, sessions, turns, progress tables + the hardcoded learner-1 row. + +-- Schema (also in db/schema.sql for reference; this is the migration source). +CREATE TABLE IF NOT EXISTS learner ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + learner_id TEXT NOT NULL REFERENCES learner(id), + scenario_id TEXT NOT NULL, + started_at TEXT NOT NULL DEFAULT (datetime('now')), + ended_at TEXT, + branch_path_json TEXT, + outcome TEXT, + cost_estimated_cents INTEGER, + debrief_text TEXT, + cost_breakdown_json TEXT +); + +CREATE TABLE IF NOT EXISTS turns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + seq INTEGER NOT NULL, + role TEXT NOT NULL, + asr_text TEXT, + tts_text TEXT, + latency_ms REAL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(session_id, seq) +); + +CREATE TABLE IF NOT EXISTS progress ( + learner_id TEXT NOT NULL REFERENCES learner(id), + scenario_id TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_outcome TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (learner_id, scenario_id) +); + +-- The single hardcoded learner row (D-007 — no auth in v0.1). +INSERT OR IGNORE INTO learner (id, display_name) VALUES ('learner-1', 'Alex'); \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..2ebbde7 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,46 @@ +-- Praxis v0.1 SQLite schema — learner state (D-007). +-- Single hardcoded learner, no auth, no multi-tenant. + +-- The single learner row (D-007). v0.1 has one hardcoded profile. +CREATE TABLE IF NOT EXISTS learner ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Session log: one row per voice session. +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + learner_id TEXT NOT NULL REFERENCES learner(id), + scenario_id TEXT NOT NULL, + started_at TEXT NOT NULL DEFAULT (datetime('now')), + ended_at TEXT, + branch_path_json TEXT, -- JSON array of branch ids taken + outcome TEXT, -- 'success' | 'failure' | NULL + cost_estimated_cents INTEGER, -- derived per-session cost (D-012) + debrief_text TEXT, -- TASK-05-05: the generated debrief + cost_breakdown_json TEXT -- TASK-04-04: token/minute/char breakdown +); + +-- Turn log: one row per ASR/TTS turn within a session. +CREATE TABLE IF NOT EXISTS turns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL REFERENCES sessions(id), + seq INTEGER NOT NULL, + role TEXT NOT NULL, -- 'user' | 'assistant' + asr_text TEXT, + tts_text TEXT, + latency_ms REAL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(session_id, seq) +); + +-- Progress: per-learner per-scenario progression (v0.1: attempts + last outcome). +CREATE TABLE IF NOT EXISTS progress ( + learner_id TEXT NOT NULL REFERENCES learner(id), + scenario_id TEXT NOT NULL, + attempts INTEGER NOT NULL DEFAULT 0, + last_outcome TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (learner_id, scenario_id) +); \ No newline at end of file diff --git a/db/store.py b/db/store.py new file mode 100644 index 0000000..291d6a6 --- /dev/null +++ b/db/store.py @@ -0,0 +1,187 @@ +"""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 uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import aiosqlite + +from db.migrate import apply_migrations + +_DEFAULT_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", +] \ No newline at end of file diff --git a/tests/test_store.py b/tests/test_store.py new file mode 100644 index 0000000..36a4efc --- /dev/null +++ b/tests/test_store.py @@ -0,0 +1,121 @@ +"""Unit tests for the SQLite schema + async store (TASK-04-01, TASK-04-02).""" + +from __future__ import annotations + +import asyncio +import sqlite3 +from pathlib import Path + +import pytest + +from db.migrate import apply_migrations +from db.store import PraxisStore, HARDCODED_LEARNER_ID + + +@pytest.fixture +def tmp_db(tmp_path: Path) -> Path: + return tmp_path / "test_praxis.db" + + +def test_migration_creates_all_tables(tmp_db: Path): + """TASK-04-01: migration creates learner, sessions, turns, progress.""" + applied = apply_migrations(tmp_db) + assert "0001_init" in applied + + conn = sqlite3.connect(str(tmp_db)) + tables = { + r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() + } + conn.close() + assert {"learner", "sessions", "turns", "progress"} <= tables + + +def test_hardcoded_learner_row_exists(tmp_db: Path): + """TASK-04-01: the hardcoded learner-1 'Alex' row exists (D-007, no auth).""" + apply_migrations(tmp_db) + conn = sqlite3.connect(str(tmp_db)) + row = conn.execute( + "SELECT id, display_name FROM learner WHERE id = ?", (HARDCODED_LEARNER_ID,) + ).fetchone() + conn.close() + assert row is not None + assert row[0] == "learner-1" + assert row[1] == "Alex" + + +def test_migrations_are_idempotent(tmp_db: Path): + """Re-running migrations doesn't re-apply.""" + apply_migrations(tmp_db) + applied = apply_migrations(tmp_db) + assert applied == [] + + +def test_store_start_log_end_session(tmp_db: Path): + """TASK-04-02: start session → log 3 turns → end session → query returns full session.""" + store = PraxisStore(tmp_db) + + async def _run(): + await store.init() + sid = await store.start_session(HARDCODED_LEARNER_ID, "cs_refund_ca_v01") + await store.log_turn(sid, 0, "assistant", tts_text="Hi, I want a refund.", latency_ms=None) + await store.log_turn(sid, 1, "user", asr_text="I'm sorry, I can help.", latency_ms=450.0) + await store.log_turn(sid, 2, "assistant", tts_text="Okay, what's the issue?", latency_ms=520.0) + await store.end_session( + sid, + branch_path=["accept_resolution"], + outcome="success", + cost_cents=12, + cost_breakdown={"tokens": 500, "minutes": 1.2, "chars": 320}, + debrief_text="You did well acknowledging the customer.", + ) + sess = await store.get_session(sid) + turns = await store.get_turns(sid) + return sess, turns + + sess, turns = asyncio.run(_run()) + assert sess is not None + assert sess.learner_id == "learner-1" + assert sess.scenario_id == "cs_refund_ca_v01" + assert sess.outcome == "success" + assert sess.branch_path == ["accept_resolution"] + assert sess.cost_estimated_cents == 12 + assert sess.debrief_text == "You did well acknowledging the customer." + assert sess.cost_breakdown["tokens"] == 500 + assert len(turns) == 3 + assert turns[0].role == "assistant" + assert turns[1].asr_text == "I'm sorry, I can help." + assert turns[2].latency_ms == 520.0 + + +def test_store_update_progress(tmp_db: Path): + """TASK-04-02: update_progress increments attempts + sets last_outcome.""" + store = PraxisStore(tmp_db) + + async def _run(): + await store.init() + await store.update_progress(HARDCODED_LEARNER_ID, "cs_refund_ca_v01", "success") + await store.update_progress(HARDCODED_LEARNER_ID, "cs_refund_ca_v01", "failure") + + async with store._connect() as db: + cur = await db.execute( + "SELECT attempts, last_outcome FROM progress WHERE learner_id = ? AND scenario_id = ?", + (HARDCODED_LEARNER_ID, "cs_refund_ca_v01"), + ) + return await cur.fetchone() + + row = asyncio.run(_run()) + assert row is not None + assert row[0] == 2 # two attempts + assert row[1] == "failure" # last outcome + + +def test_store_get_learner(tmp_db: Path): + store = PraxisStore(tmp_db) + + async def _run(): + await store.init() + return await store.get_learner() + + learner = asyncio.run(_run()) + assert learner["id"] == "learner-1" + assert learner["display_name"] == "Alex" \ No newline at end of file