""" 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