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:
@@ -23,35 +23,12 @@ from models.call import ActiveCall, AudioClassification, CallStatus, Classificat
|
||||
from models.call_flow import ActionType, CallFlow, CallFlowStep
|
||||
from models.events import EventType, GatewayEvent
|
||||
from services.audio_classifier import AudioClassifier
|
||||
from services.llm_client import get_llm
|
||||
from services.transcription import TranscriptionService
|
||||
from services.tts import TTSService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# LLM client is optional — imported at use time
|
||||
_llm_client = None
|
||||
|
||||
|
||||
def _get_llm():
|
||||
"""Lazy-load LLM client (optional dependency)."""
|
||||
global _llm_client
|
||||
if _llm_client is None:
|
||||
try:
|
||||
from config import get_settings
|
||||
from services.llm_client import LLMClient
|
||||
|
||||
settings = get_settings()
|
||||
_llm_client = LLMClient(
|
||||
base_url=settings.llm.base_url,
|
||||
model=settings.llm.model,
|
||||
api_key=settings.llm.api_key.get_secret_value(),
|
||||
timeout=settings.llm.timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"LLM client not available: {e}")
|
||||
_llm_client = False # Sentinel: don't retry
|
||||
return _llm_client if _llm_client is not False else None
|
||||
|
||||
|
||||
class HoldSlayerService:
|
||||
"""
|
||||
@@ -228,7 +205,7 @@ class HoldSlayerService:
|
||||
|
||||
# Phase 2: LLM fallback if regex couldn't decide
|
||||
if not decision and transcript:
|
||||
llm = _get_llm()
|
||||
llm = get_llm()
|
||||
if llm:
|
||||
try:
|
||||
logger.info("🤖 Regex inconclusive, asking LLM...")
|
||||
|
||||
Reference in New Issue
Block a user