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>
466 lines
17 KiB
Python
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
|