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

@@ -184,18 +184,12 @@ def create_mcp_server(
Returns the IVR navigation tree if one exists.
"""
from db.database import StoredCallFlow, get_session_factory
from sqlalchemy import select
from db.database import session_scope
from services import call_persistence as store
try:
factory = get_session_factory()
async with factory() as session:
result = await session.execute(
select(StoredCallFlow).where(
StoredCallFlow.phone_number == phone_number
)
)
row = result.scalar_one_or_none()
async with session_scope() as session:
row = await store.get_flow_by_number(session, phone_number)
if not row:
return f"No stored call flow for {phone_number}."
@@ -243,25 +237,26 @@ def create_mcp_server(
"""
from slugify import slugify as do_slugify
from db.database import StoredCallFlow, get_session_factory
from db.database import session_scope
from services import call_persistence as store
try:
steps = json.loads(steps_json)
flow_id = do_slugify(name)
factory = get_session_factory()
async with factory() as session:
db_flow = StoredCallFlow(
id=flow_id,
async with session_scope() as session:
if await store.get_flow(session, flow_id):
return f"Call flow '{flow_id}' already exists."
await store.create_flow(
session,
flow_id=flow_id,
name=name,
phone_number=phone_number,
description="Created by AI assistant",
steps=steps,
notes=notes or None,
tags=["ai-created"],
notes=notes or None,
)
session.add(db_flow)
await session.commit()
return f"Call flow '{name}' saved for {phone_number} (ID: {flow_id})"
except json.JSONDecodeError:
@@ -283,12 +278,12 @@ def create_mcp_server(
if not call:
return f"Call {call_id} not found."
for leg_id, cid in gateway.call_manager._call_legs.items():
if cid == call_id:
await gateway.sip_engine.send_dtmf(leg_id, digits)
return f"Sent DTMF '{digits}' on call {call_id}."
legs = gateway.call_manager.legs_for_call(call_id)
if not legs:
return f"No active SIP leg found for call {call_id}."
return f"No active SIP leg found for call {call_id}."
await gateway.sip_engine.send_dtmf(legs[0], digits)
return f"Sent DTMF '{digits}' on call {call_id}."
@mcp.tool()
async def get_call_transcript(call_id: str) -> str:
@@ -318,16 +313,12 @@ def create_mcp_server(
Returns the recording file path and status.
"""
from db.database import CallRecord, get_session_factory
from sqlalchemy import select
from db.database import session_scope
from services import call_persistence as store
try:
factory = get_session_factory()
async with factory() as session:
result = await session.execute(
select(CallRecord).where(CallRecord.id == call_id)
)
record = result.scalar_one_or_none()
async with session_scope() as session:
record = await store.get_record(session, call_id)
if not record:
return f"No record found for call {call_id}."
if not record.recording_path:
@@ -348,16 +339,12 @@ def create_mcp_server(
Returns the summary, action items, and sentiment analysis.
"""
from db.database import CallRecord, get_session_factory
from sqlalchemy import select
from db.database import session_scope
from services import call_persistence as store
try:
factory = get_session_factory()
async with factory() as session:
result = await session.execute(
select(CallRecord).where(CallRecord.id == call_id)
)
record = result.scalar_one_or_none()
async with session_scope() as session:
record = await store.get_record(session, call_id)
if not record:
return f"No record found for call {call_id}."
@@ -398,27 +385,17 @@ def create_mcp_server(
intent: Filter by intent text (partial match)
limit: Max results to return (default 10)
"""
from db.database import CallRecord, get_session_factory
from sqlalchemy import select
from db.database import session_scope
from services import call_persistence as store
try:
factory = get_session_factory()
async with factory() as session:
query = select(CallRecord).order_by(
CallRecord.started_at.desc()
).limit(limit)
if phone_number:
query = query.where(
CallRecord.remote_number.contains(phone_number)
)
if intent:
query = query.where(
CallRecord.intent.icontains(intent)
)
result = await session.execute(query)
records = result.scalars().all()
async with session_scope() as session:
records = await store.search_history(
session,
number_contains=phone_number or None,
intent_contains=intent or None,
limit=limit,
)
if not records:
return "No matching call records found."
@@ -482,14 +459,12 @@ def create_mcp_server(
@mcp.resource("gateway://call-flows")
async def resource_call_flows() -> str:
"""List all stored call flows."""
from db.database import StoredCallFlow, get_session_factory
from sqlalchemy import select
from db.database import session_scope
from services import call_persistence as store
try:
factory = get_session_factory()
async with factory() as session:
result = await session.execute(select(StoredCallFlow))
rows = result.scalars().all()
async with session_scope() as session:
rows = await store.list_flows(session)
flows = [
{
"id": r.id,