Stage 4: honest health, explicit error policy, event-bus integrity
Engine mode is now explicit: USE_MOCK_SIP=true is the only way to get the mock engine; an unconfigured trunk fails startup with guidance instead of silently degrading. Root-caused why the engine always ran mock: nested pydantic-settings never read .env (no env_file on the sub-settings classes) — all 8 now declare it. /health stops lying: reports engine mode (sippy|mock), a live DB SELECT 1, trunk registration state with reason, and TTS/STT availability from their last real request; "healthy" now requires ready + db + sippy + registered trunk. Error policy: leaf services (tts/transcription/llm_client) raise and track availability; call-loop callers catch, publish EventType.ERROR naming the failed service, and apply an explicit fallback. Persistence writes get one bounded 3x exponential retry, then an ERROR log — no more silent data loss. Event bus: a full subscriber queue drops its oldest event (counted) instead of silently evicting the subscription; subscribe(replay_last=N) delivers the advertised history replay, used by /ws/events (25). Receptionist correctness: a matched TAKE_MESSAGE rule beats the LLM; voicemail polls for early hangup and stops/transcribes/hangs up in finally; RecordingSession finally keeps its leg_ids so taps detach. Dead code removed: models/contact.py + Contact table, dtmf_buffer, transcribe_stream stub, SMS stub in notification.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ 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
|
||||
@@ -159,53 +160,67 @@ async def persist_call_on_end(call: ActiveCall, final_status: CallStatus) -> Non
|
||||
"""Insert a CallRecord and any transcript chunks for `call`.
|
||||
|
||||
Wired into CallManager as its on_call_ended hook by the
|
||||
composition root in main.py.
|
||||
composition root in main.py. Retries briefly — losing the row
|
||||
means the call never happened as far as history is concerned, so
|
||||
the final failure logs at ERROR with the payload identifiers.
|
||||
"""
|
||||
try:
|
||||
async with session_scope() as session:
|
||||
record = CallRecord(
|
||||
id=call.id,
|
||||
direction=call.direction,
|
||||
remote_number=call.remote_number,
|
||||
status=final_status.value,
|
||||
mode=call.mode.value,
|
||||
intent=call.intent,
|
||||
started_at=call.started_at,
|
||||
ended_at=datetime.now(),
|
||||
duration=int(call.duration),
|
||||
hold_time=int(call.hold_time),
|
||||
device_used=call.device,
|
||||
call_flow_id=call.call_flow_id,
|
||||
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)},
|
||||
)
|
||||
session.add(record)
|
||||
for attempt in range(3):
|
||||
try:
|
||||
await _write_call_record(call, final_status)
|
||||
return
|
||||
except Exception as e:
|
||||
if attempt == 2:
|
||||
logger.error(
|
||||
f"Call record lost: id={call.id} number={call.remote_number} "
|
||||
f"status={final_status.value}: {e}"
|
||||
)
|
||||
return
|
||||
await asyncio.sleep(2**attempt)
|
||||
|
||||
# Each transcript chunk gets its own row with a sequence number
|
||||
# so the dashboard can render them in order with click-to-seek.
|
||||
for seq, text in enumerate(call.transcript_chunks):
|
||||
speaker = "unknown"
|
||||
payload = text
|
||||
if ":" in text:
|
||||
head, rest = text.split(":", 1)
|
||||
head = head.strip().lower()
|
||||
if head in {"caller", "agent", "receptionist", "caller_message"}:
|
||||
speaker = head if head != "caller_message" else "caller"
|
||||
payload = rest.strip()
|
||||
session.add(TranscriptChunk(
|
||||
id=f"tc_{uuid.uuid4().hex[:10]}",
|
||||
call_id=call.id,
|
||||
seq=seq,
|
||||
t_offset_ms=0,
|
||||
speaker=speaker,
|
||||
text=payload,
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not persist call {call.id}: {e}")
|
||||
|
||||
async def _write_call_record(call: ActiveCall, final_status: CallStatus) -> None:
|
||||
async with session_scope() as session:
|
||||
record = CallRecord(
|
||||
id=call.id,
|
||||
direction=call.direction,
|
||||
remote_number=call.remote_number,
|
||||
status=final_status.value,
|
||||
mode=call.mode.value,
|
||||
intent=call.intent,
|
||||
started_at=call.started_at,
|
||||
ended_at=datetime.now(),
|
||||
duration=int(call.duration),
|
||||
hold_time=int(call.hold_time),
|
||||
device_used=call.device,
|
||||
call_flow_id=call.call_flow_id,
|
||||
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)},
|
||||
)
|
||||
session.add(record)
|
||||
|
||||
# Each transcript chunk gets its own row with a sequence number
|
||||
# so the dashboard can render them in order with click-to-seek.
|
||||
for seq, text in enumerate(call.transcript_chunks):
|
||||
speaker = "unknown"
|
||||
payload = text
|
||||
if ":" in text:
|
||||
head, rest = text.split(":", 1)
|
||||
head = head.strip().lower()
|
||||
if head in {"caller", "agent", "receptionist", "caller_message"}:
|
||||
speaker = head if head != "caller_message" else "caller"
|
||||
payload = rest.strip()
|
||||
session.add(TranscriptChunk(
|
||||
id=f"tc_{uuid.uuid4().hex[:10]}",
|
||||
call_id=call.id,
|
||||
seq=seq,
|
||||
t_offset_ms=0,
|
||||
speaker=speaker,
|
||||
text=payload,
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user