Stage 5: Alembic migrations, durable call rows, one transcript truth

Alembic replaces create_all as the schema authority: async env.py
against Base.metadata (CLI and in-app entry paths share it via
config.attributes["connection"]), an autogenerated baseline of the
create_all-era schema, and init_db now runs upgrade head — stamping
the baseline first on a pre-Alembic database so existing deployments
adopt cleanly. create_all remains for tests only.

Calls are durable from the start: CallManager gains an
on_call_created hook (wired to persist_call_on_create) that inserts
an in_progress CallRecord the moment a call is created;
persist_call_on_end finalizes that same row. A SIGKILL mid-call now
leaves an in_progress row instead of erasing the call from history
(verified live against the dev database).

One transcript representation: ActiveCall.transcript_chunks holds
TranscriptEntry (t_offset_ms, speaker, text) — add_transcript stamps
real offsets from connect time, receptionist passes speaker instead
of encoding it into "caller: ..." strings, persisted chunks carry
real seek offsets, and the dead CallRecord.transcript Text column is
dropped by migration. Device.is_online migrates String → Boolean
(with a USING cast for existing rows).

Model de-triplication: CallResponse/CallStatusResponse build via
from_call classmethods (one ActiveCall→response mapping);
DeviceStatus deleted — can_receive_call is a computed field on
Device and the list endpoint returns the domain model; all row↔dict
and row↔domain mapping now lives in call_persistence.py
(record_summary/record_detail/chunk_to_dict + device row functions).

New tests/test_data_layer.py: upgrade-head-matches-models,
pre-Alembic adoption, durable in_progress rows, end-without-create
fallback, transcript offsets, consolidated response models.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 07:42:52 -04:00
parent 4048ce1db6
commit f7a11f2f20
18 changed files with 777 additions and 202 deletions

View File

@@ -37,23 +37,7 @@ async def list_history(
limit=limit,
offset=offset,
)
return [
{
"id": r.id,
"direction": r.direction,
"remote_number": r.remote_number,
"status": r.status,
"mode": r.mode,
"intent": r.intent,
"started_at": r.started_at.isoformat() if r.started_at else None,
"ended_at": r.ended_at.isoformat() if r.ended_at else None,
"duration": r.duration,
"hold_time": r.hold_time,
"device_used": r.device_used,
"summary": r.summary,
}
for r in rows
]
return [store.record_summary(r) for r in rows]
@router.get("/{call_id}/record")
@@ -62,40 +46,14 @@ async def get_record(call_id: str, db: AsyncSession = Depends(get_db)):
row = await store.get_record(db, call_id)
if not row:
raise HTTPException(status_code=404, detail=f"Call {call_id} not found")
return {
"id": row.id,
"direction": row.direction,
"remote_number": row.remote_number,
"status": row.status,
"mode": row.mode,
"intent": row.intent,
"started_at": row.started_at.isoformat() if row.started_at else None,
"ended_at": row.ended_at.isoformat() if row.ended_at else None,
"duration": row.duration,
"hold_time": row.hold_time,
"device_used": row.device_used,
"summary": row.summary,
"action_items": row.action_items,
"sentiment": row.sentiment,
"call_flow_id": row.call_flow_id,
"classification_timeline": row.classification_timeline,
}
return store.record_detail(row)
@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 store.get_transcript_chunks(db, call_id)
return [
{
"seq": c.seq,
"t_offset_ms": c.t_offset_ms,
"speaker": c.speaker,
"text": c.text,
"confidence": c.confidence,
}
for c in rows
]
return [store.chunk_to_dict(c) for c in rows]
@router.get("/{call_id}/recording")

View File

@@ -40,12 +40,7 @@ async def make_call(
call_flow_id=request.call_flow_id,
services=request.services,
)
return CallResponse(
call_id=call.id,
status=call.status.value,
number=request.number,
mode=request.mode.value,
)
return CallResponse.from_call(call)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
@@ -81,11 +76,8 @@ async def hold_slayer(
call_flow_id=request.call_flow_id,
device=request.transfer_to,
)
return CallResponse(
call_id=call.id,
status="navigating_ivr",
number=request.number,
mode="hold_slayer",
return CallResponse.from_call(
call,
message="Hold Slayer activated. I'll ring you when a human picks up. ☕",
)
except ValueError as e:
@@ -113,21 +105,7 @@ async def get_call(
if not call:
raise HTTPException(status_code=404, detail=f"Call {call_id} not found")
return CallStatusResponse(
call_id=call.id,
status=call.status.value,
direction=call.direction,
remote_number=call.remote_number,
mode=call.mode.value,
duration=call.duration,
hold_time=call.hold_time,
audio_type=call.current_classification.value,
intent=call.intent,
transcript_excerpt=call.transcript[-500:] if call.transcript else None,
classification_history=call.classification_history[-50:],
current_step=call.current_step_id,
services=call.services,
)
return CallStatusResponse.from_call(call)
@router.post("/{call_id}/transfer")

View File

@@ -1,19 +1,19 @@
"""
Device Management API — Register and manage phones/softphones.
Row mapping lives in call_persistence; this layer works with the
Device domain model only.
"""
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
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 Device as DeviceDB
from db.database import get_db
from models.device import Device, DeviceCreate, DeviceStatus, DeviceUpdate
from models.device import Device, DeviceCreate, DeviceUpdate
from services import call_persistence as store
router = APIRouter()
@@ -25,45 +25,18 @@ async def register_device(
db: AsyncSession = Depends(get_db),
):
"""Register a new device with the gateway."""
device_id = f"dev_{uuid.uuid4().hex[:8]}"
# Save to DB
db_device = DeviceDB(
id=device_id,
name=device.name,
type=device.type.value,
sip_uri=device.sip_uri,
phone_number=device.phone_number,
priority=device.priority,
capabilities=device.capabilities,
is_online="false",
)
db.add(db_device)
await db.flush()
# Register with gateway
dev = Device(id=device_id, **device.model_dump())
dev = Device(id=f"dev_{uuid.uuid4().hex[:8]}", **device.model_dump())
await store.create_device_row(db, dev)
gateway.register_device(dev)
return dev
@router.get("/", response_model=list[DeviceStatus])
@router.get("/", response_model=list[Device])
async def list_devices(
gateway: AIPSTNGateway = Depends(get_gateway),
):
"""List all registered devices and their status."""
return [
DeviceStatus(
id=d.id,
name=d.name,
type=d.type,
is_online=d.is_online,
last_seen=d.last_seen,
can_receive_call=d.can_receive_call,
)
for d in gateway.devices.values()
]
return list(gateway.devices.values())
@router.get("/{device_id}", response_model=Device)
@@ -90,22 +63,11 @@ async def update_device(
if not device:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
# Update in-memory
update_data = update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(device, key, value)
# Update in DB
result = await db.execute(
select(DeviceDB).where(DeviceDB.id == device_id)
)
db_device = result.scalar_one_or_none()
if db_device:
for key, value in update_data.items():
if key == "type" and value is not None:
value = value.value if hasattr(value, "value") else value
setattr(db_device, key, value)
await store.update_device_row(db, device_id, update_data)
return device
@@ -120,12 +82,5 @@ async def unregister_device(
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
gateway.unregister_device(device_id)
result = await db.execute(
select(DeviceDB).where(DeviceDB.id == device_id)
)
db_device = result.scalar_one_or_none()
if db_device:
await db.delete(db_device)
await store.delete_device_row(db, device_id)
return {"status": "unregistered", "device_id": device_id}