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