Files
Praxis CI ec397f2c65 docs(milestone): complete v0.5-live-assist — v0.1.13 tagged, milestone release, merged to main
v0.5 (Live Assist — on-the-job voice companion) milestone complete.
4 phases: P0 (pre-execution, v0.1.10) → P1 (assist core + guardrail,
v0.1.11) → P2 (integration + tech-debt + NFR, v0.1.12) → P3 (final
review + ship, v0.1.13 = milestone release).

16/16 REQs covered (3 ASSIST + 4 NFR + 9 IDEATE). 4 v0.6 backlog.
469 tests passed, 0 failed. 1 P0 fixed (guardrail processor safety).
8 P1+ flagged for v0.6. 8 v0.4 P1+ tech-debt addressed.
G-049 + G-067 grill MUSTs resolved. ESCALATION-01 (PIPEDA) OPEN for
human legal review before assist surface go-live.

---ci---
project: praxis
phase: 3
milestone: v0.5
status: complete
requirements:
  covered: [REQ-ASSIST-01, REQ-ASSIST-02, REQ-ASSIST-03, REQ-NFR-ASSIST-01, REQ-NFR-ASSIST-02, REQ-NFR-ASSIST-03, REQ-NFR-ASSIST-04, REQ-IDEATE-01, REQ-IDEATE-02, REQ-IDEATE-03, REQ-IDEATE-04, REQ-IDEATE-05, REQ-IDEATE-06, REQ-IDEATE-07, REQ-IDEATE-08, REQ-IDEATE-09]
  partial: []
---/ci---
2026-08-04 22:35:56 +00:00

