feat(P01-04-03,P01-04-04): wire store into pipeline + per-session cost logging
server/cost.py — derive_cost() counts LLM input/output tokens (gemma4 + deepseek-v4-flash), Deepgram audio minutes, Cartesia/Piper characters; derives estimated cents from scenarios/cost_rates.yaml. No enforced ceiling (D-012 pilot). CostBreakdown dataclass carries the breakdown dict stored in sessions.cost_breakdown_json. Per G-005: v0.1 logged costs are pilot-config (Ollama tier + cloud), NOT at-scale /learner economics — that requires self-hosted gemma4:e4b + Piper (post-pilot). server/session_recorder.py — SessionRecorder wires the SQLite store into the pipeline lifecycle: start() creates a session row, log_turn() writes turns with ASR/TTS text + latency + accumulates cost inputs, set_branch_path(), end() derives cost + writes outcome + debrief + updates progress. No auth — learner_id is the hardcoded learner-1 (D-007). 7 tests pass (derive_cost basic/piper-zero/breakdown-dict, load_rates yaml, no-enforced-ceiling, recorder full lifecycle with DB assertions, progress updated). ---ci--- phase: 1 milestone: v0.1 plan: 04 task: 04-03,04-04 status: execute persona: backend-engineer,data-engineer requirements: covered: [REQ-STATE-01, REQ-NFR-COST-01] ---/ci---
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user