Files
praxis/server/session_recorder.py
T
Praxis CI b77536aa5e docs(P01): complete minimal-voice-loop phase
---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---
2026-08-01 13:28:42 +00:00

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"]