f04b9b3588
SLICE-01 (lead-developer): multi-stage Dockerfile (node:22-slim→python:3.12-slim), .dockerignore (excludes secrets/node_modules/.git), docker-compose.yml (port 8789, SQLite volume, env injection for all voice-service vars) SLICE-02 (backend-engineer+data-engineer): FastAPI mounts client/dist as StaticFiles at / after API routes (D-023, REQ-DEPLOY-13). G-102 MUST fix: db/store.py + db/migrate.py now read PRAXIS_DB_PATH from env so the Docker volume mount persists SQLite data. G-105 FIX: Dockerfile copies pyproject.toml before source (pip install layer cached, source changes don't invalidate). REQ-DEPLOY-01, 02, 13, 16 covered. ---ci--- project: praxis phase: 1 milestone: v0.2 status: execute slice: 01-02 wave: 1 ---/ci---
190 lines
6.2 KiB
Python
190 lines
6.2 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 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",
|
|
] |