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

@@ -2,26 +2,21 @@
Call Flows API — Store and manage IVR navigation trees.
The system gets smarter every time you call somewhere.
Thin HTTP layer over the shared data functions in call_persistence.
"""
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from slugify import slugify
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_gateway
from core.gateway import AIPSTNGateway
from db.database import StoredCallFlow, get_db
from db.database import get_db
from models.call_flow import (
CallFlow,
CallFlowCreate,
CallFlowStep,
CallFlowSummary,
CallFlowUpdate,
)
from services import call_persistence as store
router = APIRouter()
@@ -34,39 +29,23 @@ async def create_call_flow(
"""Store a new call flow for a phone number."""
flow_id = slugify(flow.name)
# Check if ID already exists
existing = await db.execute(
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
)
if existing.scalar_one_or_none():
if await store.get_flow(db, flow_id):
raise HTTPException(
status_code=409,
detail=f"Call flow '{flow_id}' already exists. Use PUT to update.",
)
db_flow = StoredCallFlow(
id=flow_id,
row = await store.create_flow(
db,
flow_id=flow_id,
name=flow.name,
phone_number=flow.phone_number,
description=flow.description,
steps=[s.model_dump() for s in flow.steps],
tags=flow.tags,
notes=flow.notes,
last_verified=datetime.now(),
)
db.add(db_flow)
await db.flush()
return CallFlow(
id=flow_id,
name=flow.name,
phone_number=flow.phone_number,
description=flow.description,
steps=flow.steps,
tags=flow.tags,
notes=flow.notes,
last_verified=datetime.now(),
)
return store.flow_to_model(row)
@router.get("/", response_model=list[CallFlowSummary])
@@ -74,9 +53,7 @@ async def list_call_flows(
db: AsyncSession = Depends(get_db),
):
"""List all stored call flows."""
result = await db.execute(select(StoredCallFlow))
rows = result.scalars().all()
rows = await store.list_flows(db)
return [
CallFlowSummary(
id=row.id,
@@ -100,26 +77,10 @@ async def get_call_flow(
db: AsyncSession = Depends(get_db),
):
"""Get a stored call flow by ID."""
result = await db.execute(
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
)
row = result.scalar_one_or_none()
row = await store.get_flow(db, flow_id)
if not row:
raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found")
return CallFlow(
id=row.id,
name=row.name,
phone_number=row.phone_number,
description=row.description or "",
steps=[CallFlowStep(**s) for s in row.steps],
tags=row.tags or [],
notes=row.notes,
avg_hold_time=row.avg_hold_time,
success_rate=row.success_rate,
last_used=row.last_used,
times_used=row.times_used or 0,
)
return store.flow_to_model(row)
@router.get("/by-number/{phone_number}", response_model=CallFlow)
@@ -128,29 +89,13 @@ async def get_flow_for_number(
db: AsyncSession = Depends(get_db),
):
"""Look up stored call flow by phone number."""
result = await db.execute(
select(StoredCallFlow).where(StoredCallFlow.phone_number == phone_number)
)
row = result.scalar_one_or_none()
row = await store.get_flow_by_number(db, phone_number)
if not row:
raise HTTPException(
status_code=404,
detail=f"No call flow found for {phone_number}",
)
return CallFlow(
id=row.id,
name=row.name,
phone_number=row.phone_number,
description=row.description or "",
steps=[CallFlowStep(**s) for s in row.steps],
tags=row.tags or [],
notes=row.notes,
avg_hold_time=row.avg_hold_time,
success_rate=row.success_rate,
last_used=row.last_used,
times_used=row.times_used or 0,
)
return store.flow_to_model(row)
@router.put("/{flow_id}", response_model=CallFlow)
@@ -160,10 +105,7 @@ async def update_call_flow(
db: AsyncSession = Depends(get_db),
):
"""Update an existing call flow."""
result = await db.execute(
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
)
row = result.scalar_one_or_none()
row = await store.get_flow(db, flow_id)
if not row:
raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found")
@@ -181,20 +123,7 @@ async def update_call_flow(
row.last_verified = update.last_verified
await db.flush()
return CallFlow(
id=row.id,
name=row.name,
phone_number=row.phone_number,
description=row.description or "",
steps=[CallFlowStep(**s) for s in row.steps],
tags=row.tags or [],
notes=row.notes,
avg_hold_time=row.avg_hold_time,
success_rate=row.success_rate,
last_used=row.last_used,
times_used=row.times_used or 0,
)
return store.flow_to_model(row)
@router.delete("/{flow_id}")
@@ -203,10 +132,7 @@ async def delete_call_flow(
db: AsyncSession = Depends(get_db),
):
"""Delete a stored call flow."""
result = await db.execute(
select(StoredCallFlow).where(StoredCallFlow.id == flow_id)
)
row = result.scalar_one_or_none()
row = await store.get_flow(db, flow_id)
if not row:
raise HTTPException(status_code=404, detail=f"Call flow '{flow_id}' not found")

View File

@@ -1,22 +1,18 @@
"""
Call History API — Read-only access to persisted call records,
transcript chunks, and recording files for the dashboard.
Thin HTTP layer over the shared data functions in call_persistence.
"""
import os
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from db.database import (
CallRecord,
RecordingRecord,
TranscriptChunk,
get_db,
)
from db.database import get_db
from services import call_persistence as store
router = APIRouter()
@@ -25,24 +21,22 @@ router = APIRouter()
async def list_history(
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
number: Optional[str] = None,
status: Optional[str] = None,
since: Optional[datetime] = None,
until: Optional[datetime] = None,
number: str | None = None,
status: str | None = None,
since: datetime | None = None,
until: datetime | None = None,
db: AsyncSession = Depends(get_db),
):
"""Paged list of past calls, newest first."""
stmt = select(CallRecord).order_by(desc(CallRecord.started_at))
if number:
stmt = stmt.where(CallRecord.remote_number == number)
if status:
stmt = stmt.where(CallRecord.status == status)
if since:
stmt = stmt.where(CallRecord.started_at >= since)
if until:
stmt = stmt.where(CallRecord.started_at <= until)
rows = (await db.execute(stmt.offset(offset).limit(limit))).scalars().all()
rows = await store.search_history(
db,
number=number,
status=status,
since=since,
until=until,
limit=limit,
offset=offset,
)
return [
{
"id": r.id,
@@ -65,9 +59,7 @@ async def list_history(
@router.get("/{call_id}/record")
async def get_record(call_id: str, db: AsyncSession = Depends(get_db)):
"""Full CallRecord with classification_timeline."""
row = (await db.execute(
select(CallRecord).where(CallRecord.id == call_id)
)).scalar_one_or_none()
row = await store.get_record(db, call_id)
if not row:
raise HTTPException(status_code=404, detail=f"Call {call_id} not found")
return {
@@ -93,11 +85,7 @@ async def get_record(call_id: str, db: AsyncSession = Depends(get_db)):
@router.get("/{call_id}/transcript")
async def get_transcript(call_id: str, db: AsyncSession = Depends(get_db)):
"""Ordered transcript chunks for a call."""
rows = (await db.execute(
select(TranscriptChunk)
.where(TranscriptChunk.call_id == call_id)
.order_by(TranscriptChunk.seq)
)).scalars().all()
rows = await store.get_transcript_chunks(db, call_id)
return [
{
"seq": c.seq,
@@ -113,14 +101,9 @@ async def get_transcript(call_id: str, db: AsyncSession = Depends(get_db)):
@router.get("/{call_id}/recording")
async def get_recording(call_id: str, db: AsyncSession = Depends(get_db)):
"""Stream the WAV recording for a call."""
row = (await db.execute(
select(RecordingRecord)
.where(RecordingRecord.call_id == call_id)
.order_by(desc(RecordingRecord.started_at))
)).scalar_one_or_none()
row = await store.latest_recording(db, call_id)
if not row or not row.path:
raise HTTPException(status_code=404, detail="Recording not found")
import os
if not os.path.exists(row.path):
raise HTTPException(status_code=404, detail="Recording file missing on disk")
return FileResponse(row.path, media_type="audio/wav", filename=os.path.basename(row.path))

View File

@@ -172,10 +172,9 @@ async def send_dtmf(
if not call:
raise HTTPException(status_code=404, detail=f"Call {call_id} not found")
# Find the PSTN leg for this call
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 {"status": "sent", "digits": digits}
legs = gateway.call_manager.legs_for_call(call_id)
if not legs:
raise HTTPException(status_code=409, detail="No active SIP leg found for this call")
raise HTTPException(status_code=500, detail="No active SIP leg found for this call")
await gateway.sip_engine.send_dtmf(legs[0], digits)
return {"status": "sent", "digits": digits}

View File

@@ -18,6 +18,14 @@ def get_gateway(request: Request) -> AIPSTNGateway:
return gateway
def get_routing_service(request: Request):
"""Get the routing service from app state."""
routing = getattr(request.app.state, "routing_service", None)
if routing is None:
raise HTTPException(status_code=503, detail="Routing service not ready")
return routing
def require_token(authorization: str | None = Header(default=None)) -> None:
"""
Enforce the static bearer token (API_TOKEN) on REST routes.

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.deps import get_gateway
from api.deps import get_gateway, get_routing_service
from core.gateway import AIPSTNGateway
from db.database import Device as DeviceDB
from db.database import get_db
@@ -15,36 +15,31 @@ from models.routing import (
RoutingRuleCreate,
RoutingRuleUpdate,
)
from services.routing import RoutingService
router = APIRouter()
@router.get("/rules", response_model=list[RoutingRule])
async def list_rules(gateway: AIPSTNGateway = Depends(get_gateway)):
if gateway._routing is None:
raise HTTPException(status_code=503, detail="Routing service not ready")
return sorted(gateway._routing.rules, key=lambda r: (r.priority, r.id))
async def list_rules(routing: RoutingService = Depends(get_routing_service)):
return sorted(routing.rules, key=lambda r: (r.priority, r.id))
@router.post("/rules", response_model=RoutingRule, status_code=201)
async def create_rule(
payload: RoutingRuleCreate,
gateway: AIPSTNGateway = Depends(get_gateway),
routing: RoutingService = Depends(get_routing_service),
):
if gateway._routing is None:
raise HTTPException(status_code=503, detail="Routing service not ready")
return await gateway._routing.create_rule(payload)
return await routing.create_rule(payload)
@router.put("/rules/{rule_id}", response_model=RoutingRule)
async def update_rule(
rule_id: str,
payload: RoutingRuleUpdate,
gateway: AIPSTNGateway = Depends(get_gateway),
routing: RoutingService = Depends(get_routing_service),
):
if gateway._routing is None:
raise HTTPException(status_code=503, detail="Routing service not ready")
rule = await gateway._routing.update_rule(rule_id, payload)
rule = await routing.update_rule(rule_id, payload)
if rule is None:
raise HTTPException(status_code=404, detail=f"Rule {rule_id} not found")
return rule
@@ -53,11 +48,9 @@ async def update_rule(
@router.delete("/rules/{rule_id}")
async def delete_rule(
rule_id: str,
gateway: AIPSTNGateway = Depends(get_gateway),
routing: RoutingService = Depends(get_routing_service),
):
if gateway._routing is None:
raise HTTPException(status_code=503, detail="Routing service not ready")
ok = await gateway._routing.delete_rule(rule_id)
ok = await routing.delete_rule(rule_id)
if not ok:
raise HTTPException(status_code=404, detail=f"Rule {rule_id} not found")
return {"status": "deleted", "rule_id": rule_id}