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---
This commit is contained in:
Praxis CI
2026-08-04 22:35:56 +00:00
parent ba928cf3b4
commit ec397f2c65
65 changed files with 11550 additions and 77 deletions
+15
View File
@@ -0,0 +1,15 @@
-- Migration 0004 — v0.5 Live Assist (D-062, REQ-NFR-ASSIST-04, D-060 layer 3, REQ-IDEATE-09).
-- Additive: existing practice sessions are unaffected (defaults preserve v0.1-v0.4 behavior).
-- session_type: 'practice' (default, existing) | 'assist' (new v0.5).
-- SQLite ALTER TABLE ADD COLUMN with a DEFAULT keeps existing rows as 'practice'.
ALTER TABLE sessions ADD COLUMN session_type TEXT NOT NULL DEFAULT 'practice';
-- guardrail_verdict_json: per-turn guardrail verdict (D-060 layer 3, REQ-IDEATE-09).
-- Nullable — only assist turns populate it; existing practice turns stay NULL.
ALTER TABLE turns ADD COLUMN guardrail_verdict_json TEXT;
-- Index for the mode-conflict check (REQ-IDEATE-03): find active sessions by type.
-- ended_at IS NULL means the session is still active (no end timestamp).
CREATE INDEX IF NOT EXISTS idx_sessions_active_by_type
ON sessions (learner_id, session_type, ended_at);
+27 -6
View File
@@ -221,13 +221,34 @@ class PgStore:
return dict(row) if row else None
async def set_credential_status(self, cred_id: str, status: str) -> None:
extra = ", revoked_at = now()" if status == "revoked" else ""
"""Set a credential's status (TASK-12-03, P1+ #4/#8 from v0.4 REVIEW).
Validates `status` against the allowed enum ('active', 'revoked') +
uses two explicit parameterized queries (no f-string interpolation in
SQL — P1+ #8 code smell fix). 'revoked' sets revoked_at=now(); 'active'
clears revoked_at=NULL (re-activation).
P1+ #4: the status field is now validated (raises ValueError on invalid
status — previously accepted any string).
P1+ #8: the f-string interpolation (`, revoked_at = now()` or empty)
is replaced with two explicit parameterized queries.
"""
if status not in ("active", "revoked"):
raise ValueError(f"Invalid credential status: {status!r}")
async with self.pool.acquire() as conn:
await conn.execute(
f"UPDATE issued_credentials SET status = $1{extra} WHERE id = $2",
status,
cred_id,
)
if status == "revoked":
await conn.execute(
"UPDATE issued_credentials SET status = $1, revoked_at = now() "
"WHERE id = $2",
status, cred_id,
)
else:
# 'active' clears revoked_at (re-activation).
await conn.execute(
"UPDATE issued_credentials SET status = $1, revoked_at = NULL "
"WHERE id = $2",
status, cred_id,
)
async def list_credentials(self, operator_id: str | None = None) -> list[dict]:
async with self.pool.acquire() as conn:
+128 -6
View File
@@ -41,6 +41,7 @@ class SessionRow:
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]:
@@ -65,6 +66,7 @@ class TurnRow:
tts_text: str | None
latency_ms: float | None
created_at: str
guardrail_verdict_json: str | None = None
class PraxisStore:
@@ -81,12 +83,32 @@ class PraxisStore:
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."""
"""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) VALUES (?, ?, ?)",
(session_id, learner_id, scenario_id),
"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
@@ -100,11 +122,91 @@ class PraxisStore:
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) "
"VALUES (?, ?, ?, ?, ?, ?)",
(session_id, seq, role, asr_text, tts_text, latency_ms),
"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()
@@ -174,6 +276,26 @@ class PraxisStore:
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