"""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.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) 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 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).""" 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, "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"]