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---
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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');
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Migration 0002 — add debrief_text column to sessions (TASK-05-05).
|
||||
-- The debrief_text column was already included in 0001_init.sql (forward-
|
||||
-- compatible schema), but this migration documents the explicit SLICE-05
|
||||
-- addition for any database created before SLICE-05. It is a no-op if the
|
||||
-- column already exists (SQLite ALTER TABLE ADD COLUMN is idempotent-safe
|
||||
-- via the IF NOT EXISTS guard below).
|
||||
|
||||
-- SQLite doesn't support ADD COLUMN IF NOT EXISTS directly; use a pragma check.
|
||||
-- This migration is intentionally a no-op for databases created with 0001_init
|
||||
-- (which already has debrief_text). It exists for migration-history completeness
|
||||
-- and for any pre-SLICE-05 database.
|
||||
|
||||
-- No SQL needed — 0001_init.sql already includes:
|
||||
-- debrief_text TEXT
|
||||
-- in the sessions table. This migration is a marker only.
|
||||
SELECT 1;
|
||||
@@ -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)
|
||||
);
|
||||
+187
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user