Files
hold-slayer/services/receptionist.py
Robert Helewka f7a11f2f20 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>
2026-07-10 07:42:52 -04:00

466 lines
17 KiB
Python

"""
AI Receptionist — Screens inbound calls, then routes or takes a message.
State machine:
GREET → TTS greeting plays into the call leg
LISTEN → buffer audio from the leg's tap until end-of-utterance
CLASSIFY → LLM extracts intent, urgency, recommended action
DECIDE → combine LLM recommendation with the routing decision
(rules win on conflict)
RING → ring_chain devices; bridge on pickup
RECORD → TTS prompt + WAV record up to message_max_seconds; transcribe
and notify
"""
import asyncio
import logging
import time as _time
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from models.call import ActiveCall, CallStatus
from models.events import EventType, GatewayEvent
from models.routing import RoutingAction, RoutingActionType, RoutingDecision
logger = logging.getLogger(__name__)
def _extract_number(sip_uri: str) -> str:
"""Pull the user part out of a SIP URI (sip:+15551212@host → +15551212)."""
if not sip_uri:
return ""
s = sip_uri.strip()
if s.startswith("<") and ">" in s:
s = s[1 : s.index(">")]
if s.startswith("sip:"):
s = s[4:]
if "@" in s:
s = s.split("@", 1)[0]
return s
class ReceptionistService:
"""Owns inbound-call policy: routing evaluation, screening, voicemail."""
def __init__(
self,
gateway,
tts=None,
transcription=None,
recording=None,
routing=None,
):
self.gateway = gateway
self.settings = gateway.settings.receptionist
self.tts = tts
self.transcription = transcription
self.recording = recording
self.routing = routing
async def on_inbound_call(self, from_uri: str, to_uri: str, leg_id: str) -> None:
"""
Entry point for an inbound INVITE (wired as the SIP engine's
on_incoming_call by the composition root).
Evaluates routing rules, then either rejects (rule says
reject/DND) or answers and runs the screening flow.
"""
from models.call import CallMode
gateway = self.gateway
caller_number = _extract_number(from_uri)
dnis = _extract_number(to_uri)
# Create a call record so the dashboard sees the ringing call.
call = await gateway.call_manager.create_call(
remote_number=caller_number,
mode=CallMode.RECEPTIONIST,
intent=None,
call_flow_id=None,
device=None,
)
call.direction = "inbound"
gateway.call_manager.map_leg(leg_id, call.id)
await gateway.call_manager.update_status(call.id, CallStatus.RINGING)
decision = (
await self.routing.evaluate(caller_number, dnis)
if self.routing is not None
else None
)
if decision is not None:
await gateway.event_bus.publish(GatewayEvent(
type=EventType.ROUTING_RULE_MATCHED,
call_id=call.id,
data={
"matched_rule_id": decision.matched_rule_id,
"matched_rule_name": decision.matched_rule_name,
"action": decision.action.type.value,
"reason": decision.reason,
},
message=decision.reason,
))
if decision.action.type in (RoutingActionType.REJECT, RoutingActionType.DND):
if hasattr(gateway.sip_engine, "reject_inbound"):
await gateway.sip_engine.reject_inbound(leg_id)
await gateway.call_manager.end_call(call.id, CallStatus.COMPLETED)
return
# Answer the leg
if hasattr(gateway.sip_engine, "accept_inbound"):
await gateway.sip_engine.accept_inbound(leg_id)
await gateway.call_manager.update_status(call.id, CallStatus.CONNECTED)
# Screen the caller (unless the receptionist is disabled)
if self.settings.enabled:
gateway.spawn(
self.handle(call, leg_id, decision),
name=f"receptionist_{call.id}",
)
async def handle(
self,
call: ActiveCall,
sip_leg_id: str,
routing_decision: Optional[RoutingDecision] = None,
) -> None:
"""Run the full receptionist flow for an inbound call."""
try:
await self._greet(call, sip_leg_id)
transcript = await self._listen(call, sip_leg_id)
if transcript:
await self.gateway.call_manager.add_transcript(
call.id, transcript, speaker="caller"
)
classification = await self._classify(call, transcript, routing_decision)
call.intent = classification.get("intent")
await self.gateway.event_bus.publish(GatewayEvent(
type=EventType.RECEPTIONIST_CAPTURED_INTENT,
call_id=call.id,
data=classification,
message=f"Intent: {classification.get('intent', '?')}",
))
action = self._decide(routing_decision, classification)
await self.gateway.event_bus.publish(GatewayEvent(
type=EventType.RECEPTIONIST_ROUTING,
call_id=call.id,
data={"action": action.type.value},
message=f"Routing decision: {action.type.value}",
))
if action.type in (RoutingActionType.REJECT, RoutingActionType.DND):
if action.message:
await self._speak(call, sip_leg_id, action.message)
await self._hangup(call, sip_leg_id)
return
if action.type in (RoutingActionType.RING_DEVICE, RoutingActionType.RING_CHAIN):
devices = self._resolve_device_list(action, classification)
if not devices:
logger.info("Receptionist: no devices to ring, falling back to message")
await self._take_message(call, sip_leg_id)
return
await self._speak(
call, sip_leg_id, "One moment, I'll connect you now."
)
if self.routing is None:
await self._take_message(call, sip_leg_id)
return
answered = await self.routing.ring_chain(
call.id, devices, action.ring_timeout
)
if answered:
return # Bridged to a device — receptionist done
# Nobody home — take a message
await self._take_message(call, sip_leg_id)
return
# Default: take a message
await self._take_message(call, sip_leg_id)
except Exception as e:
logger.error(f"Receptionist failed for {call.id}: {e}", exc_info=True)
try:
await self._hangup(call, sip_leg_id)
except Exception:
pass
# ----------------------------------------------------------------
# State machine steps
# ----------------------------------------------------------------
async def _service_error(self, call_id: str, service: str, error: Exception) -> None:
"""Surface a failed dependency as a typed event, not silence."""
logger.error(f"⚠️ {service} failed for {call_id}: {error}")
try:
await self.gateway.event_bus.publish(GatewayEvent(
type=EventType.ERROR,
call_id=call_id,
data={"service": service, "error": str(error)},
message=f"⚠️ {service} failed: {error}",
))
except Exception:
pass
async def _greet(self, call: ActiveCall, sip_leg_id: str) -> None:
await self.gateway.event_bus.publish(GatewayEvent(
type=EventType.RECEPTIONIST_GREETING,
call_id=call.id,
data={"text": self.settings.greeting_template},
message="Playing greeting",
))
await self._speak(call, sip_leg_id, self.settings.greeting_template)
async def _listen(self, call: ActiveCall, sip_leg_id: str) -> str:
"""Buffer audio from the call's tap until silence or timeout."""
await self.gateway.event_bus.publish(GatewayEvent(
type=EventType.RECEPTIONIST_LISTENING,
call_id=call.id,
message="Listening for caller",
))
media = self.gateway.media_pipeline
if media is None:
return ""
tap = media.create_tap(sip_leg_id)
audio = bytearray()
deadline = _time.monotonic() + self.settings.listen_timeout_s
silent_for = 0.0
frame_ms = 20
try:
while _time.monotonic() < deadline:
remaining = max(0.05, deadline - _time.monotonic())
frame = await tap.read_frame(timeout=min(0.5, remaining))
if frame is None:
silent_for += 0.5
else:
audio.extend(frame)
if self._frame_is_silent(frame):
silent_for += frame_ms / 1000.0
else:
silent_for = 0.0
if silent_for >= self.settings.end_of_utterance_silence_s and audio:
break
finally:
tap.close()
if not audio or self.transcription is None:
return ""
try:
return await self.transcription.transcribe(bytes(audio))
except Exception as e:
await self._service_error(call.id, "transcription", e)
return ""
async def _classify(
self,
call: ActiveCall,
transcript: str,
routing_decision: Optional[RoutingDecision],
) -> dict:
"""Ask the LLM to interpret the caller's utterance."""
from services.llm_client import get_llm
llm = get_llm()
if llm is None or not transcript.strip():
return {
"intent": transcript or "unknown",
"urgency": "normal",
"recommended_action": "ring",
"device_hint": None,
}
rules_summary = ""
if routing_decision and routing_decision.matched_rule_name:
rules_summary = (
f"A routing rule already matched: '{routing_decision.matched_rule_name}' "
f"(action: {routing_decision.action.type.value})."
)
try:
return await llm.chat_json(
user_message=(
f"Caller: {call.remote_number}\n"
f"Transcript: {transcript}\n"
f"{rules_summary}\n\n"
"Return JSON with keys: intent (short string), "
"urgency (low|normal|high), "
"recommended_action (ring|message|reject), "
"device_hint (string or null)."
),
system=self.settings.llm_persona,
)
except Exception as e:
await self._service_error(call.id, "llm", e)
return {
"intent": transcript,
"urgency": "normal",
"recommended_action": "ring",
"device_hint": None,
}
def _decide(
self,
routing_decision: Optional[RoutingDecision],
classification: dict,
) -> RoutingAction:
"""Rules win on conflict; otherwise use the LLM's recommendation.
A decision counts as a rule only when one actually matched
(matched_rule_id set) — the no-rule default is take_message and
must stay overridable by the LLM. A matched TAKE_MESSAGE rule
wins like any other rule.
"""
if routing_decision and routing_decision.matched_rule_id:
return routing_decision.action
recommended = (classification.get("recommended_action") or "ring").lower()
if recommended == "reject":
return RoutingAction(type=RoutingActionType.REJECT,
message="Sorry, I can't connect that call right now.")
if recommended == "message":
return RoutingAction(type=RoutingActionType.TAKE_MESSAGE)
return RoutingAction(type=RoutingActionType.RING_CHAIN)
def _resolve_device_list(
self, action: RoutingAction, classification: dict
) -> list[str]:
if action.type == RoutingActionType.RING_DEVICE and action.device_id:
return [action.device_id]
if action.device_ids:
return action.device_ids
# Default chain: every device that can take a call, in priority order
devices = sorted(
(d for d in self.gateway.devices.values() if d.can_receive_call),
key=lambda d: d.priority,
)
return [d.id for d in devices]
async def _take_message(self, call: ActiveCall, sip_leg_id: str) -> None:
await self._speak(call, sip_leg_id, self.settings.message_prompt)
media = self.gateway.media_pipeline
recording_svc = self.recording
if recording_svc is None or media is None:
logger.warning("Receptionist: recording unavailable, ending call")
await self._hangup(call, sip_leg_id)
return
# RecordingService writes a WAV file and the recordings row.
session = await recording_svc.start_recording(
call.id, media_pipeline=media, leg_ids=[sip_leg_id]
)
try:
# Record up to the cap, but stop early once the caller hangs
# up (leg termination ends the call via the leg-state wiring).
deadline = _time.monotonic() + self.settings.message_max_seconds
while _time.monotonic() < deadline:
await asyncio.sleep(1.0)
if self.gateway.call_manager.get_call(call.id) is None:
break
finally:
session = await recording_svc.stop_recording(
call.id, media_pipeline=media
)
message_text = ""
rec_path = session.filepath_mixed if session else None
if rec_path and Path(rec_path).exists() and self.transcription is not None:
try:
audio_bytes = Path(rec_path).read_bytes()
message_text = await self.transcription.transcribe(audio_bytes)
except Exception as e:
await self._service_error(call.id, "transcription", e)
if message_text:
await self.gateway.call_manager.add_transcript(
call.id, message_text, speaker="caller"
)
await self.gateway.event_bus.publish(GatewayEvent(
type=EventType.RECEPTIONIST_MESSAGE_SAVED,
call_id=call.id,
data={
"path": rec_path,
"transcript": message_text,
"caller": call.remote_number,
},
message=f"📥 Message saved from {call.remote_number}",
))
await self._hangup(call, sip_leg_id)
# ----------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------
async def _speak(self, call: ActiveCall, sip_leg_id: str, text: str) -> None:
tts = self.tts
media = self.gateway.media_pipeline
if tts is None or media is None or not text.strip():
return
import os
import tempfile
fd, tmp_path = tempfile.mkstemp(suffix=".wav", prefix=f"recept_{call.id}_")
os.close(fd)
try:
try:
ok = await tts.synthesize_to_file(text, tmp_path)
except Exception as e:
await self._service_error(call.id, "tts", e)
return
if not ok:
return
await media.play_wav(sip_leg_id, tmp_path)
await self.gateway.event_bus.publish(GatewayEvent(
type=EventType.SPEAK_PLAYED,
call_id=call.id,
data={"text": text, "speaker": "receptionist"},
message=f"🗣️ {text[:80]}",
))
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
async def _hangup(self, call: ActiveCall, sip_leg_id: str) -> None:
try:
await self.gateway.sip_engine.hangup(sip_leg_id)
except Exception as e:
logger.warning(f"Receptionist hangup failed: {e}")
await self.gateway.call_manager.end_call(call.id, CallStatus.COMPLETED)
@staticmethod
def _frame_is_silent(frame: bytes, threshold: int = 500) -> bool:
"""Crude RMS-style check on a 16-bit PCM frame (mono, signed LE)."""
if not frame or len(frame) < 2:
return True
# Inline RMS — `audioop` was removed in Python 3.13.
import struct
n = len(frame) // 2
if n == 0:
return True
samples = struct.unpack_from(f"<{n}h", frame)
sq = 0
for s in samples:
sq += s * s
rms = (sq / n) ** 0.5
return rms < threshold