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:
2026-07-09 20:29:01 -04:00
parent 5880b59872
commit 67a00defc3
17 changed files with 732 additions and 758 deletions

View File

@@ -26,11 +26,11 @@ class CallManager:
The single source of truth for what's happening on the gateway.
"""
def __init__(self, event_bus: EventBus):
def __init__(self, event_bus: EventBus, on_call_ended=None):
self.event_bus = event_bus
self._active_calls: dict[str, ActiveCall] = {}
self._call_legs: dict[str, str] = {} # SIP leg ID -> call ID mapping
self._on_call_ended = None # async callback(call: ActiveCall, final_status)
self._on_call_ended = on_call_ended # async callback(call, final_status)
# ================================================================
# Call Lifecycle

View File

@@ -1,16 +1,18 @@
"""
AI PSTN Gateway — The main orchestrator.
AI PSTN Gateway — call operations and device registry.
Ties together SIP engine, call manager, event bus, and all services.
This is the top-level object that FastAPI and MCP talk to.
The application service that FastAPI and MCP talk to for live-call
work. Composition happens in main.py's lifespan: services are built
there and attached; this module never imports from services/.
"""
import asyncio
import logging
from collections.abc import Callable
from datetime import datetime
from typing import Optional
from config import Settings, get_settings
from config import Settings
from core.call_manager import CallManager
from core.dial_plan import is_emergency_number, next_extension
from core.event_bus import EventBus
@@ -18,28 +20,19 @@ from core.media_pipeline import MediaPipeline
from core.sip_engine import MockSIPEngine, SIPEngine
from core.sippy_engine import SippyEngine
from models.call import ActiveCall, CallMode, CallStatus
from models.call_flow import CallFlow
from models.device import Device, DeviceType
from models.events import EventType, GatewayEvent
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
def _build_sip_engine(settings: Settings, gateway: "AIPSTNGateway") -> SIPEngine:
def build_sip_engine(
settings: Settings,
media_pipeline: MediaPipeline,
on_leg_state_change: Callable,
on_device_registered: Callable,
on_incoming_call: Callable,
) -> SIPEngine:
"""Build the appropriate SIP engine from config."""
trunk = settings.sip_trunk
gw_sip = settings.gateway_sip
@@ -57,10 +50,10 @@ def _build_sip_engine(settings: Settings, gateway: "AIPSTNGateway") -> SIPEngine
trunk_transport=trunk.transport,
domain=gw_sip.domain,
did=trunk.did,
media_pipeline=gateway.media_pipeline,
on_leg_state_change=gateway._on_sip_leg_state,
on_device_registered=gateway._on_sip_device_registered,
on_incoming_call=gateway._on_sip_incoming_call,
media_pipeline=media_pipeline,
on_leg_state_change=on_leg_state_change,
on_device_registered=on_device_registered,
on_incoming_call=on_incoming_call,
)
except Exception as e:
logger.warning(f"Could not create SippyEngine: {e} — using mock")
@@ -72,35 +65,31 @@ class AIPSTNGateway:
"""
The AI PSTN Gateway.
Central coordination point for:
- SIP engine (signaling + media)
- Call manager (state + events)
- Hold Slayer service
- Audio classifier
- Transcription service
- Device management
Owns live-call operations (make/transfer/hangup), the device
registry, and per-call background tasks. Services are attached by
the composition root; mode handlers launch per-call services
(hold slayer) without the gateway knowing their types.
"""
def __init__(
self,
settings: Settings,
sip_engine: Optional[SIPEngine] = None,
on_call_ended=None,
):
self.settings = settings
self.event_bus = EventBus()
self.call_manager = CallManager(self.event_bus)
self.call_manager = CallManager(self.event_bus, on_call_ended=on_call_ended)
self.media_pipeline = MediaPipeline(sample_rate=16000)
self.sip_engine: SIPEngine = sip_engine or MockSIPEngine()
# Services (initialized in start())
self._hold_slayer = None
self._audio_classifier = None
self._transcription = None
# Attached by the composition root (attach_services)
self._tts = None
self._routing = None
self._receptionist = None
# Device registry (loaded from DB on start)
# Per-call-mode launchers registered by the composition root
self._mode_handlers: dict[CallMode, Callable] = {}
# Device registry
self._devices: dict[str, Device] = {}
# Background tasks (per-call services, receptionist sessions) —
@@ -117,23 +106,20 @@ class AIPSTNGateway:
task.add_done_callback(self._tasks.discard)
return task
@classmethod
def from_config(cls, sip_engine: Optional[SIPEngine] = None) -> "AIPSTNGateway":
"""Create gateway from environment config."""
settings = get_settings()
gw = cls(settings=settings)
if sip_engine is not None:
gw.sip_engine = sip_engine
else:
gw.sip_engine = _build_sip_engine(settings, gw)
return gw
def attach_services(self, tts=None) -> None:
"""Attach shared services the gateway must manage on shutdown."""
self._tts = tts
def register_mode_handler(self, mode: CallMode, handler: Callable) -> None:
"""Register a launcher called as handler(call, sip_leg_id, call_flow_id)."""
self._mode_handlers[mode] = handler
# ================================================================
# Lifecycle
# ================================================================
async def start(self) -> None:
"""Boot the gateway — start SIP engine and services."""
"""Boot the gateway — start media pipeline and SIP engine."""
logger.info("🔥 Starting AI PSTN Gateway...")
# Start media pipeline first so SIP engine can hand it RTP streams
@@ -141,26 +127,7 @@ class AIPSTNGateway:
# Start SIP engine
await self.sip_engine.start()
logger.info(f" SIP Engine: ready")
# Import services here to avoid circular imports
from services.audio_classifier import AudioClassifier
from services.transcription import TranscriptionService
from services.tts import TTSService
from services.routing import RoutingService
from services.receptionist import ReceptionistService
self._audio_classifier = AudioClassifier(self.settings.classifier)
self._transcription = TranscriptionService(self.settings.speaches)
self._tts = TTSService(self.settings.tts)
self._routing = RoutingService(self)
await self._routing.start()
self._receptionist = ReceptionistService(self)
# Persist completed calls to the database for history/playback.
from services.call_persistence import persist_call_on_end
self.call_manager._on_call_ended = persist_call_on_end
logger.info(" SIP Engine: ready")
self._started_at = datetime.now()
@@ -283,24 +250,10 @@ class AIPSTNGateway:
await self.call_manager.update_status(call.id, CallStatus.FAILED)
raise
# If hold_slayer mode, launch the Hold Slayer service
if mode == CallMode.HOLD_SLAYER:
from services.hold_slayer import HoldSlayerService
hold_slayer = HoldSlayerService(
gateway=self,
call_manager=self.call_manager,
sip_engine=self.sip_engine,
classifier=self._audio_classifier,
transcription=self._transcription,
settings=self.settings,
tts=self._tts,
)
# Launch as background task — don't block
self.spawn(
hold_slayer.run(call, sip_leg_id, call_flow_id),
name=f"holdslayer_{call.id}",
)
# Hand off to the registered per-mode launcher (e.g. hold slayer)
handler = self._mode_handlers.get(mode)
if handler is not None:
handler(call, sip_leg_id, call_flow_id)
return call
@@ -321,11 +274,14 @@ class AIPSTNGateway:
self.call_manager.map_leg(device_leg_id, call_id)
# Get the original PSTN leg
pstn_leg_id = None
for leg_id, cid in self.call_manager._call_legs.items():
if cid == call_id and leg_id != device_leg_id:
pstn_leg_id = leg_id
break
pstn_leg_id = next(
(
leg_id
for leg_id in self.call_manager.legs_for_call(call_id)
if leg_id != device_leg_id
),
None,
)
if pstn_leg_id:
# Bridge the PSTN leg and device leg
@@ -342,9 +298,8 @@ class AIPSTNGateway:
raise ValueError(f"Call {call_id} not found")
# Hang up all legs associated with this call
for leg_id, cid in list(self.call_manager._call_legs.items()):
if cid == call_id:
await self.sip_engine.hangup(leg_id)
for leg_id in self.call_manager.legs_for_call(call_id):
await self.sip_engine.hangup(leg_id)
await self.call_manager.end_call(call_id)
@@ -470,73 +425,6 @@ class AIPSTNGateway:
},
))
async def _on_sip_incoming_call(
self, from_uri: str, to_uri: str, leg_id: str
) -> None:
"""
Called by SippyEngine when an inbound INVITE arrives.
Evaluates routing rules, then either:
- Rejects (rule says reject/DND)
- Answers + hands off to the AI Receptionist
"""
import uuid as _uuid
from models.call import CallMode, CallStatus
from models.routing import RoutingActionType
caller_number = _extract_number(from_uri)
dnis = _extract_number(to_uri)
# Create a call record so the dashboard sees the ringing call.
call = await self.call_manager.create_call(
remote_number=caller_number,
mode=CallMode.RECEPTIONIST,
intent=None,
call_flow_id=None,
device=None,
)
# Mark inbound
call.direction = "inbound"
self.call_manager.map_leg(leg_id, call.id)
await self.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 self.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(self.sip_engine, "reject_inbound"):
await self.sip_engine.reject_inbound(leg_id)
await self.call_manager.end_call(call.id, CallStatus.COMPLETED)
return
# Answer the leg
if hasattr(self.sip_engine, "accept_inbound"):
await self.sip_engine.accept_inbound(leg_id)
await self.call_manager.update_status(call.id, CallStatus.CONNECTED)
# Hand off to the AI Receptionist
if self._receptionist is not None and self.settings.receptionist.enabled:
self.spawn(
self._receptionist.handle(call, leg_id, decision),
name=f"receptionist_{call.id}",
)
def preferred_device(self) -> Optional[Device]:
"""Get the highest-priority online device."""
online_devices = [