Stage 5: Alembic migrations, durable call rows, one transcript truth
Alembic replaces create_all as the schema authority: async env.py against Base.metadata (CLI and in-app entry paths share it via config.attributes["connection"]), an autogenerated baseline of the create_all-era schema, and init_db now runs upgrade head — stamping the baseline first on a pre-Alembic database so existing deployments adopt cleanly. create_all remains for tests only. Calls are durable from the start: CallManager gains an on_call_created hook (wired to persist_call_on_create) that inserts an in_progress CallRecord the moment a call is created; persist_call_on_end finalizes that same row. A SIGKILL mid-call now leaves an in_progress row instead of erasing the call from history (verified live against the dev database). One transcript representation: ActiveCall.transcript_chunks holds TranscriptEntry (t_offset_ms, speaker, text) — add_transcript stamps real offsets from connect time, receptionist passes speaker instead of encoding it into "caller: ..." strings, persisted chunks carry real seek offsets, and the dead CallRecord.transcript Text column is dropped by migration. Device.is_online migrates String → Boolean (with a USING cast for existing rows). Model de-triplication: CallResponse/CallStatusResponse build via from_call classmethods (one ActiveCall→response mapping); DeviceStatus deleted — can_receive_call is a computed field on Device and the list endpoint returns the domain model; all row↔dict and row↔domain mapping now lives in call_persistence.py (record_summary/record_detail/chunk_to_dict + device row functions). New tests/test_data_layer.py: upgrade-head-matches-models, pre-Alembic adoption, durable in_progress rows, end-without-create fallback, transcript offsets, consolidated response models. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,15 +5,19 @@ Central nervous system of the gateway. Tracks all active calls,
|
||||
publishes events, and coordinates between SIP engine and services.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from core.event_bus import EventBus, EventSubscription
|
||||
from models.call import ActiveCall, AudioClassification, CallMode, CallStatus, ClassificationResult
|
||||
from core.event_bus import EventBus
|
||||
from models.call import (
|
||||
ActiveCall,
|
||||
CallMode,
|
||||
CallStatus,
|
||||
ClassificationResult,
|
||||
TranscriptEntry,
|
||||
)
|
||||
from models.events import EventType, GatewayEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,10 +30,11 @@ class CallManager:
|
||||
The single source of truth for what's happening on the gateway.
|
||||
"""
|
||||
|
||||
def __init__(self, event_bus: EventBus, on_call_ended=None):
|
||||
def __init__(self, event_bus: EventBus, on_call_created=None, on_call_ended=None):
|
||||
self.event_bus = event_bus
|
||||
self._active_calls: dict[str, ActiveCall] = {}
|
||||
self._call_legs: dict[str, str] = {} # SIP leg ID -> call ID mapping
|
||||
self._on_call_created = on_call_created # async callback(call)
|
||||
self._on_call_ended = on_call_ended # async callback(call, final_status)
|
||||
|
||||
# ================================================================
|
||||
@@ -67,6 +72,14 @@ class CallManager:
|
||||
message=f"📞 Calling {remote_number} ({mode.value})",
|
||||
))
|
||||
|
||||
# Durable in_progress row — a crash mid-call must not erase the
|
||||
# call from history. The hook does its own retrying/logging.
|
||||
if self._on_call_created is not None:
|
||||
try:
|
||||
await self._on_call_created(call)
|
||||
except Exception as e:
|
||||
logger.warning(f"on_call_created hook failed for {call_id}: {e}")
|
||||
|
||||
return call
|
||||
|
||||
async def update_status(self, call_id: str, status: CallStatus) -> None:
|
||||
@@ -135,18 +148,26 @@ class CallManager:
|
||||
message=f"🎵 Audio: {result.audio_type.value} ({result.confidence:.0%})",
|
||||
))
|
||||
|
||||
async def add_transcript(self, call_id: str, text: str) -> None:
|
||||
"""Add a transcript chunk to a call."""
|
||||
async def add_transcript(
|
||||
self, call_id: str, text: str, speaker: str = "unknown"
|
||||
) -> None:
|
||||
"""Add a transcript entry to a call, stamped with its offset."""
|
||||
call = self._active_calls.get(call_id)
|
||||
if not call:
|
||||
return
|
||||
|
||||
call.transcript_chunks.append(text)
|
||||
anchor = call.connected_at or call.started_at
|
||||
entry = TranscriptEntry(
|
||||
t_offset_ms=int((datetime.now() - anchor).total_seconds() * 1000),
|
||||
speaker=speaker,
|
||||
text=text,
|
||||
)
|
||||
call.transcript_chunks.append(entry)
|
||||
|
||||
await self.event_bus.publish(GatewayEvent(
|
||||
type=EventType.TRANSCRIPT_CHUNK,
|
||||
call_id=call_id,
|
||||
data={"text": text},
|
||||
data={"text": text, "speaker": speaker, "t_offset_ms": entry.t_offset_ms},
|
||||
message=f"📝 '{text[:80]}...' " if len(text) > 80 else f"📝 '{text}'",
|
||||
))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user