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.
This commit is contained in:
2026-05-22 06:28:33 -04:00
parent dbdb03beb9
commit 63f1a270bb
28 changed files with 2275 additions and 11 deletions

View File

@@ -31,6 +31,7 @@ class CallMode(str, Enum):
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):

View File

@@ -38,6 +38,7 @@ class Device(DeviceBase):
id: str
is_online: bool = False
dnd: bool = False
last_seen: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@@ -45,6 +46,8 @@ class Device(DeviceBase):
@property
def can_receive_call(self) -> bool:
"""Can this device receive a call right now?"""
if self.dnd:
return False
if self.type in (DeviceType.SIP_PHONE, DeviceType.SOFTPHONE, DeviceType.WEBRTC):
return self.is_online and self.sip_uri is not None
if self.type == DeviceType.CELL:

View File

@@ -32,6 +32,19 @@ class EventType(str, Enum):
# Audio
AUDIO_CLASSIFIED = "audio.classified"
TRANSCRIPT_CHUNK = "audio.transcript_chunk"
SPEAK_PLAYED = "audio.speak_played"
# AI Receptionist
RECEPTIONIST_GREETING = "receptionist.greeting"
RECEPTIONIST_LISTENING = "receptionist.listening"
RECEPTIONIST_CAPTURED_INTENT = "receptionist.captured_intent"
RECEPTIONIST_ROUTING = "receptionist.routing"
RECEPTIONIST_MESSAGE_SAVED = "receptionist.message_saved"
RECEPTIONIST_REJECTED = "receptionist.rejected"
# Routing
ROUTING_RULE_MATCHED = "routing.rule_matched"
ROUTING_DEVICE_DND = "routing.device_dnd"
# Device
DEVICE_REGISTERED = "device.registered"

84
models/routing.py Normal file
View File

@@ -0,0 +1,84 @@
"""
Routing rule models — Smart routing for inbound calls.
Rules are evaluated in priority order (lower first). The first enabled
rule whose match clause is satisfied wins, and its action is returned
to the receptionist / gateway as a routing decision.
"""
from datetime import datetime
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
class RoutingActionType(str, Enum):
RING_DEVICE = "ring_device" # Ring a single device
RING_CHAIN = "ring_chain" # Ring a chain of devices (priority order)
TAKE_MESSAGE = "take_message" # Skip the ring, go straight to voicemail
REJECT = "reject" # Decline the call (busy / 603)
DND = "dnd" # Do Not Disturb — also reject, but tagged for analytics
class TimeRange(BaseModel):
"""A recurring time-of-day window."""
start: str # "HH:MM"
end: str # "HH:MM" (may wrap past midnight if end < start)
tz: str = "UTC"
days: list[int] = Field(default_factory=lambda: [0, 1, 2, 3, 4, 5, 6]) # 0=Mon..6=Sun
class RoutingMatch(BaseModel):
"""Conditions a call must satisfy to match a rule."""
caller_pattern: Optional[str] = None # glob, e.g. "+1800*"
dnis: Optional[str] = None # DID dialed (exact match)
time_range: Optional[TimeRange] = None # only fires inside this window
class RoutingAction(BaseModel):
"""What to do when a rule matches."""
type: RoutingActionType
device_id: Optional[str] = None # for RING_DEVICE
device_ids: list[str] = Field(default_factory=list) # for RING_CHAIN
ring_timeout: int = 25 # seconds per device
message: Optional[str] = None # optional TTS string for REJECT/DND
class RoutingRule(BaseModel):
id: str
name: str
priority: int = 100
enabled: bool = True
match: RoutingMatch = Field(default_factory=RoutingMatch)
action: RoutingAction
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class RoutingRuleCreate(BaseModel):
name: str
priority: int = 100
enabled: bool = True
match: RoutingMatch = Field(default_factory=RoutingMatch)
action: RoutingAction
class RoutingRuleUpdate(BaseModel):
name: Optional[str] = None
priority: Optional[int] = None
enabled: Optional[bool] = None
match: Optional[RoutingMatch] = None
action: Optional[RoutingAction] = None
class RoutingDecision(BaseModel):
"""Result of evaluating the routing rules for an inbound call."""
matched_rule_id: Optional[str] = None
matched_rule_name: Optional[str] = None
action: RoutingAction
reason: str = ""