"""Session recorder — wires the SQLite store into the pipeline lifecycle (TASK-04-03). On session start: create a sessions row. Per turn: log a turns row with ASR/TTS text + latency. On branch decision: update branch_path. On session end: set outcome + update progress + store cost + debrief. After end(): the caller may invoke `run_mastery_flow()` to run the off-voice-path mastery scoring pipeline (SLICE-07 TASK-07-01): evidence extraction → rubric scoring → scenario score → IRT theta update → path gate check + week advance → SQLite gate-event audit → optional VC issuance (SLICE-09, lazy import). No auth — learner_id is the hardcoded 'learner-1' (D-007). """ from __future__ import annotations import asyncio import json import logging import uuid from typing import Any, Awaitable, Callable from db.store import PraxisStore, HARDCODED_LEARNER_ID from server.cost import CostBreakdown, derive_cost log = logging.getLogger(__name__) class SessionRecorder: """Records a voice session to SQLite (TASK-04-03).""" def __init__( self, store: PraxisStore, learner_id: str = HARDCODED_LEARNER_ID, scenario_id: str = "cs_refund_ca_v01", ) -> None: self.store = store self.learner_id = learner_id self.scenario_id = scenario_id self.session_id: str | None = None self._turn_seq = 0 # Cost inputs accumulated over the session. self._llm_input_tokens = 0 self._llm_output_tokens = 0 self._deepgram_minutes = 0.0 self._tts_chars = 0 self._debrief_input_tokens = 0 self._debrief_output_tokens = 0 self._branch_path: list[str] = [] # Transcribed turns captured for the post-session mastery flow. # Each entry: {"role": "learner"|"customer"|"assistant", "content": str}. self._mastery_turns: list[dict[str, str]] = [] # Populated by run_mastery_flow(); surfaced to the debrief caller. self.mastery_result: dict[str, Any] | None = None async def start(self) -> str: """Create the session row; return the session id.""" self.session_id = await self.store.start_session(self.learner_id, self.scenario_id) return self.session_id async def log_turn( self, role: str, asr_text: str | None = None, tts_text: str | None = None, latency_ms: float | None = None, ) -> None: """Log one turn to the turns table.""" if self.session_id is None: return await self.store.log_turn( self.session_id, self._turn_seq, role, asr_text, tts_text, latency_ms ) self._turn_seq += 1 # Accumulate cost inputs. if asr_text: # Rough: 1 token ≈ 4 chars. self._llm_input_tokens += len(asr_text) // 4 self._mastery_turns.append({"role": role, "content": asr_text}) if tts_text: self._tts_chars += len(tts_text) self._llm_output_tokens += len(tts_text) // 4 if role == "assistant" and not asr_text: self._mastery_turns.append({"role": role, "content": tts_text}) if latency_ms and role == "assistant": # Rough audio-minutes estimate from latency (placeholder for real metering). pass def add_audio_minutes(self, minutes: float) -> None: self._deepgram_minutes += minutes def add_debrief_tokens(self, input_tokens: int, output_tokens: int) -> None: self._debrief_input_tokens += input_tokens self._debrief_output_tokens += output_tokens def set_branch_path(self, branch_path: list[str]) -> None: self._branch_path = branch_path def set_mastery_turns(self, turns: list[dict[str, str]]) -> None: """Override the captured transcript turns used by run_mastery_flow().""" self._mastery_turns = list(turns) async def end( self, outcome: str, tts_provider: str = "cartesia", debrief_text: str | None = None, schedule_mastery: bool = False, mastery_deps: "MasteryFlowDeps | None" = None, ) -> CostBreakdown: """End the session: derive cost, write the session row, update progress. If `schedule_mastery=True` and `mastery_deps` is provided, the mastery flow is scheduled as a fire-and-forget asyncio task (off the voice path). The task result lands in `self.mastery_result` once it completes. """ if self.session_id is None: raise RuntimeError("SessionRecorder.end() called before start()") breakdown = derive_cost( llm_input_tokens=self._llm_input_tokens, llm_output_tokens=self._llm_output_tokens, deepgram_audio_minutes=self._deepgram_minutes, tts_characters=self._tts_chars, debrief_input_tokens=self._debrief_input_tokens, debrief_output_tokens=self._debrief_output_tokens, tts_provider=tts_provider, ) await self.store.end_session( self.session_id, branch_path=self._branch_path, outcome=outcome, cost_cents=breakdown.derived_cents, cost_breakdown=breakdown.as_dict(), debrief_text=debrief_text, ) await self.store.update_progress(self.learner_id, self.scenario_id, outcome) if schedule_mastery and mastery_deps is not None: asyncio.create_task( self._run_mastery_flow_guarded(mastery_deps) ) return breakdown async def _run_mastery_flow_guarded(self, deps: "MasteryFlowDeps") -> None: try: await self.run_mastery_flow(deps) except Exception: log.exception("mastery flow failed for session %s", self.session_id) async def run_mastery_flow(self, deps: "MasteryFlowDeps") -> dict[str, Any]: """Run the off-voice-path mastery scoring pipeline (SLICE-07 TASK-07-01). Steps: 1. evidence_extractor.extract_evidence(turns, rubric_criteria, llm) 2. if ExtractionResult.scoring_inconclusive → return inconclusive status (no score, no gate event, no progress change). The caller surfaces a retry in the debrief (grill Axis 4 MUST #3). 3. rubric_scorer.score(evidence, rubric) 4. mastery_score.compute_scenario_score(criterion_scores, rubric) 5. irt.update_theta + persist via store.upsert_ability 6. path_engine.check_gate + advance_week + persist via store.upsert_progress 7. record mastery_gate_event in SQLite (audit, REQ-NFR-MAST-02) 8. if week-final gate open → vc_issuer.issue_credential (lazy import; SLICE-09 may not be present yet → ImportError is swallowed) Returns a dict describing the result (status, scenario_score, theta, week, gate_open, ...). Stored on `self.mastery_result`. """ from server.mastery import evidence_extractor as _ev from server.mastery import mastery_score as _ms from server.mastery import rubric_scorer as _rs rubric = deps.load_rubric() scenario = deps.load_scenario() criterion_ids = [m.criterion_id for m in scenario.rubric_criteria] or rubric.criterion_ids() path_slug = scenario.path extraction = await _ev.extract_evidence( self._mastery_turns, criterion_ids, deps.llm ) if extraction.scoring_inconclusive: self.mastery_result = { "status": "scoring_inconclusive", "attempts": extraction.attempts, "rejected_quotes": extraction.rejected_quotes, "retry_advised": True, } return self.mastery_result criterion_scores = _rs.score(extraction.evidence, rubric) scenario_score = _ms.compute_scenario_score(criterion_scores, rubric) progress_row = await self.store.get_progress(self.learner_id, path_slug) if progress_row is not None: progress = dict(progress_row) scenarios_passed: list[str] = list( json.loads(progress.get("scenarios_passed_json") or "[]") ) else: progress = {} scenarios_passed = [] if scenario_score.passed and self.scenario_id not in scenarios_passed: scenarios_passed.append(self.scenario_id) # Recompute the path score over the passing set we know about. path_score = _ms.compute_path_score( [scenario_score] if scenario_score.passed else [] ) # If prior passing scenario scores are tracked elsewhere, they'd be # folded in here; the mastery_progress row stores the cumulative mean. path = deps.load_path() week = deps.path_engine.current_week(progress) if progress else 1 gate_open = deps.path_engine.check_gate( {"distinct_passed": len(scenarios_passed), "mastery_score": path_score}, week, path, ) # IRT theta update (uses scenario difficulty as the item parameter b). ability_row = await self.store.get_ability(self.learner_id, path_slug) if ability_row is not None: theta = float(ability_row["theta"]) sigma_sq = float(ability_row["sigma_sq"]) observations = int(ability_row["observations"]) else: theta = 0.0 sigma_sq = 1.0 observations = 0 outcome = 1.0 if scenario_score.passed else 0.0 b = float(scenario.difficulty) new_theta, new_sigma_sq = deps.irt.update_theta(theta, sigma_sq, outcome, b) new_observations = observations + 1 await self.store.upsert_ability( self.learner_id, path_slug, new_theta, new_sigma_sq, new_observations ) # Advance the week only if the gate is open (D-048). new_progress = progress if gate_open: new_progress = deps.path_engine.advance_week(progress or {"current_week": week}) new_progress["distinct_passed"] = len(scenarios_passed) new_progress["mastery_score"] = path_score else: new_progress = dict(progress or {"current_week": week}) new_progress["distinct_passed"] = len(scenarios_passed) new_progress["mastery_score"] = path_score new_week = int(new_progress.get("current_week", week)) await self.store.upsert_progress( self.learner_id, path_slug, new_week, scenarios_passed, path_score, gate_open, ) # Audit log (REQ-NFR-MAST-02). scoring_inconclusive never reaches here. rubric_scores_json = [cs.model_dump() for cs in criterion_scores] await self.store.record_gate_event( self.learner_id, path_slug, week, scenarios_passed, rubric_scores_json, path_score, gate_open, ) # VC issuance — week-final gate open (grill Axis 8 MUST). SLICE-09 may # not exist yet; the lazy import is wrapped so P1 ships independently. vc_credential_id: str | None = None path_complete = gate_open and new_week >= 6 if path_complete: try: from server.vc.issuer import issue_credential as _issue_credential # type: ignore vc_credential_id = await _issue_credential( store=self.store, learner_id=self.learner_id, path=path_slug, scenarios_passed=scenarios_passed, rubric_score=path_score, completed_weeks=new_week, evidence=rubric_scores_json, ) except ImportError: log.info("vc_issuer not available (SLICE-09 pending); skipping issuance") except Exception: log.exception("vc issuance failed for learner %s", self.learner_id) self.mastery_result = { "status": "scored", "scenario_id": self.scenario_id, "weighted_mean": scenario_score.weighted_mean, "passed": scenario_score.passed, "fail_reason": scenario_score.fail_reason, "theta": new_theta, "sigma_sq": new_sigma_sq, "observations": new_observations, "week": week, "new_week": new_week, "gate_open": gate_open, "path_complete": path_complete, "vc_credential_id": vc_credential_id, "attempts": extraction.attempts, } return self.mastery_result class MasteryFlowDeps: """Dependency bundle for SessionRecorder.run_mastery_flow(). Injected by the caller (DI): keeps session_recorder.py decoupled from the concrete rubric/scenario/path loaders and the LLM provider. """ def __init__( self, llm: Any, irt: Any, path_engine: Any, load_rubric: Callable[[], Any], load_scenario: Callable[[], Any], load_path: Callable[[], Any], ) -> None: self.llm = llm self.irt = irt self.path_engine = path_engine self.load_rubric = load_rubric self.load_scenario = load_scenario self.load_path = load_path __all__ = ["SessionRecorder", "MasteryFlowDeps"]