Files
praxis/db/store.py
T
Praxis CI b77536aa5e docs(P01): complete minimal-voice-loop phase
---ci---
phase: 1
milestone: v0.1
status: complete
requirements:
  covered: [REQ-VOICE-01, REQ-VOICE-02, REQ-VOICE-03, REQ-VOICE-04, REQ-SCEN-01, REQ-STATE-01, REQ-LLM-01, REQ-LLM-02, REQ-DEBRIEF-01, REQ-ORCH-01, REQ-ORCH-02, REQ-SCEN-FMT-01, REQ-NFR-LAT-01, REQ-NFR-SAFE-01, REQ-NFR-COST-01]
  partial: []
---/ci---
2026-08-01 13:28:42 +00:00

187 lines
6.0 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 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",
]