""" Call Persistence — the data-access layer for calls and call flows. Holds the on-hangup persistence hook plus the query/write functions that both the REST handlers and the MCP tools call, so the two surfaces can't drift. Every function takes an AsyncSession; callers own the transaction (get_db for REST, session_scope for MCP/services). """ import asyncio import logging import uuid from datetime import datetime from sqlalchemy import desc, select from sqlalchemy.ext.asyncio import AsyncSession from db.database import ( CallRecord, RecordingRecord, StoredCallFlow, TranscriptChunk, session_scope, ) from db.database import Device as DeviceRow from models.call import ActiveCall, CallStatus from models.call_flow import CallFlow, CallFlowStep from models.device import Device logger = logging.getLogger(__name__) def flow_to_model(row: StoredCallFlow) -> CallFlow: """The one StoredCallFlow-row → CallFlow-model mapping.""" return CallFlow( id=row.id, name=row.name, phone_number=row.phone_number, description=row.description or "", steps=[CallFlowStep(**s) for s in (row.steps or [])], tags=row.tags or [], notes=row.notes, avg_hold_time=row.avg_hold_time, success_rate=row.success_rate, last_used=row.last_used, times_used=row.times_used or 0, ) def record_summary(row: CallRecord) -> dict: """The one CallRecord-row → list-item mapping.""" return { "id": row.id, "direction": row.direction, "remote_number": row.remote_number, "status": row.status, "mode": row.mode, "intent": row.intent, "started_at": row.started_at.isoformat() if row.started_at else None, "ended_at": row.ended_at.isoformat() if row.ended_at else None, "duration": row.duration, "hold_time": row.hold_time, "device_used": row.device_used, "summary": row.summary, } def record_detail(row: CallRecord) -> dict: """Full CallRecord-row mapping, superset of record_summary.""" return record_summary(row) | { "action_items": row.action_items, "sentiment": row.sentiment, "call_flow_id": row.call_flow_id, "classification_timeline": row.classification_timeline, } def chunk_to_dict(row: TranscriptChunk) -> dict: """The one TranscriptChunk-row → dict mapping.""" return { "seq": row.seq, "t_offset_ms": row.t_offset_ms, "speaker": row.speaker, "text": row.text, "confidence": row.confidence, } # ================================================================ # Call flows # ================================================================ async def get_flow(session: AsyncSession, flow_id: str) -> StoredCallFlow | None: result = await session.execute( select(StoredCallFlow).where(StoredCallFlow.id == flow_id) ) return result.scalar_one_or_none() async def get_flow_by_number( session: AsyncSession, phone_number: str ) -> StoredCallFlow | None: result = await session.execute( select(StoredCallFlow).where(StoredCallFlow.phone_number == phone_number) ) return result.scalar_one_or_none() async def list_flows(session: AsyncSession) -> list[StoredCallFlow]: result = await session.execute(select(StoredCallFlow)) return list(result.scalars().all()) async def create_flow( session: AsyncSession, flow_id: str, name: str, phone_number: str, steps: list[dict], description: str | None = None, tags: list[str] | None = None, notes: str | None = None, ) -> StoredCallFlow: row = StoredCallFlow( id=flow_id, name=name, phone_number=phone_number, description=description, steps=steps, tags=tags, notes=notes, last_verified=datetime.now(), ) session.add(row) await session.flush() return row async def save_learned_flow(session: AsyncSession, flow: CallFlow) -> StoredCallFlow: """The one CallFlow-model → row mapping (auto-learned flows).""" row = StoredCallFlow( id=flow.id, name=flow.name, phone_number=flow.phone_number, description=flow.description, steps=[s.model_dump(mode="json") for s in flow.steps], tags=flow.tags, notes=flow.notes, times_used=flow.times_used, last_used=flow.last_used, last_verified=datetime.now(), ) session.add(row) await session.flush() return row async def update_flow_from_model( session: AsyncSession, row: StoredCallFlow, flow: CallFlow ) -> None: """Write a refined CallFlow back onto its existing row.""" row.steps = [s.model_dump(mode="json") for s in flow.steps] row.times_used = flow.times_used row.last_used = flow.last_used row.notes = flow.notes await session.flush() # ================================================================ # Devices # ================================================================ async def create_device_row(session: AsyncSession, device: Device) -> None: """The one Device-model → row mapping.""" session.add(DeviceRow( id=device.id, name=device.name, type=device.type.value, sip_uri=device.sip_uri, phone_number=device.phone_number, priority=device.priority, capabilities=device.capabilities, is_online=device.is_online, )) await session.flush() async def update_device_row( session: AsyncSession, device_id: str, values: dict ) -> None: result = await session.execute( select(DeviceRow).where(DeviceRow.id == device_id) ) row = result.scalar_one_or_none() if row is None: return for key, value in values.items(): if key == "type" and value is not None: value = value.value if hasattr(value, "value") else value setattr(row, key, value) async def delete_device_row(session: AsyncSession, device_id: str) -> None: result = await session.execute( select(DeviceRow).where(DeviceRow.id == device_id) ) row = result.scalar_one_or_none() if row is not None: await session.delete(row) # ================================================================ # Call history / records # ================================================================ async def get_record(session: AsyncSession, call_id: str) -> CallRecord | None: result = await session.execute( select(CallRecord).where(CallRecord.id == call_id) ) return result.scalar_one_or_none() async def search_history( session: AsyncSession, number: str | None = None, number_contains: str | None = None, intent_contains: str | None = None, status: str | None = None, since: datetime | None = None, until: datetime | None = None, limit: int = 50, offset: int = 0, ) -> list[CallRecord]: stmt = select(CallRecord).order_by(desc(CallRecord.started_at)) if number: stmt = stmt.where(CallRecord.remote_number == number) if number_contains: stmt = stmt.where(CallRecord.remote_number.contains(number_contains)) if intent_contains: stmt = stmt.where(CallRecord.intent.icontains(intent_contains)) if status: stmt = stmt.where(CallRecord.status == status) if since: stmt = stmt.where(CallRecord.started_at >= since) if until: stmt = stmt.where(CallRecord.started_at <= until) result = await session.execute(stmt.offset(offset).limit(limit)) return list(result.scalars().all()) async def get_transcript_chunks( session: AsyncSession, call_id: str ) -> list[TranscriptChunk]: result = await session.execute( select(TranscriptChunk) .where(TranscriptChunk.call_id == call_id) .order_by(TranscriptChunk.seq) ) return list(result.scalars().all()) async def latest_recording( session: AsyncSession, call_id: str ) -> RecordingRecord | None: result = await session.execute( select(RecordingRecord) .where(RecordingRecord.call_id == call_id) .order_by(desc(RecordingRecord.started_at)) ) return result.scalars().first() async def persist_call_on_create(call: ActiveCall) -> None: """Insert an in_progress CallRecord the moment a call starts. Wired into CallManager as its on_call_created hook — a crash mid-call leaves this row behind instead of erasing the call from history. persist_call_on_end updates it to the terminal state. """ await _with_retry(_insert_in_progress_record, call) async def persist_call_on_end(call: ActiveCall, final_status: CallStatus) -> None: """Finalize the CallRecord and write transcript chunks for `call`. Wired into CallManager as its on_call_ended hook by the composition root in main.py. """ await _with_retry(_finalize_call_record, call, final_status) async def _with_retry(write, call: ActiveCall, *args) -> None: """Losing the row means the call never happened as far as history is concerned, so the final failure logs at ERROR with identifiers.""" for attempt in range(3): try: await write(call, *args) return except Exception as e: if attempt == 2: logger.error( f"Call record lost ({write.__name__}): id={call.id} " f"number={call.remote_number}: {e}" ) return await asyncio.sleep(2**attempt) async def _insert_in_progress_record(call: ActiveCall) -> None: async with session_scope() as session: session.add(CallRecord( id=call.id, direction=call.direction, remote_number=call.remote_number, status="in_progress", mode=call.mode.value, intent=call.intent, started_at=call.started_at, device_used=call.device, call_flow_id=call.call_flow_id, metadata_={"services": list(call.services)}, )) async def _finalize_call_record(call: ActiveCall, final_status: CallStatus) -> None: async with session_scope() as session: record = await get_record(session, call.id) if record is None: # The create-time insert failed (or predates the hook); # write the whole row now instead. record = CallRecord(id=call.id) session.add(record) record.direction = call.direction record.remote_number = call.remote_number record.status = final_status.value record.mode = call.mode.value record.intent = call.intent record.started_at = call.started_at record.ended_at = datetime.now() record.duration = int(call.duration) record.hold_time = int(call.hold_time) record.device_used = call.device record.call_flow_id = call.call_flow_id record.classification_timeline = [ { "timestamp": c.timestamp, "audio_type": c.audio_type.value, "confidence": c.confidence, } for c in call.classification_history ] metadata = {"services": list(call.services)} if call.exploration_steps: metadata["exploration_steps"] = call.exploration_steps record.metadata_ = metadata # Each transcript entry gets its own row with a sequence number # and real offset so the dashboard can render click-to-seek. for seq, entry in enumerate(call.transcript_chunks): session.add(TranscriptChunk( id=f"tc_{uuid.uuid4().hex[:10]}", call_id=call.id, seq=seq, t_offset_ms=entry.t_offset_ms, speaker=entry.speaker, text=entry.text, ))