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>
This commit is contained in:
@@ -27,12 +27,100 @@ from models.routing import RoutingAction, RoutingActionType, RoutingDecision
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReceptionistService:
|
||||
"""Drives the receptionist state machine for a single inbound call."""
|
||||
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
|
||||
|
||||
def __init__(self, gateway):
|
||||
|
||||
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,
|
||||
@@ -89,7 +177,10 @@ class ReceptionistService:
|
||||
await self._speak(
|
||||
call, sip_leg_id, "One moment, I'll connect you now."
|
||||
)
|
||||
answered = await self.gateway._routing.ring_chain(
|
||||
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:
|
||||
@@ -156,10 +247,10 @@ class ReceptionistService:
|
||||
finally:
|
||||
tap.close()
|
||||
|
||||
if not audio:
|
||||
if not audio or self.transcription is None:
|
||||
return ""
|
||||
|
||||
return await self.gateway._transcription.transcribe(bytes(audio))
|
||||
return await self.transcription.transcribe(bytes(audio))
|
||||
|
||||
async def _classify(
|
||||
self,
|
||||
@@ -168,9 +259,9 @@ class ReceptionistService:
|
||||
routing_decision: Optional[RoutingDecision],
|
||||
) -> dict:
|
||||
"""Ask the LLM to interpret the caller's utterance."""
|
||||
from services.hold_slayer import _get_llm
|
||||
from services.llm_client import get_llm
|
||||
|
||||
llm = _get_llm()
|
||||
llm = get_llm()
|
||||
if llm is None or not transcript.strip():
|
||||
return {
|
||||
"intent": transcript or "unknown",
|
||||
@@ -245,7 +336,7 @@ class ReceptionistService:
|
||||
await self._speak(call, sip_leg_id, self.settings.message_prompt)
|
||||
|
||||
media = self.gateway.media_pipeline
|
||||
recording_svc = getattr(self.gateway, "_recording_service", None)
|
||||
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)
|
||||
@@ -264,10 +355,10 @@ class ReceptionistService:
|
||||
|
||||
message_text = ""
|
||||
rec_path = session.filepath_mixed if session else None
|
||||
if rec_path and Path(rec_path).exists():
|
||||
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.gateway._transcription.transcribe(audio_bytes)
|
||||
message_text = await self.transcription.transcribe(audio_bytes)
|
||||
except Exception as e:
|
||||
logger.warning(f"Receptionist transcribe failed: {e}")
|
||||
|
||||
@@ -292,7 +383,7 @@ class ReceptionistService:
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
async def _speak(self, call: ActiveCall, sip_leg_id: str, text: str) -> None:
|
||||
tts = self.gateway._tts
|
||||
tts = self.tts
|
||||
media = self.gateway.media_pipeline
|
||||
if tts is None or media is None or not text.strip():
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user