b77536aa5e
---ci--- phase: 1 milestone: v0.1 status: complete requirements: covered: [REQ-VOICE-01, REQ-VOICE-02, REQ-VOICE-03, REQ-VOICE-04, REQ-SCEN-01, REQ-STATE-01, REQ-LLM-01, REQ-LLM-02, REQ-DEBRIEF-01, REQ-ORCH-01, REQ-ORCH-02, REQ-SCEN-FMT-01, REQ-NFR-LAT-01, REQ-NFR-SAFE-01, REQ-NFR-COST-01] partial: [] ---/ci---
114 lines
3.9 KiB
Python
114 lines
3.9 KiB
Python
"""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"] |