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