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

@@ -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))