"""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. No auth — learner_id is the hardcoded 'learner-1' (D-007). """ from __future__ import annotations from typing import Any from db.store import PraxisStore, HARDCODED_LEARNER_ID from server.cost import CostBreakdown, derive_cost 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] = [] 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 if tts_text: self._tts_chars += len(tts_text) self._llm_output_tokens += len(tts_text) // 4 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 async def end( self, outcome: str, tts_provider: str = "cartesia", debrief_text: str | None = None, ) -> CostBreakdown: """End the session: derive cost, write the session row, update progress.""" 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) return breakdown __all__ = ["SessionRecorder"]