Files
praxis/server/assist/session.py
T
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

232 lines
9.2 KiB
Python

"""AssistSession — the shift-bounded assist session model (D-062, D-063, TASK-01-03).
Distinct from the practice SessionRecorder: assist shifts are coaching, not
assessment. D-063 is binding: schedule_mastery=False — assist turns NEVER update
θ or count toward mastery gates. The cohort aggregation hook fires on shift-end
(session_type='assist') but the mastery flow is practice-only.
The shift lifecycle:
start() → create a sessions row (session_type='assist')
log_assist_turn* → write turns with guardrail_verdict_json (D-060 layer 3)
end() → set ended_at + outcome, fire the aggregation hook (no mastery flow)
"""
from __future__ import annotations
import datetime as _dt
import json
import logging
import uuid
from typing import Any
from db.store import PraxisStore, HARDCODED_LEARNER_ID
from server.assist.context import AssistContext
from server.assist.latency_metrics import AssistLatencyMetrics
from server.assist.pii_policy import redact_pii
log = logging.getLogger(__name__)
def _now_iso() -> str:
return _dt.datetime.now(_dt.timezone.utc).isoformat()
class AssistSession:
"""A shift-bounded assist session (D-062, D-063, TASK-01-03)."""
session_type: str = "assist"
def __init__(
self,
store: PraxisStore,
learner_id: str,
context: AssistContext,
pg_store: Any = None,
) -> None:
self.store = store
self.learner_id = learner_id
self.context = context
self.pg_store = pg_store
self.session_id: str | None = None
self.turn_count: int = 0
self.guardrail_block_count: int = 0
self.shift_started_at: _dt.datetime = _dt.datetime.now(_dt.timezone.utc)
# Per-shift latency metrics (TASK-09-01, D-072). The assist pipeline's
# LatencyObserver holds the live records; at shift-end the pipeline code
# calls record() for each completed turn, then summary() flows to the
# session_outcome → cohort aggregation (assist_p95_latency_ms metric).
self.latency_metrics = AssistLatencyMetrics()
# TASK-11-01: per-shift assist cost accumulator (cents). Each turn's
# cost is added via add_assist_turn_cost(); the total flows to the
# session_outcome as assist_cost_cents for the C-3 budget check.
self.assist_cost_cents: int = 0
async def start(self) -> str:
"""Create the assist shift session row. Returns the session id."""
scenario_id = f"assist:{self.context.scenario_tag}"
self.session_id = await self.store.start_session_typed(
self.learner_id, scenario_id, session_type="assist"
)
self.shift_started_at = _dt.datetime.now(_dt.timezone.utc)
log.info(
"assist shift started: id=%s learner=%s week=%d scenario=%s",
self.session_id, self.learner_id, self.context.current_week,
self.context.scenario_tag,
)
return self.session_id
async def log_assist_turn(
self,
asr_text: str,
tts_text: str,
guardrail_verdict: dict | None,
latency_ms: float | None = None,
) -> None:
"""Log one complete assist turn (D-060 layer 3, REQ-IDEATE-09).
PII redaction (REQ-IDEATE-05) is applied to asr_text before storage.
The guardrail_verdict is JSON-serialized into guardrail_verdict_json.
"""
if self.session_id is None:
return
redacted_asr = redact_pii(asr_text)
verdict_json = json.dumps(guardrail_verdict) if guardrail_verdict else None
await self.store.log_turn_with_verdict(
self.session_id,
self.turn_count,
role="assistant",
asr_text=redacted_asr,
tts_text=tts_text,
latency_ms=latency_ms,
guardrail_verdict_json=verdict_json,
)
self.turn_count += 1
if guardrail_verdict and not guardrail_verdict.get("allowed", True):
self.guardrail_block_count += 1
async def log_assist_turn_partial(self, asr_text: str) -> int:
"""Write a partial turn (ASR only) — REQ-IDEATE-09 incremental audit-log.
Returns the turn seq so log_assist_turn_complete() can update the row.
"""
if self.session_id is None:
return self.turn_count
redacted_asr = redact_pii(asr_text)
await self.store.log_turn_with_verdict(
self.session_id,
self.turn_count,
role="assistant",
asr_text=redacted_asr,
tts_text=None,
latency_ms=None,
guardrail_verdict_json=None,
)
seq = self.turn_count
self.turn_count += 1
return seq
async def log_assist_turn_complete(
self,
seq: int,
tts_text: str,
guardrail_verdict: dict,
latency_ms: float | None = None,
) -> None:
"""Update a partial turn row with the LLM response + verdict (REQ-IDEATE-09).
Fetches the turn by (session_id, seq) → updates tts_text + verdict.
"""
if self.session_id is None:
return
verdict_json = json.dumps(guardrail_verdict)
# Find the turn row by session_id + seq, then update by id.
turns = await self.store.get_turns(self.session_id)
turn_id: int | None = None
for t in turns:
if t.seq == seq:
turn_id = t.id
break
if turn_id is None:
log.warning("incremental audit-log: turn seq=%d not found", seq)
return
await self.store.update_turn_verdict(
turn_id, tts_text=tts_text,
guardrail_verdict_json=verdict_json, latency_ms=latency_ms,
)
if not guardrail_verdict.get("allowed", True):
self.guardrail_block_count += 1
def add_assist_turn_cost(self, cost_cents: int) -> None:
"""Accumulate per-turn assist cost (TASK-11-01, REQ-IDEATE-07).
Called by the assist pipeline after each turn's cost is derived via
derive_assist_turn_cost(). The total flows to the session_outcome as
assist_cost_cents (for the C-3 budget check — TASK-11-02).
"""
self.assist_cost_cents += int(cost_cents)
async def end(self, outcome: str = "completed") -> dict[str, Any]:
"""End the shift: update the session row + fire the aggregation hook.
D-063 is binding: run_mastery_flow() is NEVER called (schedule_mastery=False).
The cohort aggregation hook fires (session_type='assist') if pg_store is
available. Returns the session_outcome dict.
"""
if self.session_id is None:
raise RuntimeError("AssistSession.end() called before start()")
await self.store.end_session_assist(
self.session_id, outcome, self.turn_count, self.guardrail_block_count
)
session_outcome = self._build_session_outcome(outcome)
# Fire the cohort aggregation hook (D-054, D-062). Off the voice path,
# fire-and-forget. No-op if pg_store is None. Mastery flow is NOT
# scheduled (D-063 — schedule_mastery=False for assist).
if self.pg_store is not None:
import asyncio
asyncio.create_task(self._run_cohort_aggregation(session_outcome))
log.info(
"assist shift ended: id=%s outcome=%s turns=%d blocks=%d",
self.session_id, outcome, self.turn_count, self.guardrail_block_count,
)
return session_outcome
def _build_session_outcome(self, outcome: str) -> dict[str, Any]:
"""Construct the session_outcome dict for the aggregation hook (D-062)."""
latency_summary = self.latency_metrics.summary()
return {
"learner_ref": self.learner_id,
"path": self.context.path_slug,
"scenario_id": f"assist:{self.context.scenario_tag}",
"outcome": outcome,
"session_type": "assist",
"rubric_scores": [], # assist has no rubric scoring (D-063)
"failure_mode": None,
"branch_path": [],
"assist_turn_count": self.turn_count,
"guardrail_blocks": self.guardrail_block_count,
# D-072 (TASK-09-01): p95 latency flows to the cohort aggregation
# as assist_p95_latency_ms. None if no completed turns.
"assist_p95_latency_ms": latency_summary.get("p95"),
"assist_p50_latency_ms": latency_summary.get("p50"),
"assist_p99_latency_ms": latency_summary.get("p99"),
"assist_within_pilot": latency_summary.get("within_pilot", False),
# TASK-11-01: per-shift assist cost (sum of per-turn costs in cents).
"assist_cost_cents": getattr(self, "assist_cost_cents", 0),
"timestamp": _now_iso(),
}
async def _run_cohort_aggregation(self, session_outcome: dict[str, Any]) -> None:
"""Fire-and-forget wrapper around the cohort aggregation hook (D-054)."""
try:
from server.cohort.hook import on_session_end
await on_session_end(self.pg_store, session_outcome)
except Exception:
log.exception(
"cohort aggregation dispatch failed for assist shift %s",
self.session_id,
)
__all__ = ["AssistSession"]