Files
hold-slayer/api/call_history.py
Robert Helewka 63f1a270bb feat: add call history API endpoints and TTS service client
Adds read-only access to persisted call records for the dashboard
and implements a client for the Rhema text-to-speech service.

- api/call_history.py: New router providing paged call lists
  and detailed call records with transcript metadata.
- services/tts.py: Async client for OpenAI-compatible TTS
  endpoints (Rhema/Kokoro) used for call-flow steps.
2026-05-22 06:28:33 -04:00

127 lines
4.1 KiB
Python

"""
Call History API — Read-only access to persisted call records,
transcript chunks, and recording files for the dashboard.
"""
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,
)
router = APIRouter()
@router.get("/history")
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,
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()
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
]
@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()
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,
}
@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()
return [
{
"seq": c.seq,
"t_offset_ms": c.t_offset_ms,
"speaker": c.speaker,
"text": c.text,
"confidence": c.confidence,
}
for c in rows
]
@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()
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))