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:
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
84
models/routing.py
Normal 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 = ""
|
||||
Reference in New Issue
Block a user