544 lines
20 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
session_type: str = "practice"
@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
guardrail_verdict_json: str | None = None
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.
Backward-compat wrapper: existing practice callers get
session_type='practice' (the column default). v0.5 assist shifts
call start_session_typed(..., session_type='assist').
"""
return await self.start_session_typed(
learner_id, scenario_id, session_type="practice"
)
async def start_session_typed(
self,
learner_id: str,
scenario_id: str,
session_type: str = "practice",
) -> str:
"""Create a session row with an explicit session_type (TASK-01-04, D-062).
session_type: 'practice' (default, existing) | 'assist' (new v0.5).
"""
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, session_type) "
"VALUES (?, ?, ?, ?)",
(session_id, learner_id, scenario_id, session_type),
)
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:
"""Backward-compat wrapper: practice turns have no guardrail verdict."""
await self.log_turn_with_verdict(
session_id, seq, role, asr_text, tts_text, latency_ms,
guardrail_verdict_json=None,
)
async def log_turn_with_verdict(
self,
session_id: str,
seq: int,
role: str,
asr_text: str | None = None,
tts_text: str | None = None,
latency_ms: float | None = None,
guardrail_verdict_json: str | None = None,
) -> None:
"""Log one turn with an optional guardrail verdict (TASK-01-04, D-060 layer 3)."""
async with self._connect() as db:
await db.execute(
"INSERT INTO turns "
"(session_id, seq, role, asr_text, tts_text, latency_ms, guardrail_verdict_json) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(session_id, seq, role, asr_text, tts_text, latency_ms, guardrail_verdict_json),
)
await db.commit()
async def update_turn_verdict(
self,
turn_id: int,
tts_text: str | None,
guardrail_verdict_json: str | None,
latency_ms: float | None = None,
) -> None:
"""Update a partial turn row with the LLM response + verdict (REQ-IDEATE-09).
Used by the incremental audit-log write: a partial turn (ASR only) is
written first, then this updates it with the TTS text + verdict before
TTS playback completes (abrupt termination still leaves an audit trail).
"""
async with self._connect() as db:
await db.execute(
"UPDATE turns SET tts_text = ?, guardrail_verdict_json = ?, "
"latency_ms = COALESCE(?, latency_ms) WHERE id = ?",
(tts_text, guardrail_verdict_json, latency_ms, turn_id),
)
await db.commit()
async def get_active_session(
self, learner_id: str, session_type: str
) -> dict | None:
"""Find an active (not ended) session for the learner of the given type.
Mode-conflict check (TASK-01-05, REQ-IDEATE-03): used to enforce assist
vs practice mutual exclusivity. Uses idx_sessions_active_by_type.
Returns the session row (as dict) or None.
"""
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT id, learner_id, scenario_id, started_at, ended_at, "
"outcome, session_type FROM sessions "
"WHERE learner_id = ? AND session_type = ? AND ended_at IS NULL "
"ORDER BY started_at DESC LIMIT 1",
(learner_id, session_type),
)
row = await cur.fetchone()
return dict(row) if row else None
async def end_session_assist(
self,
session_id: str,
outcome: str,
turn_count: int,
guardrail_block_count: int,
) -> None:
"""End an assist shift: set ended_at + outcome (TASK-01-04, D-062).
outcome: 'completed' | 'abandoned' | 'auto_ended' (D-069).
The existing end_session() is unchanged for practice sessions.
"""
async with self._connect() as db:
await db.execute(
"UPDATE sessions SET ended_at = datetime('now'), outcome = ? "
"WHERE id = ?",
(outcome, session_id),
)
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_turn_by_id(self, turn_id: int) -> TurnRow | None:
"""Fetch a single turn by id (used by the incremental audit-log update)."""
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute("SELECT * FROM turns WHERE id = ?", (turn_id,))
row = await cur.fetchone()
return TurnRow(**dict(row)) if row else None
async def list_active_assist_sessions(self) -> list[dict]:
"""List all active (not ended) assist sessions (for the 8h auto-end monitor)."""
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT id, learner_id, scenario_id, started_at, session_type "
"FROM sessions WHERE session_type = 'assist' AND ended_at IS NULL "
"ORDER BY started_at"
)
rows = await cur.fetchall()
return [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
async def get_ability(self, learner_id: str, path: str) -> dict | None:
"""Return the learner_ability row for (learner_id, path) or None."""
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT learner_id, path, theta, sigma_sq, observations, updated_at "
"FROM learner_ability WHERE learner_id = ? AND path = ?",
(learner_id, path),
)
row = await cur.fetchone()
return dict(row) if row else None
async def upsert_ability(
self,
learner_id: str,
path: str,
theta: float,
sigma_sq: float,
observations: int,
) -> None:
"""Insert or update the learner_ability row for (learner_id, path)."""
async with self._connect() as db:
await db.execute(
"INSERT INTO learner_ability (learner_id, path, theta, sigma_sq, observations, updated_at) "
"VALUES (?, ?, ?, ?, ?, datetime('now')) "
"ON CONFLICT(learner_id, path) DO UPDATE SET "
"theta = excluded.theta, sigma_sq = excluded.sigma_sq, "
"observations = excluded.observations, updated_at = datetime('now')",
(learner_id, path, theta, sigma_sq, observations),
)
await db.commit()
async def get_progress(self, learner_id: str, path: str) -> dict | None:
"""Return the mastery_progress row for (learner_id, path) or None."""
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT learner_id, path, current_week, scenarios_passed_json, "
"mastery_score, gate_open, updated_at "
"FROM mastery_progress WHERE learner_id = ? AND path = ?",
(learner_id, path),
)
row = await cur.fetchone()
return dict(row) if row else None
async def upsert_progress(
self,
learner_id: str,
path: str,
current_week: int,
scenarios_passed: list[str],
mastery_score: float,
gate_open: bool,
) -> None:
"""Insert or update the mastery_progress row for (learner_id, path)."""
gate_int = 1 if gate_open else 0
async with self._connect() as db:
await db.execute(
"INSERT INTO mastery_progress "
"(learner_id, path, current_week, scenarios_passed_json, mastery_score, gate_open, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, datetime('now')) "
"ON CONFLICT(learner_id, path) DO UPDATE SET "
"current_week = excluded.current_week, "
"scenarios_passed_json = excluded.scenarios_passed_json, "
"mastery_score = excluded.mastery_score, gate_open = excluded.gate_open, "
"updated_at = datetime('now')",
(
learner_id,
path,
current_week,
json.dumps(scenarios_passed),
mastery_score,
gate_int,
),
)
await db.commit()
async def record_gate_event(
self,
learner_id: str,
path: str,
week: int,
scenarios_passed: list[str],
rubric_scores: list[dict],
mastery_score: float,
gate_open: bool,
) -> str:
"""Append a row to the mastery_gate_events audit log; return the event id."""
event_id = f"gate-{uuid.uuid4().hex[:12]}"
gate_int = 1 if gate_open else 0
async with self._connect() as db:
await db.execute(
"INSERT INTO mastery_gate_events "
"(id, learner_id, path, week, scenarios_passed_json, rubric_scores_json, "
"mastery_score, gate_open, recorded_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))",
(
event_id,
learner_id,
path,
week,
json.dumps(scenarios_passed),
json.dumps(rubric_scores),
mastery_score,
gate_int,
),
)
await db.commit()
return event_id
async def list_gate_events(
self, learner_id: str, path: str | None = None
) -> list[dict]:
"""Query mastery_gate_events by learner (optionally by path), oldest first."""
async with self._connect() as db:
db.row_factory = aiosqlite.Row
if path is None:
cur = await db.execute(
"SELECT * FROM mastery_gate_events WHERE learner_id = ? "
"ORDER BY recorded_at, id",
(learner_id,),
)
else:
cur = await db.execute(
"SELECT * FROM mastery_gate_events WHERE learner_id = ? AND path = ? "
"ORDER BY recorded_at, id",
(learner_id, path),
)
rows = await cur.fetchall()
return [dict(r) for r in rows]
async def init_issuer_key(
self, key_id: str, public_key: str, private_key_enc: bytes
) -> None:
async with self._connect() as db:
await db.execute(
"INSERT INTO issuer_keys (id, public_key, private_key_enc, status) "
"VALUES (?, ?, ?, 'active')",
(key_id, public_key, private_key_enc),
)
await db.commit()
async def get_active_signing_key_row(self) -> dict | None:
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT id, public_key, private_key_enc, status, created_at "
"FROM issuer_keys WHERE status = 'active' ORDER BY created_at DESC LIMIT 1"
)
row = await cur.fetchone()
return dict(row) if row else None
async def get_public_key_row(self, key_id: str) -> dict | None:
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT id, public_key, private_key_enc, status, created_at "
"FROM issuer_keys WHERE id = ?",
(key_id,),
)
row = await cur.fetchone()
return dict(row) if row else None
async def set_issuer_key_superseded(self, key_id: str) -> None:
async with self._connect() as db:
await db.execute(
"UPDATE issuer_keys SET status = 'superseded' WHERE id = ?",
(key_id,),
)
await db.commit()
async def insert_credential(
self,
cred_id: str,
learner_id: str,
payload_json: str,
signature_b64: str,
) -> None:
async with self._connect() as db:
await db.execute(
"INSERT INTO issued_credentials "
"(id, learner_id, vc_payload_json, signature_b64, status) "
"VALUES (?, ?, ?, ?, 'active')",
(cred_id, learner_id, payload_json, signature_b64),
)
await db.commit()
async def get_credential(self, cred_id: str) -> dict | None:
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT id, learner_id, vc_payload_json, signature_b64, status, issued_at "
"FROM issued_credentials WHERE id = ?",
(cred_id,),
)
row = await cur.fetchone()
return dict(row) if row else None
async def set_credential_status(self, cred_id: str, status: str) -> None:
async with self._connect() as db:
await db.execute(
"UPDATE issued_credentials SET status = ? WHERE id = ?",
(status, cred_id),
)
await db.commit()
async def get_status_list(self, list_id: str) -> dict | None:
async with self._connect() as db:
db.row_factory = aiosqlite.Row
cur = await db.execute(
"SELECT id, bitstring, size, updated_at "
"FROM status_lists WHERE id = ?",
(list_id,),
)
row = await cur.fetchone()
return dict(row) if row else None
async def upsert_status_list(
self, list_id: str, bitstring: bytes, size: int
) -> None:
async with self._connect() as db:
await db.execute(
"INSERT INTO status_lists (id, bitstring, size, updated_at) "
"VALUES (?, ?, ?, datetime('now')) "
"ON CONFLICT(id) DO UPDATE SET "
"bitstring = excluded.bitstring, size = excluded.size, "
"updated_at = datetime('now')",
(list_id, bitstring, size),
)
await db.commit()
__all__ = [
"PraxisStore",
"SessionRow",
"TurnRow",
"HARDCODED_LEARNER_ID",
]