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:
214
core/gateway.py
214
core/gateway.py
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user