Files
hold-slayer/services/receptionist.py
Robert Helewka 67a00defc3 refactor: composition root in lifespan, break core↔services cycle, shared data layer
The gateway was the composition root, device registry, inbound-call
policy, and call-operations service in one class, with core↔services
circular imports papered over by function-local imports, wiring done
by assigning private attributes, and MCP tools duplicating REST query
logic against their own sessions.

Composition:
- main.py's lifespan now builds every service and wires them by
  constructor/registration. gateway.from_config() is gone; core/ no
  longer imports services/ anywhere — the cycle is dead.
- Inbound-call policy moved to ReceptionistService.on_inbound_call
  (routing evaluation, reject/answer, screening dispatch); wired as
  the engine's on_incoming_call by the lifespan. Receptionist deps
  (tts/transcription/recording/routing) are constructor-injected —
  no more gateway._tts reach-through or importing hold_slayer's
  private _get_llm (now services.llm_client.get_llm, shared).
- Hold-slayer launch goes through a mode-handler registry
  (register_mode_handler); the gateway no longer knows the service's
  type. CallManager takes on_call_ended in its constructor.
- build_sip_engine() is a pure function taking explicit callbacks.
- api/routing.py uses the routing service from app.state via a
  proper dependency instead of gateway._routing.

Shared data layer:
- db.session_scope() is the one session convention (get_db wraps it).
- services/call_persistence.py gains the query/write functions and
  the single StoredCallFlow→CallFlow mapper; api/call_flows.py,
  api/call_history.py, and the six DB-touching MCP tools are thin
  wrappers over them — the two surfaces can't drift.
- legs_for_call() replaces the three private _call_legs scans
  (gateway transfer/hangup, REST dtmf, MCP dtmf).

7 new tests (mode-handler launch, on_call_ended hook, receptionist
inbound answer/reject, call-flow CRUD round-trip and history routes
against real SQLite through the shared layer). aiosqlite added to dev
deps for that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:29:01 -04:00

437 lines
16 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:
call.transcript_chunks.append(f"caller: {transcript}")
await self.gateway.event_bus.publish(GatewayEvent(
type=EventType.TRANSCRIPT_CHUNK,
call_id=call.id,
data={"text": transcript, "speaker": "caller"},
message=f"📝 caller: {transcript[:80]}",
))
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 _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 ""
return await self.transcription.transcribe(bytes(audio))
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:
logger.warning(f"Receptionist LLM classify failed: {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."""
if routing_decision and routing_decision.action.type not in (
RoutingActionType.TAKE_MESSAGE,
):
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:
await asyncio.sleep(self.settings.message_max_seconds)
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:
logger.warning(f"Receptionist transcribe failed: {e}")
if message_text:
call.transcript_chunks.append(f"caller_message: {message_text}")
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:
ok = await tts.synthesize_to_file(text, tmp_path)
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