Files
hold-slayer/models/call.py
Robert Helewka f7a11f2f20 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>
2026-07-10 07:42:52 -04:00

208 lines
6.6 KiB
Python

"""
Call models — Active call state, requests, and responses.
"""
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
class CallStatus(str, Enum):
"""Call lifecycle states."""
INITIATING = "initiating"
RINGING = "ringing"
CONNECTED = "connected"
NAVIGATING_IVR = "navigating_ivr"
ON_HOLD = "on_hold"
HUMAN_DETECTED = "human_detected"
TRANSFERRING = "transferring"
BRIDGED = "bridged" # User is connected to the remote party
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class CallMode(str, Enum):
"""How the call should be handled."""
DIRECT = "direct" # Call and connect immediately
HOLD_SLAYER = "hold_slayer" # Navigate IVR, wait on hold, transfer when human
AI_ASSISTED = "ai_assisted" # Connect with transcription, recording, noise cancel
RECEPTIONIST = "receptionist" # AI screens inbound caller, then routes or takes a message
class AudioClassification(str, Enum):
"""What kind of audio is currently playing."""
SILENCE = "silence"
MUSIC = "music" # Hold music
IVR_PROMPT = "ivr_prompt" # Automated voice (TTS/recording)
LIVE_HUMAN = "live_human" # Real person talking
RINGING = "ringing" # Ring-back tone
DTMF = "dtmf" # Touch tones
UNKNOWN = "unknown"
class ClassificationResult(BaseModel):
"""A single audio classification at a point in time."""
timestamp: float # Unix timestamp
audio_type: AudioClassification
confidence: float # 0.0 - 1.0
details: Optional[dict] = None # Extra analysis data
class TranscriptEntry(BaseModel):
"""One transcribed utterance, offset from call start for seek."""
t_offset_ms: int
speaker: str = "unknown" # caller / agent / receptionist / unknown
text: str
class ActiveCall(BaseModel):
"""In-memory state for an active call."""
id: str
direction: str = "outbound"
remote_number: str
status: CallStatus = CallStatus.INITIATING
mode: CallMode = CallMode.DIRECT
intent: Optional[str] = None
call_flow_id: Optional[str] = None
device: Optional[str] = None
started_at: datetime = Field(default_factory=datetime.now)
connected_at: Optional[datetime] = None
hold_started_at: Optional[datetime] = None
current_classification: AudioClassification = AudioClassification.UNKNOWN
classification_history: list[ClassificationResult] = Field(default_factory=list)
transcript_chunks: list[TranscriptEntry] = Field(default_factory=list)
current_step_id: Optional[str] = None # Current position in call flow
services: list[str] = Field(default_factory=list) # Active services on this call
@property
def duration(self) -> int:
"""Total call duration in seconds."""
if self.connected_at:
return int((datetime.now() - self.connected_at).total_seconds())
return 0
@property
def hold_time(self) -> int:
"""Time spent on hold in seconds."""
if self.hold_started_at and self.status == CallStatus.ON_HOLD:
return int((datetime.now() - self.hold_started_at).total_seconds())
return 0
@property
def transcript(self) -> str:
"""Full transcript so far."""
return "\n".join(e.text for e in self.transcript_chunks)
def summary(self) -> dict:
"""Compact summary for list views."""
return {
"call_id": self.id,
"remote_number": self.remote_number,
"status": self.status.value,
"mode": self.mode.value,
"duration": self.duration,
"hold_time": self.hold_time,
"audio_type": self.current_classification.value,
"intent": self.intent,
}
# ============================================================
# API Request/Response Models
# ============================================================
class CallRequest(BaseModel):
"""Request to place an outbound call."""
number: str # E.164 format
mode: CallMode = CallMode.DIRECT
intent: Optional[str] = None # What you need (for hold_slayer IVR navigation)
device: Optional[str] = None # Target device to ring / transfer to
call_flow_id: Optional[str] = None # Use a stored IVR tree
services: list[str] = Field(
default_factory=lambda: ["recording", "transcription"]
)
class HoldSlayerRequest(BaseModel):
"""Request to launch the Hold Slayer."""
number: str # E.164 format
intent: str # "dispute a charge on my December statement"
call_flow_id: Optional[str] = None # Optional: use stored IVR tree
transfer_to: Optional[str] = None # Device to ring when human detected
notify: list[str] = Field(default_factory=lambda: ["push"]) # Notification channels
class CallResponse(BaseModel):
"""Response after initiating a call."""
call_id: str
status: str
number: str
mode: str
message: Optional[str] = None
@classmethod
def from_call(cls, call: "ActiveCall", message: Optional[str] = None) -> "CallResponse":
return cls(
call_id=call.id,
status=call.status.value,
number=call.remote_number,
mode=call.mode.value,
message=message,
)
class CallStatusResponse(BaseModel):
"""Full status of an active or completed call."""
call_id: str
status: str
direction: str
remote_number: str
mode: str
duration: int
hold_time: int
audio_type: str
intent: Optional[str] = None
transcript_excerpt: Optional[str] = None # Last N chars
classification_history: list[ClassificationResult] = Field(default_factory=list)
current_step: Optional[str] = None
services: list[str] = Field(default_factory=list)
@classmethod
def from_call(cls, call: "ActiveCall") -> "CallStatusResponse":
"""The one ActiveCall → status-response mapping."""
return cls(
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[-20:],
current_step=call.current_step_id,
services=call.services,
)
class TransferRequest(BaseModel):
"""Request to transfer a call to a device."""
device: str # Device ID or type