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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user