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

258
services/routing.py Normal file
View File

@@ -0,0 +1,258 @@
"""
Routing Service — Smart routing for inbound calls.
Evaluates `RoutingRule` records against an incoming call's caller ID,
DNIS, and the current time, returning a `RoutingDecision`. Also exposes
a ring-chain helper that tries devices in priority order until one
answers (or all timeouts elapse).
"""
import asyncio
import fnmatch
import logging
import uuid
from datetime import datetime, time
from typing import Optional
from zoneinfo import ZoneInfo
from sqlalchemy import select
from db.database import RoutingRuleRecord, get_session_factory
from models.routing import (
RoutingAction,
RoutingActionType,
RoutingDecision,
RoutingMatch,
RoutingRule,
RoutingRuleCreate,
RoutingRuleUpdate,
TimeRange,
)
logger = logging.getLogger(__name__)
def _parse_hhmm(s: str) -> time:
h, m = s.split(":")
return time(hour=int(h), minute=int(m))
def _time_in_range(now: datetime, tr: TimeRange) -> bool:
"""True if `now` (interpreted in tr.tz) falls inside the window."""
try:
tz = ZoneInfo(tr.tz)
except Exception:
tz = ZoneInfo("UTC")
local = now.astimezone(tz)
if local.weekday() not in tr.days:
return False
start = _parse_hhmm(tr.start)
end = _parse_hhmm(tr.end)
cur = local.time()
if start <= end:
return start <= cur < end
# Wraps past midnight (e.g. 22:00 → 06:00)
return cur >= start or cur < end
def _caller_matches(pattern: Optional[str], caller_number: str) -> bool:
if not pattern:
return True
return fnmatch.fnmatch(caller_number or "", pattern)
def _dnis_matches(rule_dnis: Optional[str], dnis: str) -> bool:
if not rule_dnis:
return True
return rule_dnis == dnis
class RoutingService:
"""Caches enabled rules and evaluates them per inbound call."""
DEFAULT_ACTION = RoutingAction(type=RoutingActionType.TAKE_MESSAGE)
def __init__(self, gateway):
self.gateway = gateway
self._rules: list[RoutingRule] = []
self._lock = asyncio.Lock()
async def start(self) -> None:
await self.reload()
async def reload(self) -> None:
async with self._lock:
self._rules = await self._load_rules_from_db()
logger.info(f"📋 Loaded {len(self._rules)} routing rules")
@property
def rules(self) -> list[RoutingRule]:
return list(self._rules)
async def evaluate(
self,
caller_number: str,
dnis: str,
now: Optional[datetime] = None,
) -> RoutingDecision:
"""Walk rules in priority order, return first match or default."""
now = now or datetime.now().astimezone()
for rule in sorted(self._rules, key=lambda r: (r.priority, r.id)):
if not rule.enabled:
continue
m = rule.match
if not _caller_matches(m.caller_pattern, caller_number):
continue
if not _dnis_matches(m.dnis, dnis):
continue
if m.time_range and not _time_in_range(now, m.time_range):
continue
return RoutingDecision(
matched_rule_id=rule.id,
matched_rule_name=rule.name,
action=rule.action,
reason=f"matched rule '{rule.name}'",
)
return RoutingDecision(
action=self.DEFAULT_ACTION,
reason="no rule matched — default take_message",
)
# ----------------------------------------------------------------
# Ring chain
# ----------------------------------------------------------------
async def ring_chain(
self,
call_id: str,
device_ids: list[str],
ring_timeout: int = 25,
) -> Optional[str]:
"""
Try each device sequentially. Returns the device_id that answered,
or None if all timed out.
"""
for device_id in device_ids:
device = self.gateway.devices.get(device_id)
if not device:
continue
if getattr(device, "dnd", False):
logger.info(f"📞 Skipping {device_id} (DND)")
continue
try:
await self.gateway.transfer_call(call_id, device_id)
# If transfer_call returned normally, treat as picked up.
return device_id
except asyncio.TimeoutError:
logger.info(f"📞 Ring timeout for device {device_id}")
continue
except Exception as e:
logger.warning(f"📞 Ring failed for device {device_id}: {e}")
continue
return None
# ----------------------------------------------------------------
# CRUD
# ----------------------------------------------------------------
async def create_rule(self, payload: RoutingRuleCreate) -> RoutingRule:
rule_id = f"rule_{uuid.uuid4().hex[:8]}"
record = RoutingRuleRecord(
id=rule_id,
name=payload.name,
priority=payload.priority,
enabled=payload.enabled,
match=payload.match.model_dump(),
action=payload.action.model_dump(),
)
async with get_session_factory()() as session:
session.add(record)
await session.commit()
rule = RoutingRule(
id=rule_id,
name=payload.name,
priority=payload.priority,
enabled=payload.enabled,
match=payload.match,
action=payload.action,
)
async with self._lock:
self._rules.append(rule)
return rule
async def update_rule(
self, rule_id: str, payload: RoutingRuleUpdate
) -> Optional[RoutingRule]:
async with get_session_factory()() as session:
result = await session.execute(
select(RoutingRuleRecord).where(RoutingRuleRecord.id == rule_id)
)
record = result.scalar_one_or_none()
if not record:
return None
if payload.name is not None:
record.name = payload.name
if payload.priority is not None:
record.priority = payload.priority
if payload.enabled is not None:
record.enabled = payload.enabled
if payload.match is not None:
record.match = payload.match.model_dump()
if payload.action is not None:
record.action = payload.action.model_dump()
await session.commit()
await self.reload()
return next((r for r in self._rules if r.id == rule_id), None)
async def delete_rule(self, rule_id: str) -> bool:
async with get_session_factory()() as session:
result = await session.execute(
select(RoutingRuleRecord).where(RoutingRuleRecord.id == rule_id)
)
record = result.scalar_one_or_none()
if not record:
return False
await session.delete(record)
await session.commit()
async with self._lock:
self._rules = [r for r in self._rules if r.id != rule_id]
return True
# ----------------------------------------------------------------
# Internals
# ----------------------------------------------------------------
async def _load_rules_from_db(self) -> list[RoutingRule]:
try:
async with get_session_factory()() as session:
result = await session.execute(select(RoutingRuleRecord))
rows = result.scalars().all()
except Exception as e:
logger.warning(f"Routing rules load failed: {e}")
return []
rules: list[RoutingRule] = []
for row in rows:
try:
match = RoutingMatch(**(row.match or {}))
action = RoutingAction(**(row.action or {}))
rules.append(
RoutingRule(
id=row.id,
name=row.name,
priority=row.priority,
enabled=row.enabled,
match=match,
action=action,
created_at=row.created_at,
updated_at=row.updated_at,
)
)
except Exception as e:
logger.warning(f"Skipping malformed rule {row.id}: {e}")
return rules