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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user