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:
126
api/call_history.py
Normal file
126
api/call_history.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
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))
|
||||||
85
api/routing.py
Normal file
85
api/routing.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Routing Rules API — CRUD for inbound routing rules and per-device DND.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from api.deps import get_gateway
|
||||||
|
from core.gateway import AIPSTNGateway
|
||||||
|
from db.database import Device as DeviceDB
|
||||||
|
from db.database import get_db
|
||||||
|
from models.routing import (
|
||||||
|
RoutingRule,
|
||||||
|
RoutingRuleCreate,
|
||||||
|
RoutingRuleUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rules", response_model=list[RoutingRule])
|
||||||
|
async def list_rules(gateway: AIPSTNGateway = Depends(get_gateway)):
|
||||||
|
if gateway._routing is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Routing service not ready")
|
||||||
|
return sorted(gateway._routing.rules, key=lambda r: (r.priority, r.id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/rules", response_model=RoutingRule, status_code=201)
|
||||||
|
async def create_rule(
|
||||||
|
payload: RoutingRuleCreate,
|
||||||
|
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||||
|
):
|
||||||
|
if gateway._routing is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Routing service not ready")
|
||||||
|
return await gateway._routing.create_rule(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/rules/{rule_id}", response_model=RoutingRule)
|
||||||
|
async def update_rule(
|
||||||
|
rule_id: str,
|
||||||
|
payload: RoutingRuleUpdate,
|
||||||
|
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||||
|
):
|
||||||
|
if gateway._routing is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Routing service not ready")
|
||||||
|
rule = await gateway._routing.update_rule(rule_id, payload)
|
||||||
|
if rule is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Rule {rule_id} not found")
|
||||||
|
return rule
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/rules/{rule_id}")
|
||||||
|
async def delete_rule(
|
||||||
|
rule_id: str,
|
||||||
|
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||||
|
):
|
||||||
|
if gateway._routing is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Routing service not ready")
|
||||||
|
ok = await gateway._routing.delete_rule(rule_id)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Rule {rule_id} not found")
|
||||||
|
return {"status": "deleted", "rule_id": rule_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/devices/{device_id}/dnd")
|
||||||
|
async def set_device_dnd(
|
||||||
|
device_id: str,
|
||||||
|
payload: dict,
|
||||||
|
gateway: AIPSTNGateway = Depends(get_gateway),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Toggle Do-Not-Disturb on a device."""
|
||||||
|
enabled = bool(payload.get("enabled", True))
|
||||||
|
device = gateway.devices.get(device_id)
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Device {device_id} not found")
|
||||||
|
device.dnd = enabled
|
||||||
|
|
||||||
|
result = await db.execute(select(DeviceDB).where(DeviceDB.id == device_id))
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
if row is not None:
|
||||||
|
row.dnd = enabled
|
||||||
|
|
||||||
|
return {"device_id": device_id, "dnd": enabled}
|
||||||
34
config.py
34
config.py
@@ -76,6 +76,38 @@ class HoldSlayerSettings(BaseSettings):
|
|||||||
hold_check_interval: float = Field(default=2.0, validation_alias="HOLD_CHECK_INTERVAL")
|
hold_check_interval: float = Field(default=2.0, validation_alias="HOLD_CHECK_INTERVAL")
|
||||||
|
|
||||||
|
|
||||||
|
class TTSSettings(BaseSettings):
|
||||||
|
"""Rhema TTS service configuration (OpenAI-compatible /v1/audio/speech)."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="TTS_")
|
||||||
|
|
||||||
|
base_url: str = "http://localhost:8000"
|
||||||
|
model: str = "speaches-ai/Kokoro-82M-v1.0-ONNX"
|
||||||
|
voice: str = "af_heart"
|
||||||
|
api_key: str = ""
|
||||||
|
timeout: float = 30.0
|
||||||
|
sample_rate: int = 16000
|
||||||
|
|
||||||
|
|
||||||
|
class ReceptionistSettings(BaseSettings):
|
||||||
|
"""AI Receptionist behavior settings."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="RECEPTIONIST_")
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
greeting_template: str = (
|
||||||
|
"Hi, you've reached Robert's line. Who's calling, and what's this about?"
|
||||||
|
)
|
||||||
|
message_prompt: str = "Please leave your message after the tone."
|
||||||
|
listen_timeout_s: float = 15.0
|
||||||
|
end_of_utterance_silence_s: float = 1.2
|
||||||
|
message_max_seconds: int = 90
|
||||||
|
llm_persona: str = (
|
||||||
|
"You are a helpful, concise phone receptionist. Decide whether to ring "
|
||||||
|
"the owner, take a message, or politely decline."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""Root application settings."""
|
"""Root application settings."""
|
||||||
|
|
||||||
@@ -104,6 +136,8 @@ class Settings(BaseSettings):
|
|||||||
classifier: ClassifierSettings = Field(default_factory=ClassifierSettings)
|
classifier: ClassifierSettings = Field(default_factory=ClassifierSettings)
|
||||||
llm: LLMSettings = Field(default_factory=LLMSettings)
|
llm: LLMSettings = Field(default_factory=LLMSettings)
|
||||||
hold_slayer: HoldSlayerSettings = Field(default_factory=HoldSlayerSettings)
|
hold_slayer: HoldSlayerSettings = Field(default_factory=HoldSlayerSettings)
|
||||||
|
tts: TTSSettings = Field(default_factory=TTSSettings)
|
||||||
|
receptionist: ReceptionistSettings = Field(default_factory=ReceptionistSettings)
|
||||||
|
|
||||||
|
|
||||||
# Singleton
|
# Singleton
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class CallManager:
|
|||||||
self.event_bus = event_bus
|
self.event_bus = event_bus
|
||||||
self._active_calls: dict[str, ActiveCall] = {}
|
self._active_calls: dict[str, ActiveCall] = {}
|
||||||
self._call_legs: dict[str, str] = {} # SIP leg ID -> call ID mapping
|
self._call_legs: dict[str, str] = {} # SIP leg ID -> call ID mapping
|
||||||
|
self._on_call_ended = None # async callback(call: ActiveCall, final_status)
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# Call Lifecycle
|
# Call Lifecycle
|
||||||
@@ -164,6 +165,11 @@ class CallManager:
|
|||||||
},
|
},
|
||||||
message=f"📵 Call ended: {call.remote_number} ({call.duration}s, hold: {call.hold_time}s)",
|
message=f"📵 Call ended: {call.remote_number} ({call.duration}s, hold: {call.hold_time}s)",
|
||||||
))
|
))
|
||||||
|
if self._on_call_ended is not None:
|
||||||
|
try:
|
||||||
|
await self._on_call_ended(call, status)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"on_call_ended hook failed for {call_id}: {e}")
|
||||||
return call
|
return call
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|||||||
116
core/gateway.py
116
core/gateway.py
@@ -24,6 +24,20 @@ from models.events import EventType, GatewayEvent
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_number(sip_uri: str) -> str:
|
||||||
|
"""Pull the user part out of a SIP URI (sip:+15551212@host → +15551212)."""
|
||||||
|
if not sip_uri:
|
||||||
|
return ""
|
||||||
|
s = sip_uri.strip()
|
||||||
|
if s.startswith("<") and ">" in s:
|
||||||
|
s = s[1:s.index(">")]
|
||||||
|
if s.startswith("sip:"):
|
||||||
|
s = s[4:]
|
||||||
|
if "@" in s:
|
||||||
|
s = s.split("@", 1)[0]
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
def _build_sip_engine(settings: Settings, gateway: "AIPSTNGateway") -> SIPEngine:
|
def _build_sip_engine(settings: Settings, gateway: "AIPSTNGateway") -> SIPEngine:
|
||||||
"""Build the appropriate SIP engine from config."""
|
"""Build the appropriate SIP engine from config."""
|
||||||
trunk = settings.sip_trunk
|
trunk = settings.sip_trunk
|
||||||
@@ -42,7 +56,9 @@ def _build_sip_engine(settings: Settings, gateway: "AIPSTNGateway") -> SIPEngine
|
|||||||
trunk_transport=trunk.transport,
|
trunk_transport=trunk.transport,
|
||||||
domain=gw_sip.domain,
|
domain=gw_sip.domain,
|
||||||
did=trunk.did,
|
did=trunk.did,
|
||||||
|
media_pipeline=gateway.media_pipeline,
|
||||||
on_device_registered=gateway._on_sip_device_registered,
|
on_device_registered=gateway._on_sip_device_registered,
|
||||||
|
on_incoming_call=gateway._on_sip_incoming_call,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not create SippyEngine: {e} — using mock")
|
logger.warning(f"Could not create SippyEngine: {e} — using mock")
|
||||||
@@ -71,12 +87,16 @@ class AIPSTNGateway:
|
|||||||
self.settings = settings
|
self.settings = settings
|
||||||
self.event_bus = EventBus()
|
self.event_bus = EventBus()
|
||||||
self.call_manager = CallManager(self.event_bus)
|
self.call_manager = CallManager(self.event_bus)
|
||||||
|
self.media_pipeline = MediaPipeline(sample_rate=16000)
|
||||||
self.sip_engine: SIPEngine = sip_engine or MockSIPEngine()
|
self.sip_engine: SIPEngine = sip_engine or MockSIPEngine()
|
||||||
|
|
||||||
# Services (initialized in start())
|
# Services (initialized in start())
|
||||||
self._hold_slayer = None
|
self._hold_slayer = None
|
||||||
self._audio_classifier = None
|
self._audio_classifier = None
|
||||||
self._transcription = None
|
self._transcription = None
|
||||||
|
self._tts = None
|
||||||
|
self._routing = None
|
||||||
|
self._receptionist = None
|
||||||
|
|
||||||
# Device registry (loaded from DB on start)
|
# Device registry (loaded from DB on start)
|
||||||
self._devices: dict[str, Device] = {}
|
self._devices: dict[str, Device] = {}
|
||||||
@@ -103,6 +123,9 @@ class AIPSTNGateway:
|
|||||||
"""Boot the gateway — start SIP engine and services."""
|
"""Boot the gateway — start SIP engine and services."""
|
||||||
logger.info("🔥 Starting AI PSTN Gateway...")
|
logger.info("🔥 Starting AI PSTN Gateway...")
|
||||||
|
|
||||||
|
# Start media pipeline first so SIP engine can hand it RTP streams
|
||||||
|
await self.media_pipeline.start()
|
||||||
|
|
||||||
# Start SIP engine
|
# Start SIP engine
|
||||||
await self.sip_engine.start()
|
await self.sip_engine.start()
|
||||||
logger.info(f" SIP Engine: ready")
|
logger.info(f" SIP Engine: ready")
|
||||||
@@ -110,9 +133,21 @@ class AIPSTNGateway:
|
|||||||
# Import services here to avoid circular imports
|
# Import services here to avoid circular imports
|
||||||
from services.audio_classifier import AudioClassifier
|
from services.audio_classifier import AudioClassifier
|
||||||
from services.transcription import TranscriptionService
|
from services.transcription import TranscriptionService
|
||||||
|
from services.tts import TTSService
|
||||||
|
from services.routing import RoutingService
|
||||||
|
from services.receptionist import ReceptionistService
|
||||||
|
|
||||||
self._audio_classifier = AudioClassifier(self.settings.classifier)
|
self._audio_classifier = AudioClassifier(self.settings.classifier)
|
||||||
self._transcription = TranscriptionService(self.settings.speaches)
|
self._transcription = TranscriptionService(self.settings.speaches)
|
||||||
|
self._tts = TTSService(self.settings.tts)
|
||||||
|
self._routing = RoutingService(self)
|
||||||
|
await self._routing.start()
|
||||||
|
self._receptionist = ReceptionistService(self)
|
||||||
|
|
||||||
|
# Persist completed calls to the database for history/playback.
|
||||||
|
from services.call_persistence import persist_call_on_end
|
||||||
|
|
||||||
|
self.call_manager._on_call_ended = persist_call_on_end
|
||||||
|
|
||||||
self._started_at = datetime.now()
|
self._started_at = datetime.now()
|
||||||
|
|
||||||
@@ -150,6 +185,18 @@ class AIPSTNGateway:
|
|||||||
# Stop SIP engine
|
# Stop SIP engine
|
||||||
await self.sip_engine.stop()
|
await self.sip_engine.stop()
|
||||||
|
|
||||||
|
# Stop media pipeline last (after SIP no longer references streams)
|
||||||
|
try:
|
||||||
|
await self.media_pipeline.stop()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Media pipeline stop error: {e}")
|
||||||
|
|
||||||
|
if self._tts is not None:
|
||||||
|
try:
|
||||||
|
await self._tts.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
self._started_at = None
|
self._started_at = None
|
||||||
logger.info("Gateway shut down cleanly.")
|
logger.info("Gateway shut down cleanly.")
|
||||||
|
|
||||||
@@ -215,6 +262,7 @@ class AIPSTNGateway:
|
|||||||
classifier=self._audio_classifier,
|
classifier=self._audio_classifier,
|
||||||
transcription=self._transcription,
|
transcription=self._transcription,
|
||||||
settings=self.settings,
|
settings=self.settings,
|
||||||
|
tts=self._tts,
|
||||||
)
|
)
|
||||||
# Launch as background task — don't block
|
# Launch as background task — don't block
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -364,6 +412,74 @@ class AIPSTNGateway:
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
||||||
|
async def _on_sip_incoming_call(
|
||||||
|
self, from_uri: str, to_uri: str, leg_id: str
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Called by SippyEngine when an inbound INVITE arrives.
|
||||||
|
|
||||||
|
Evaluates routing rules, then either:
|
||||||
|
- Rejects (rule says reject/DND)
|
||||||
|
- Answers + hands off to the AI Receptionist
|
||||||
|
"""
|
||||||
|
import uuid as _uuid
|
||||||
|
from models.call import CallMode, CallStatus
|
||||||
|
from models.routing import RoutingActionType
|
||||||
|
|
||||||
|
caller_number = _extract_number(from_uri)
|
||||||
|
dnis = _extract_number(to_uri)
|
||||||
|
|
||||||
|
# Create a call record so the dashboard sees the ringing call.
|
||||||
|
call = await self.call_manager.create_call(
|
||||||
|
remote_number=caller_number,
|
||||||
|
mode=CallMode.RECEPTIONIST,
|
||||||
|
intent=None,
|
||||||
|
call_flow_id=None,
|
||||||
|
device=None,
|
||||||
|
)
|
||||||
|
# Mark inbound
|
||||||
|
call.direction = "inbound"
|
||||||
|
self.call_manager.map_leg(leg_id, call.id)
|
||||||
|
await self.call_manager.update_status(call.id, CallStatus.RINGING)
|
||||||
|
|
||||||
|
decision = (
|
||||||
|
await self._routing.evaluate(caller_number, dnis)
|
||||||
|
if self._routing is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
if decision is not None:
|
||||||
|
await self.event_bus.publish(GatewayEvent(
|
||||||
|
type=EventType.ROUTING_RULE_MATCHED,
|
||||||
|
call_id=call.id,
|
||||||
|
data={
|
||||||
|
"matched_rule_id": decision.matched_rule_id,
|
||||||
|
"matched_rule_name": decision.matched_rule_name,
|
||||||
|
"action": decision.action.type.value,
|
||||||
|
"reason": decision.reason,
|
||||||
|
},
|
||||||
|
message=decision.reason,
|
||||||
|
))
|
||||||
|
|
||||||
|
if decision.action.type in (RoutingActionType.REJECT, RoutingActionType.DND):
|
||||||
|
if hasattr(self.sip_engine, "reject_inbound"):
|
||||||
|
await self.sip_engine.reject_inbound(leg_id)
|
||||||
|
await self.call_manager.end_call(call.id, CallStatus.COMPLETED)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Answer the leg
|
||||||
|
if hasattr(self.sip_engine, "accept_inbound"):
|
||||||
|
await self.sip_engine.accept_inbound(leg_id)
|
||||||
|
await self.call_manager.update_status(call.id, CallStatus.CONNECTED)
|
||||||
|
|
||||||
|
# Hand off to the AI Receptionist
|
||||||
|
if self._receptionist is not None and self.settings.receptionist.enabled:
|
||||||
|
import asyncio as _asyncio
|
||||||
|
_asyncio.create_task(
|
||||||
|
self._receptionist.handle(call, leg_id, decision),
|
||||||
|
name=f"receptionist_{call.id}",
|
||||||
|
)
|
||||||
|
|
||||||
def preferred_device(self) -> Optional[Device]:
|
def preferred_device(self) -> Optional[Device]:
|
||||||
"""Get the highest-priority online device."""
|
"""Get the highest-priority online device."""
|
||||||
online_devices = [
|
online_devices = [
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ class MediaStream:
|
|||||||
self.rtp_port: Optional[int] = None # Local RTP listen port
|
self.rtp_port: Optional[int] = None # Local RTP listen port
|
||||||
self.taps: list[AudioTap] = []
|
self.taps: list[AudioTap] = []
|
||||||
self.recorder = None # PJSUA2 AudioMediaRecorder
|
self.recorder = None # PJSUA2 AudioMediaRecorder
|
||||||
|
self.player = None # PJSUA2 AudioMediaPlayer (active playback)
|
||||||
|
self.play_lock = asyncio.Lock() # Serializes playback per stream
|
||||||
self.active = True
|
self.active = True
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
@@ -505,6 +507,72 @@ class MediaPipeline:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f" Tone generation error: {e}")
|
logger.error(f" Tone generation error: {e}")
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# WAV Playback (TTS prompts, SPEAK steps, receptionist greetings)
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
async def play_wav(self, stream_id: str, filepath: str) -> bool:
|
||||||
|
"""
|
||||||
|
Play a WAV file into the given stream, awaiting completion.
|
||||||
|
|
||||||
|
Playback is serialized per stream — if another playback is in
|
||||||
|
flight on the same stream this call waits for it to finish.
|
||||||
|
Falls back to a duration-based sleep when PJSUA2 is unavailable.
|
||||||
|
"""
|
||||||
|
stream = self._streams.get(stream_id)
|
||||||
|
if not stream:
|
||||||
|
logger.warning(f" Cannot play WAV: stream {stream_id} not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async with stream.play_lock:
|
||||||
|
duration_s = self._wav_duration_seconds(filepath)
|
||||||
|
|
||||||
|
if self._endpoint:
|
||||||
|
try:
|
||||||
|
import pjsua2 as pj
|
||||||
|
|
||||||
|
player = pj.AudioMediaPlayer()
|
||||||
|
# PJMEDIA_FILE_NO_LOOP == 1
|
||||||
|
player.createPlayer(filepath, 1)
|
||||||
|
stream.player = player
|
||||||
|
|
||||||
|
# In a full PJSUA2 integration:
|
||||||
|
# player.getAudioMedia().startTransmit(stream.audio_media)
|
||||||
|
# We don't hold the AudioMedia ref here in stub mode.
|
||||||
|
logger.info(
|
||||||
|
f" 🔊 Playing {filepath} on {stream_id} ({duration_s:.1f}s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
await asyncio.sleep(duration_s)
|
||||||
|
stream.player = None
|
||||||
|
return True
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.debug(f" PJSUA2 not available, virtual playback of {filepath}")
|
||||||
|
await asyncio.sleep(duration_s)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f" Failed to play {filepath} on {stream_id}: {e}")
|
||||||
|
stream.player = None
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
logger.info(f" 🔊 Playing {filepath} on {stream_id} (virtual, {duration_s:.1f}s)")
|
||||||
|
await asyncio.sleep(duration_s)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _wav_duration_seconds(filepath: str) -> float:
|
||||||
|
"""Read WAV header to compute playback duration. Defaults to 2s on error."""
|
||||||
|
try:
|
||||||
|
import wave
|
||||||
|
|
||||||
|
with wave.open(filepath, "rb") as wf:
|
||||||
|
frames = wf.getnframes()
|
||||||
|
rate = wf.getframerate() or 16000
|
||||||
|
return frames / float(rate) if frames else 2.0
|
||||||
|
except Exception:
|
||||||
|
return 2.0
|
||||||
|
|
||||||
# ================================================================
|
# ================================================================
|
||||||
# Status
|
# Status
|
||||||
# ================================================================
|
# ================================================================
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ class SippyEngine(SIPEngine):
|
|||||||
media_pipeline=None, # MediaPipeline instance
|
media_pipeline=None, # MediaPipeline instance
|
||||||
on_leg_state_change: Optional[Callable] = None,
|
on_leg_state_change: Optional[Callable] = None,
|
||||||
on_device_registered: Optional[Callable] = None,
|
on_device_registered: Optional[Callable] = None,
|
||||||
|
on_incoming_call: Optional[Callable] = None,
|
||||||
):
|
):
|
||||||
# SIP config
|
# SIP config
|
||||||
self._sip_address = sip_address
|
self._sip_address = sip_address
|
||||||
@@ -186,6 +187,7 @@ class SippyEngine(SIPEngine):
|
|||||||
# Callbacks for async state changes
|
# Callbacks for async state changes
|
||||||
self._on_leg_state_change = on_leg_state_change
|
self._on_leg_state_change = on_leg_state_change
|
||||||
self._on_device_registered = on_device_registered
|
self._on_device_registered = on_device_registered
|
||||||
|
self._on_incoming_call = on_incoming_call
|
||||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
|
||||||
# State
|
# State
|
||||||
@@ -355,21 +357,54 @@ class SippyEngine(SIPEngine):
|
|||||||
await self._on_device_registered(aor, contact, expires)
|
await self._on_device_registered(aor, contact, expires)
|
||||||
|
|
||||||
def _handle_incoming_invite(self, req, sip_t):
|
def _handle_incoming_invite(self, req, sip_t):
|
||||||
"""Handle an incoming INVITE — create inbound call leg."""
|
"""Handle an incoming INVITE — create inbound call leg.
|
||||||
|
|
||||||
|
The gateway is notified via `on_incoming_call`; it decides
|
||||||
|
whether to answer (via `accept_inbound`) or reject the leg
|
||||||
|
based on routing rules.
|
||||||
|
"""
|
||||||
from_uri = str(req.getHFBody("from").getUri())
|
from_uri = str(req.getHFBody("from").getUri())
|
||||||
to_uri = str(req.getHFBody("to").getUri())
|
to_uri = str(req.getHFBody("to").getUri())
|
||||||
|
|
||||||
leg_id = f"leg_{uuid.uuid4().hex[:12]}"
|
leg_id = f"leg_{uuid.uuid4().hex[:12]}"
|
||||||
leg = SipCallLeg(leg_id, "inbound", from_uri)
|
leg = SipCallLeg(leg_id, "inbound", from_uri)
|
||||||
leg.sippy_ua = sip_t.ua if hasattr(sip_t, "ua") else None
|
leg.sippy_ua = sip_t.ua if hasattr(sip_t, "ua") else None
|
||||||
|
leg.pending_invite = req
|
||||||
self._legs[leg_id] = leg
|
self._legs[leg_id] = leg
|
||||||
|
|
||||||
logger.info(f" Incoming call: {from_uri} → {to_uri} (leg: {leg_id})")
|
logger.info(f" Incoming call: {from_uri} → {to_uri} (leg: {leg_id})")
|
||||||
|
|
||||||
# Auto-answer for now (gateway always answers)
|
# Surface to the gateway. If no callback is wired, fall back to
|
||||||
# In production, this would check routing rules
|
# auto-answer so we don't regress the previous behavior.
|
||||||
|
if self._on_incoming_call and self._loop:
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self._on_incoming_call(from_uri, to_uri, leg_id),
|
||||||
|
self._loop,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
controller = SippyCallController(leg, self)
|
||||||
|
controller.on_connected(str(req.getBody()) if req.getBody() else None)
|
||||||
|
|
||||||
|
async def accept_inbound(self, leg_id: str) -> bool:
|
||||||
|
"""Answer a previously-surfaced inbound INVITE."""
|
||||||
|
leg = self._legs.get(leg_id)
|
||||||
|
if not leg or leg.direction != "inbound":
|
||||||
|
return False
|
||||||
|
req = getattr(leg, "pending_invite", None)
|
||||||
controller = SippyCallController(leg, self)
|
controller = SippyCallController(leg, self)
|
||||||
controller.on_connected(str(req.getBody()) if req.getBody() else None)
|
body = str(req.getBody()) if req and req.getBody() else None
|
||||||
|
controller.on_connected(body)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def reject_inbound(self, leg_id: str, code: int = 603, reason: str = "Decline") -> bool:
|
||||||
|
"""Reject a previously-surfaced inbound INVITE with a SIP error."""
|
||||||
|
leg = self._legs.pop(leg_id, None)
|
||||||
|
if not leg or leg.direction != "inbound":
|
||||||
|
return False
|
||||||
|
logger.info(f" ⛔ Rejecting inbound leg {leg_id}: {code} {reason}")
|
||||||
|
# Real SIP rejection would go through Sippy here; we just drop the leg
|
||||||
|
# in stub mode so callers see the call terminate.
|
||||||
|
return True
|
||||||
|
|
||||||
def _handle_incoming_bye(self, req, sip_t):
|
def _handle_incoming_bye(self, req, sip_t):
|
||||||
"""Handle incoming BYE — tear down call leg."""
|
"""Handle incoming BYE — tear down call leg."""
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import type { CallSummary, DeviceStatus, GatewayEvent, GatewayStatus, HealthStatus } from './types';
|
import type {
|
||||||
|
CallHistoryRow,
|
||||||
|
CallSummary,
|
||||||
|
DeviceStatus,
|
||||||
|
GatewayEvent,
|
||||||
|
GatewayStatus,
|
||||||
|
HealthStatus,
|
||||||
|
RoutingRule,
|
||||||
|
TranscriptRow,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
async function get<T>(path: string): Promise<T> {
|
async function get<T>(path: string): Promise<T> {
|
||||||
const res = await fetch(path);
|
const res = await fetch(path);
|
||||||
@@ -27,6 +36,67 @@ export async function hangupCall(callId: string): Promise<void> {
|
|||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchCallHistory(
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
): Promise<CallHistoryRow[]> {
|
||||||
|
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||||
|
return get<CallHistoryRow[]>(`/api/calls/history?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCallRecord(callId: string): Promise<CallHistoryRow> {
|
||||||
|
return get<CallHistoryRow>(`/api/calls/${callId}/record`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchTranscript(callId: string): Promise<TranscriptRow[]> {
|
||||||
|
return get<TranscriptRow[]>(`/api/calls/${callId}/transcript`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordingUrl(callId: string): string {
|
||||||
|
return `/api/calls/${callId}/recording`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRoutingRules(): Promise<RoutingRule[]> {
|
||||||
|
return get<RoutingRule[]>('/api/routing/rules');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRoutingRule(rule: Partial<RoutingRule>): Promise<RoutingRule> {
|
||||||
|
const res = await fetch('/api/routing/rules', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(rule),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
return res.json() as Promise<RoutingRule>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRoutingRule(
|
||||||
|
ruleId: string,
|
||||||
|
patch: Partial<RoutingRule>,
|
||||||
|
): Promise<RoutingRule> {
|
||||||
|
const res = await fetch(`/api/routing/rules/${ruleId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
return res.json() as Promise<RoutingRule>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRoutingRule(ruleId: string): Promise<void> {
|
||||||
|
const res = await fetch(`/api/routing/rules/${ruleId}`, { method: 'DELETE' });
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDeviceDnd(deviceId: string, enabled: boolean): Promise<void> {
|
||||||
|
const res = await fetch(`/api/routing/devices/${deviceId}/dnd`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
export function connectEventStream(
|
export function connectEventStream(
|
||||||
onEvent: (e: GatewayEvent) => void,
|
onEvent: (e: GatewayEvent) => void,
|
||||||
onClose: () => void,
|
onClose: () => void,
|
||||||
|
|||||||
@@ -72,3 +72,65 @@ export interface GatewayEvent {
|
|||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CallHistoryRow {
|
||||||
|
id: string;
|
||||||
|
direction: string;
|
||||||
|
remote_number: string;
|
||||||
|
status: string;
|
||||||
|
mode: string;
|
||||||
|
intent?: string;
|
||||||
|
started_at?: string;
|
||||||
|
ended_at?: string;
|
||||||
|
duration: number;
|
||||||
|
hold_time: number;
|
||||||
|
device_used?: string;
|
||||||
|
summary?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptRow {
|
||||||
|
seq: number;
|
||||||
|
t_offset_ms: number;
|
||||||
|
speaker: string;
|
||||||
|
text: string;
|
||||||
|
confidence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoutingActionType =
|
||||||
|
| 'ring_device'
|
||||||
|
| 'ring_chain'
|
||||||
|
| 'take_message'
|
||||||
|
| 'reject'
|
||||||
|
| 'dnd';
|
||||||
|
|
||||||
|
export interface TimeRange {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
tz: string;
|
||||||
|
days: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingMatch {
|
||||||
|
caller_pattern?: string | null;
|
||||||
|
dnis?: string | null;
|
||||||
|
time_range?: TimeRange | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingAction {
|
||||||
|
type: RoutingActionType;
|
||||||
|
device_id?: string | null;
|
||||||
|
device_ids: string[];
|
||||||
|
ring_timeout: number;
|
||||||
|
message?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutingRule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
priority: number;
|
||||||
|
enabled: boolean;
|
||||||
|
match: RoutingMatch;
|
||||||
|
action: RoutingAction;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,7 +42,11 @@
|
|||||||
return () => mq.removeEventListener('change', onSystemChange);
|
return () => mq.removeEventListener('change', onSystemChange);
|
||||||
});
|
});
|
||||||
|
|
||||||
const nav = [{ href: '/', label: 'Dashboard' }];
|
const nav = [
|
||||||
|
{ href: '/', label: 'Dashboard' },
|
||||||
|
{ href: '/history', label: 'History' },
|
||||||
|
{ href: '/routing', label: 'Routing' },
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
|
<div class="min-h-screen bg-slate-50 dark:bg-gray-950 text-gray-900 dark:text-gray-100">
|
||||||
|
|||||||
80
dashboard/src/routes/calls/[call_id]/+page.svelte
Normal file
80
dashboard/src/routes/calls/[call_id]/+page.svelte
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { fetchCallRecord, fetchTranscript, recordingUrl } from '$lib/api';
|
||||||
|
import type { CallHistoryRow, TranscriptRow } from '$lib/types';
|
||||||
|
|
||||||
|
let record = $state<CallHistoryRow | null>(null);
|
||||||
|
let transcript = $state<TranscriptRow[]>([]);
|
||||||
|
let audioEl: HTMLAudioElement | null = null;
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
const callId = $derived($page.params.call_id);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
record = await fetchCallRecord(callId);
|
||||||
|
transcript = await fetchTranscript(callId);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function seekTo(ms: number) {
|
||||||
|
if (audioEl) {
|
||||||
|
audioEl.currentTime = ms / 1000;
|
||||||
|
audioEl.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<a href="/history" class="text-sm text-orange-600 hover:underline">← Back to history</a>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-sm text-gray-500 mt-4">Loading…</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="text-sm text-red-500 mt-4">Failed: {error}</div>
|
||||||
|
{:else if record}
|
||||||
|
<header class="mt-4 mb-4">
|
||||||
|
<h1 class="text-xl font-semibold font-mono">{record.remote_number}</h1>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
{record.direction} · {record.mode} · {record.status} ·
|
||||||
|
{record.duration}s
|
||||||
|
{#if record.intent}<span> · {record.intent}</span>{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="mb-6">
|
||||||
|
<audio
|
||||||
|
bind:this={audioEl}
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
class="w-full"
|
||||||
|
src={recordingUrl(callId)}
|
||||||
|
></audio>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="text-sm uppercase text-gray-500 mb-2">Transcript</h2>
|
||||||
|
{#if transcript.length === 0}
|
||||||
|
<div class="text-sm text-gray-500">No transcript stored.</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{#each transcript as chunk}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class="text-left w-full px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
onclick={() => seekTo(chunk.t_offset_ms)}
|
||||||
|
>
|
||||||
|
<span class="text-xs uppercase text-gray-500 mr-2">{chunk.speaker}</span>
|
||||||
|
<span>{chunk.text}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
76
dashboard/src/routes/history/+page.svelte
Normal file
76
dashboard/src/routes/history/+page.svelte
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { fetchCallHistory } from '$lib/api';
|
||||||
|
import type { CallHistoryRow } from '$lib/types';
|
||||||
|
|
||||||
|
let rows = $state<CallHistoryRow[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
function fmtDuration(s: number): string {
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso?: string): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
rows = await fetchCallHistory(100, 0);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1 class="text-xl font-semibold mb-4">Call History</h1>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-sm text-gray-500">Loading…</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="text-sm text-red-500">Failed to load history: {error}</div>
|
||||||
|
{:else if rows.length === 0}
|
||||||
|
<div class="text-sm text-gray-500">No calls yet.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto rounded border border-gray-200 dark:border-gray-800">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-900 text-left text-xs uppercase text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2">When</th>
|
||||||
|
<th class="px-3 py-2">Number</th>
|
||||||
|
<th class="px-3 py-2">Dir</th>
|
||||||
|
<th class="px-3 py-2">Mode</th>
|
||||||
|
<th class="px-3 py-2">Status</th>
|
||||||
|
<th class="px-3 py-2">Duration</th>
|
||||||
|
<th class="px-3 py-2">Hold</th>
|
||||||
|
<th class="px-3 py-2">Intent</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rows as row}
|
||||||
|
<tr class="border-t border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900">
|
||||||
|
<td class="px-3 py-2">
|
||||||
|
<a class="text-orange-600 hover:underline" href={`/calls/${row.id}`}>
|
||||||
|
{fmtDate(row.started_at)}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 font-mono">{row.remote_number}</td>
|
||||||
|
<td class="px-3 py-2">{row.direction}</td>
|
||||||
|
<td class="px-3 py-2">{row.mode}</td>
|
||||||
|
<td class="px-3 py-2">{row.status}</td>
|
||||||
|
<td class="px-3 py-2">{fmtDuration(row.duration)}</td>
|
||||||
|
<td class="px-3 py-2">{fmtDuration(row.hold_time)}</td>
|
||||||
|
<td class="px-3 py-2 max-w-xs truncate">{row.intent ?? ''}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
219
dashboard/src/routes/routing/+page.svelte
Normal file
219
dashboard/src/routes/routing/+page.svelte
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import {
|
||||||
|
createRoutingRule,
|
||||||
|
deleteRoutingRule,
|
||||||
|
fetchDevices,
|
||||||
|
fetchRoutingRules,
|
||||||
|
setDeviceDnd,
|
||||||
|
updateRoutingRule,
|
||||||
|
} from '$lib/api';
|
||||||
|
import type { DeviceStatus, RoutingAction, RoutingActionType, RoutingRule } from '$lib/types';
|
||||||
|
|
||||||
|
let rules = $state<RoutingRule[]>([]);
|
||||||
|
let devices = $state<DeviceStatus[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
let draft = $state({
|
||||||
|
name: '',
|
||||||
|
priority: 100,
|
||||||
|
caller_pattern: '',
|
||||||
|
dnis: '',
|
||||||
|
action_type: 'ring_chain' as RoutingActionType,
|
||||||
|
device_id: '',
|
||||||
|
ring_timeout: 25,
|
||||||
|
message: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionTypes: RoutingActionType[] = [
|
||||||
|
'ring_device',
|
||||||
|
'ring_chain',
|
||||||
|
'take_message',
|
||||||
|
'reject',
|
||||||
|
'dnd',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
[rules, devices] = await Promise.all([fetchRoutingRules(), fetchDevices()]);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(refresh);
|
||||||
|
|
||||||
|
async function addRule() {
|
||||||
|
if (!draft.name.trim()) return;
|
||||||
|
const action: RoutingAction = {
|
||||||
|
type: draft.action_type,
|
||||||
|
device_id: draft.action_type === 'ring_device' ? draft.device_id || null : null,
|
||||||
|
device_ids: [],
|
||||||
|
ring_timeout: draft.ring_timeout,
|
||||||
|
message: draft.message || null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await createRoutingRule({
|
||||||
|
name: draft.name,
|
||||||
|
priority: draft.priority,
|
||||||
|
enabled: true,
|
||||||
|
match: {
|
||||||
|
caller_pattern: draft.caller_pattern || null,
|
||||||
|
dnis: draft.dnis || null,
|
||||||
|
time_range: null,
|
||||||
|
},
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
draft = {
|
||||||
|
name: '',
|
||||||
|
priority: 100,
|
||||||
|
caller_pattern: '',
|
||||||
|
dnis: '',
|
||||||
|
action_type: 'ring_chain',
|
||||||
|
device_id: '',
|
||||||
|
ring_timeout: 25,
|
||||||
|
message: '',
|
||||||
|
};
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEnabled(rule: RoutingRule) {
|
||||||
|
await updateRoutingRule(rule.id, { enabled: !rule.enabled });
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRule(rule: RoutingRule) {
|
||||||
|
if (!confirm(`Delete rule "${rule.name}"?`)) return;
|
||||||
|
await deleteRoutingRule(rule.id);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDnd(device: DeviceStatus, enabled: boolean) {
|
||||||
|
await setDeviceDnd(device.id, enabled);
|
||||||
|
await refresh();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h1 class="text-xl font-semibold mb-4">Routing Rules</h1>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="text-sm text-red-500 mb-4">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="text-sm uppercase text-gray-500 mb-2">Devices · DND</h2>
|
||||||
|
{#if devices.length === 0}
|
||||||
|
<div class="text-sm text-gray-500">No devices registered.</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{#each devices as d}
|
||||||
|
<li class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="font-mono">{d.name}</span>
|
||||||
|
<span class="text-gray-500">({d.type})</span>
|
||||||
|
<label class="ml-auto flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
onchange={(e) => toggleDnd(d, (e.target as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
<span>DND</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="text-sm uppercase text-gray-500 mb-2">Add Rule</h2>
|
||||||
|
<div class="grid grid-cols-2 gap-2 max-w-2xl">
|
||||||
|
<input class="px-2 py-1 border rounded" placeholder="Name" bind:value={draft.name} />
|
||||||
|
<input
|
||||||
|
class="px-2 py-1 border rounded"
|
||||||
|
type="number"
|
||||||
|
placeholder="Priority"
|
||||||
|
bind:value={draft.priority}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="px-2 py-1 border rounded"
|
||||||
|
placeholder="Caller pattern (e.g. +1800*)"
|
||||||
|
bind:value={draft.caller_pattern}
|
||||||
|
/>
|
||||||
|
<input class="px-2 py-1 border rounded" placeholder="DNIS" bind:value={draft.dnis} />
|
||||||
|
<select class="px-2 py-1 border rounded" bind:value={draft.action_type}>
|
||||||
|
{#each actionTypes as t}
|
||||||
|
<option value={t}>{t}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{#if draft.action_type === 'ring_device'}
|
||||||
|
<select class="px-2 py-1 border rounded" bind:value={draft.device_id}>
|
||||||
|
<option value="">— device —</option>
|
||||||
|
{#each devices as d}
|
||||||
|
<option value={d.id}>{d.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
class="px-2 py-1 border rounded"
|
||||||
|
type="number"
|
||||||
|
placeholder="Ring timeout"
|
||||||
|
bind:value={draft.ring_timeout}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<input
|
||||||
|
class="px-2 py-1 border rounded col-span-2"
|
||||||
|
placeholder="Optional message (for reject)"
|
||||||
|
bind:value={draft.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button class="mt-2 px-3 py-1.5 rounded bg-orange-600 text-white" onclick={addRule}>
|
||||||
|
Add rule
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="text-sm uppercase text-gray-500 mb-2">Rules</h2>
|
||||||
|
{#if loading}
|
||||||
|
<div class="text-sm text-gray-500">Loading…</div>
|
||||||
|
{:else if rules.length === 0}
|
||||||
|
<div class="text-sm text-gray-500">No rules yet.</div>
|
||||||
|
{:else}
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="text-left text-xs uppercase text-gray-500">
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 py-1">Pri</th>
|
||||||
|
<th class="px-2 py-1">Name</th>
|
||||||
|
<th class="px-2 py-1">Caller</th>
|
||||||
|
<th class="px-2 py-1">DNIS</th>
|
||||||
|
<th class="px-2 py-1">Action</th>
|
||||||
|
<th class="px-2 py-1">Enabled</th>
|
||||||
|
<th class="px-2 py-1"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each rules as rule}
|
||||||
|
<tr class="border-t border-gray-100 dark:border-gray-800">
|
||||||
|
<td class="px-2 py-1 font-mono">{rule.priority}</td>
|
||||||
|
<td class="px-2 py-1">{rule.name}</td>
|
||||||
|
<td class="px-2 py-1 font-mono">{rule.match.caller_pattern ?? ''}</td>
|
||||||
|
<td class="px-2 py-1 font-mono">{rule.match.dnis ?? ''}</td>
|
||||||
|
<td class="px-2 py-1">{rule.action.type}</td>
|
||||||
|
<td class="px-2 py-1">
|
||||||
|
<button onclick={() => toggleEnabled(rule)}>
|
||||||
|
{rule.enabled ? '✓' : '✗'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1">
|
||||||
|
<button class="text-red-500" onclick={() => removeRule(rule)}>delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -8,6 +8,7 @@ from datetime import datetime
|
|||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
JSON,
|
JSON,
|
||||||
|
Boolean,
|
||||||
Column,
|
Column,
|
||||||
DateTime,
|
DateTime,
|
||||||
Float,
|
Float,
|
||||||
@@ -111,6 +112,7 @@ class Device(Base):
|
|||||||
priority = Column(Integer, default=10) # Routing priority (lower = higher priority)
|
priority = Column(Integer, default=10) # Routing priority (lower = higher priority)
|
||||||
is_online = Column(String, default="false")
|
is_online = Column(String, default="false")
|
||||||
capabilities = Column(JSON, default=list) # ["voice", "video", "sms"]
|
capabilities = Column(JSON, default=list) # ["voice", "video", "sms"]
|
||||||
|
dnd = Column(Boolean, default=False, nullable=False)
|
||||||
last_seen = Column(DateTime, nullable=True)
|
last_seen = Column(DateTime, nullable=True)
|
||||||
created_at = Column(DateTime, default=func.now())
|
created_at = Column(DateTime, default=func.now())
|
||||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
@@ -119,6 +121,55 @@ class Device(Base):
|
|||||||
return f"<Device {self.id} {self.name} ({self.type})>"
|
return f"<Device {self.id} {self.name} ({self.type})>"
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingRuleRecord(Base):
|
||||||
|
__tablename__ = "routing_rules"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
priority = Column(Integer, default=100, nullable=False) # lower runs first
|
||||||
|
enabled = Column(Boolean, default=True, nullable=False)
|
||||||
|
match = Column(JSON, nullable=False) # caller_pattern, dnis, time_range, days
|
||||||
|
action = Column(JSON, nullable=False) # {type, ...}
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<RoutingRule {self.id} {self.name} p={self.priority}>"
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptChunk(Base):
|
||||||
|
__tablename__ = "transcript_chunks"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True)
|
||||||
|
call_id = Column(String, index=True, nullable=False)
|
||||||
|
seq = Column(Integer, nullable=False)
|
||||||
|
t_offset_ms = Column(Integer, default=0) # offset from call start
|
||||||
|
speaker = Column(String, default="unknown") # caller / agent / receptionist / unknown
|
||||||
|
text = Column(Text, nullable=False)
|
||||||
|
confidence = Column(Float, nullable=True)
|
||||||
|
created_at = Column(DateTime, default=func.now())
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<TranscriptChunk {self.call_id}#{self.seq}>"
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingRecord(Base):
|
||||||
|
__tablename__ = "recordings"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True)
|
||||||
|
call_id = Column(String, index=True, nullable=False)
|
||||||
|
path = Column(String, nullable=False)
|
||||||
|
format = Column(String, default="wav")
|
||||||
|
duration_s = Column(Float, default=0.0)
|
||||||
|
size_bytes = Column(Integer, default=0)
|
||||||
|
channels = Column(Integer, default=1)
|
||||||
|
started_at = Column(DateTime, default=func.now())
|
||||||
|
ended_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Recording {self.id} call={self.call_id} {self.path}>"
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Engine & Session
|
# Engine & Session
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
15
main.py
15
main.py
@@ -18,7 +18,7 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from api import calls, call_flows, devices, websocket
|
from api import call_flows, call_history, calls, devices, routing, websocket
|
||||||
from config import get_settings
|
from config import get_settings
|
||||||
from core.gateway import AIPSTNGateway
|
from core.gateway import AIPSTNGateway
|
||||||
from db.database import close_db, init_db
|
from db.database import close_db, init_db
|
||||||
@@ -114,6 +114,7 @@ async def lifespan(app: FastAPI):
|
|||||||
recording_svc = RecordingService()
|
recording_svc = RecordingService()
|
||||||
await recording_svc.start()
|
await recording_svc.start()
|
||||||
app.state.recording_service = recording_svc
|
app.state.recording_service = recording_svc
|
||||||
|
gateway._recording_service = recording_svc
|
||||||
|
|
||||||
analytics_svc = CallAnalytics()
|
analytics_svc = CallAnalytics()
|
||||||
app.state.analytics_service = analytics_svc
|
app.state.analytics_service = analytics_svc
|
||||||
@@ -171,10 +172,22 @@ app = FastAPI(
|
|||||||
|
|
||||||
# === API Routes ===
|
# === API Routes ===
|
||||||
app.include_router(calls.router, prefix="/api/calls", tags=["Calls"])
|
app.include_router(calls.router, prefix="/api/calls", tags=["Calls"])
|
||||||
|
app.include_router(call_history.router, prefix="/api/calls", tags=["Call History"])
|
||||||
app.include_router(call_flows.router, prefix="/api/call-flows", tags=["Call Flows"])
|
app.include_router(call_flows.router, prefix="/api/call-flows", tags=["Call Flows"])
|
||||||
app.include_router(devices.router, prefix="/api/devices", tags=["Devices"])
|
app.include_router(devices.router, prefix="/api/devices", tags=["Devices"])
|
||||||
|
app.include_router(routing.router, prefix="/api/routing", tags=["Routing"])
|
||||||
app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"])
|
app.include_router(websocket.router, prefix="/ws", tags=["WebSocket"])
|
||||||
|
|
||||||
|
# === Dashboard (built SvelteKit static) ===
|
||||||
|
import os as _os
|
||||||
|
_dashboard_build = _os.path.join(_os.path.dirname(__file__), "dashboard", "build")
|
||||||
|
if _os.path.isdir(_dashboard_build):
|
||||||
|
app.mount(
|
||||||
|
"/dashboard",
|
||||||
|
StaticFiles(directory=_dashboard_build, html=True),
|
||||||
|
name="dashboard",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# === Root Endpoint ===
|
# === Root Endpoint ===
|
||||||
@app.get("/", tags=["System"])
|
@app.get("/", tags=["System"])
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class CallMode(str, Enum):
|
|||||||
DIRECT = "direct" # Call and connect immediately
|
DIRECT = "direct" # Call and connect immediately
|
||||||
HOLD_SLAYER = "hold_slayer" # Navigate IVR, wait on hold, transfer when human
|
HOLD_SLAYER = "hold_slayer" # Navigate IVR, wait on hold, transfer when human
|
||||||
AI_ASSISTED = "ai_assisted" # Connect with transcription, recording, noise cancel
|
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):
|
class AudioClassification(str, Enum):
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class Device(DeviceBase):
|
|||||||
|
|
||||||
id: str
|
id: str
|
||||||
is_online: bool = False
|
is_online: bool = False
|
||||||
|
dnd: bool = False
|
||||||
last_seen: Optional[datetime] = None
|
last_seen: Optional[datetime] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
@@ -45,6 +46,8 @@ class Device(DeviceBase):
|
|||||||
@property
|
@property
|
||||||
def can_receive_call(self) -> bool:
|
def can_receive_call(self) -> bool:
|
||||||
"""Can this device receive a call right now?"""
|
"""Can this device receive a call right now?"""
|
||||||
|
if self.dnd:
|
||||||
|
return False
|
||||||
if self.type in (DeviceType.SIP_PHONE, DeviceType.SOFTPHONE, DeviceType.WEBRTC):
|
if self.type in (DeviceType.SIP_PHONE, DeviceType.SOFTPHONE, DeviceType.WEBRTC):
|
||||||
return self.is_online and self.sip_uri is not None
|
return self.is_online and self.sip_uri is not None
|
||||||
if self.type == DeviceType.CELL:
|
if self.type == DeviceType.CELL:
|
||||||
|
|||||||
@@ -32,6 +32,19 @@ class EventType(str, Enum):
|
|||||||
# Audio
|
# Audio
|
||||||
AUDIO_CLASSIFIED = "audio.classified"
|
AUDIO_CLASSIFIED = "audio.classified"
|
||||||
TRANSCRIPT_CHUNK = "audio.transcript_chunk"
|
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
|
||||||
DEVICE_REGISTERED = "device.registered"
|
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 = ""
|
||||||
70
services/call_persistence.py
Normal file
70
services/call_persistence.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
Call Persistence — Writes completed calls and their transcript chunks
|
||||||
|
to the database when CallManager.end_call() fires.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from db.database import CallRecord, TranscriptChunk, get_session_factory
|
||||||
|
from models.call import ActiveCall, CallStatus
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def persist_call_on_end(call: ActiveCall, final_status: CallStatus) -> None:
|
||||||
|
"""Insert a CallRecord and any transcript chunks for `call`.
|
||||||
|
|
||||||
|
Wired into CallManager via _on_call_ended in gateway.start().
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with get_session_factory()() as session:
|
||||||
|
record = CallRecord(
|
||||||
|
id=call.id,
|
||||||
|
direction=call.direction,
|
||||||
|
remote_number=call.remote_number,
|
||||||
|
status=final_status.value,
|
||||||
|
mode=call.mode.value,
|
||||||
|
intent=call.intent,
|
||||||
|
started_at=call.started_at,
|
||||||
|
ended_at=datetime.now(),
|
||||||
|
duration=int(call.duration),
|
||||||
|
hold_time=int(call.hold_time),
|
||||||
|
device_used=call.device,
|
||||||
|
call_flow_id=call.call_flow_id,
|
||||||
|
classification_timeline=[
|
||||||
|
{
|
||||||
|
"timestamp": c.timestamp,
|
||||||
|
"audio_type": c.audio_type.value,
|
||||||
|
"confidence": c.confidence,
|
||||||
|
}
|
||||||
|
for c in call.classification_history
|
||||||
|
],
|
||||||
|
metadata_={"services": list(call.services)},
|
||||||
|
)
|
||||||
|
session.add(record)
|
||||||
|
|
||||||
|
# Each transcript chunk gets its own row with a sequence number
|
||||||
|
# so the dashboard can render them in order with click-to-seek.
|
||||||
|
for seq, text in enumerate(call.transcript_chunks):
|
||||||
|
speaker = "unknown"
|
||||||
|
payload = text
|
||||||
|
if ":" in text:
|
||||||
|
head, rest = text.split(":", 1)
|
||||||
|
head = head.strip().lower()
|
||||||
|
if head in {"caller", "agent", "receptionist", "caller_message"}:
|
||||||
|
speaker = head if head != "caller_message" else "caller"
|
||||||
|
payload = rest.strip()
|
||||||
|
session.add(TranscriptChunk(
|
||||||
|
id=f"tc_{uuid.uuid4().hex[:10]}",
|
||||||
|
call_id=call.id,
|
||||||
|
seq=seq,
|
||||||
|
t_offset_ms=0,
|
||||||
|
speaker=speaker,
|
||||||
|
text=payload,
|
||||||
|
))
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not persist call {call.id}: {e}")
|
||||||
@@ -24,6 +24,7 @@ from models.call_flow import ActionType, CallFlow, CallFlowStep
|
|||||||
from models.events import EventType, GatewayEvent
|
from models.events import EventType, GatewayEvent
|
||||||
from services.audio_classifier import AudioClassifier
|
from services.audio_classifier import AudioClassifier
|
||||||
from services.transcription import TranscriptionService
|
from services.transcription import TranscriptionService
|
||||||
|
from services.tts import TTSService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ class HoldSlayerService:
|
|||||||
classifier: AudioClassifier,
|
classifier: AudioClassifier,
|
||||||
transcription: TranscriptionService,
|
transcription: TranscriptionService,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
tts: Optional[TTSService] = None,
|
||||||
):
|
):
|
||||||
self.gateway = gateway
|
self.gateway = gateway
|
||||||
self.call_manager = call_manager
|
self.call_manager = call_manager
|
||||||
@@ -75,6 +77,7 @@ class HoldSlayerService:
|
|||||||
self.classifier = classifier
|
self.classifier = classifier
|
||||||
self.transcription = transcription
|
self.transcription = transcription
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
|
self.tts = tts
|
||||||
|
|
||||||
async def run(
|
async def run(
|
||||||
self,
|
self,
|
||||||
@@ -257,10 +260,7 @@ class HoldSlayerService:
|
|||||||
current_step_id = step.next_step
|
current_step_id = step.next_step
|
||||||
|
|
||||||
elif step.action == ActionType.SPEAK:
|
elif step.action == ActionType.SPEAK:
|
||||||
# Say something into the call (TTS)
|
await self._speak(call, sip_leg_id, step.action_value or "")
|
||||||
# TODO: Implement TTS integration
|
|
||||||
logger.info(f"🗣️ Would say: '{step.action_value}' (TTS not yet implemented)")
|
|
||||||
await asyncio.sleep(3.0)
|
|
||||||
current_step_id = step.next_step
|
current_step_id = step.next_step
|
||||||
|
|
||||||
elif step.action == ActionType.TRANSFER:
|
elif step.action == ActionType.TRANSFER:
|
||||||
@@ -715,3 +715,53 @@ class HoldSlayerService:
|
|||||||
logger.error(f"Failed to load call flow '{flow_id}': {e}")
|
logger.error(f"Failed to load call flow '{flow_id}': {e}")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
async def _speak(self, call: ActiveCall, sip_leg_id: str, text: str) -> bool:
|
||||||
|
"""
|
||||||
|
Synthesize `text` via TTS and play it into the call leg.
|
||||||
|
|
||||||
|
Falls back to a brief sleep if TTS is unavailable so a SPEAK step
|
||||||
|
doesn't block the flow indefinitely.
|
||||||
|
"""
|
||||||
|
if not text.strip():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.tts or not getattr(self.gateway, "media_pipeline", None):
|
||||||
|
logger.warning(f"🗣️ TTS unavailable, skipping SPEAK: '{text[:60]}'")
|
||||||
|
await asyncio.sleep(2.0)
|
||||||
|
return False
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
fd, tmp_path = tempfile.mkstemp(suffix=".wav", prefix=f"speak_{call.id}_")
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ok = await self.tts.synthesize_to_file(text, tmp_path)
|
||||||
|
if not ok:
|
||||||
|
logger.warning(f"🗣️ TTS synthesis returned no audio for: '{text[:60]}'")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"🗣️ Speaking: '{text[:80]}'")
|
||||||
|
await self.gateway.media_pipeline.play_wav(sip_leg_id, tmp_path)
|
||||||
|
|
||||||
|
# Publish event so the dashboard/transcript shows what we said.
|
||||||
|
try:
|
||||||
|
await self.gateway.event_bus.publish(
|
||||||
|
GatewayEvent(
|
||||||
|
type=EventType.SPEAK_PLAYED,
|
||||||
|
call_id=call.id,
|
||||||
|
data={"text": text},
|
||||||
|
message=f"Played TTS: {text[:80]}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|||||||
345
services/receptionist.py
Normal file
345
services/receptionist.py
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
"""
|
||||||
|
AI Receptionist — Screens inbound calls, then routes or takes a message.
|
||||||
|
|
||||||
|
State machine:
|
||||||
|
GREET → TTS greeting plays into the call leg
|
||||||
|
LISTEN → buffer audio from the leg's tap until end-of-utterance
|
||||||
|
CLASSIFY → LLM extracts intent, urgency, recommended action
|
||||||
|
DECIDE → combine LLM recommendation with the routing decision
|
||||||
|
(rules win on conflict)
|
||||||
|
RING → ring_chain devices; bridge on pickup
|
||||||
|
RECORD → TTS prompt + WAV record up to message_max_seconds; transcribe
|
||||||
|
and notify
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time as _time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from models.call import ActiveCall, CallStatus
|
||||||
|
from models.events import EventType, GatewayEvent
|
||||||
|
from models.routing import RoutingAction, RoutingActionType, RoutingDecision
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ReceptionistService:
|
||||||
|
"""Drives the receptionist state machine for a single inbound call."""
|
||||||
|
|
||||||
|
def __init__(self, gateway):
|
||||||
|
self.gateway = gateway
|
||||||
|
self.settings = gateway.settings.receptionist
|
||||||
|
|
||||||
|
async def handle(
|
||||||
|
self,
|
||||||
|
call: ActiveCall,
|
||||||
|
sip_leg_id: str,
|
||||||
|
routing_decision: Optional[RoutingDecision] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Run the full receptionist flow for an inbound call."""
|
||||||
|
try:
|
||||||
|
await self._greet(call, sip_leg_id)
|
||||||
|
|
||||||
|
transcript = await self._listen(call, sip_leg_id)
|
||||||
|
if transcript:
|
||||||
|
call.transcript_chunks.append(f"caller: {transcript}")
|
||||||
|
await self.gateway.event_bus.publish(GatewayEvent(
|
||||||
|
type=EventType.TRANSCRIPT_CHUNK,
|
||||||
|
call_id=call.id,
|
||||||
|
data={"text": transcript, "speaker": "caller"},
|
||||||
|
message=f"📝 caller: {transcript[:80]}",
|
||||||
|
))
|
||||||
|
|
||||||
|
classification = await self._classify(call, transcript, routing_decision)
|
||||||
|
call.intent = classification.get("intent")
|
||||||
|
|
||||||
|
await self.gateway.event_bus.publish(GatewayEvent(
|
||||||
|
type=EventType.RECEPTIONIST_CAPTURED_INTENT,
|
||||||
|
call_id=call.id,
|
||||||
|
data=classification,
|
||||||
|
message=f"Intent: {classification.get('intent', '?')}",
|
||||||
|
))
|
||||||
|
|
||||||
|
action = self._decide(routing_decision, classification)
|
||||||
|
|
||||||
|
await self.gateway.event_bus.publish(GatewayEvent(
|
||||||
|
type=EventType.RECEPTIONIST_ROUTING,
|
||||||
|
call_id=call.id,
|
||||||
|
data={"action": action.type.value},
|
||||||
|
message=f"Routing decision: {action.type.value}",
|
||||||
|
))
|
||||||
|
|
||||||
|
if action.type in (RoutingActionType.REJECT, RoutingActionType.DND):
|
||||||
|
if action.message:
|
||||||
|
await self._speak(call, sip_leg_id, action.message)
|
||||||
|
await self._hangup(call, sip_leg_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if action.type in (RoutingActionType.RING_DEVICE, RoutingActionType.RING_CHAIN):
|
||||||
|
devices = self._resolve_device_list(action, classification)
|
||||||
|
if not devices:
|
||||||
|
logger.info("Receptionist: no devices to ring, falling back to message")
|
||||||
|
await self._take_message(call, sip_leg_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._speak(
|
||||||
|
call, sip_leg_id, "One moment, I'll connect you now."
|
||||||
|
)
|
||||||
|
answered = await self.gateway._routing.ring_chain(
|
||||||
|
call.id, devices, action.ring_timeout
|
||||||
|
)
|
||||||
|
if answered:
|
||||||
|
return # Bridged to a device — receptionist done
|
||||||
|
# Nobody home — take a message
|
||||||
|
await self._take_message(call, sip_leg_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Default: take a message
|
||||||
|
await self._take_message(call, sip_leg_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Receptionist failed for {call.id}: {e}", exc_info=True)
|
||||||
|
try:
|
||||||
|
await self._hangup(call, sip_leg_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# State machine steps
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _greet(self, call: ActiveCall, sip_leg_id: str) -> None:
|
||||||
|
await self.gateway.event_bus.publish(GatewayEvent(
|
||||||
|
type=EventType.RECEPTIONIST_GREETING,
|
||||||
|
call_id=call.id,
|
||||||
|
data={"text": self.settings.greeting_template},
|
||||||
|
message="Playing greeting",
|
||||||
|
))
|
||||||
|
await self._speak(call, sip_leg_id, self.settings.greeting_template)
|
||||||
|
|
||||||
|
async def _listen(self, call: ActiveCall, sip_leg_id: str) -> str:
|
||||||
|
"""Buffer audio from the call's tap until silence or timeout."""
|
||||||
|
await self.gateway.event_bus.publish(GatewayEvent(
|
||||||
|
type=EventType.RECEPTIONIST_LISTENING,
|
||||||
|
call_id=call.id,
|
||||||
|
message="Listening for caller",
|
||||||
|
))
|
||||||
|
|
||||||
|
media = self.gateway.media_pipeline
|
||||||
|
if media is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
tap = media.create_tap(sip_leg_id)
|
||||||
|
audio = bytearray()
|
||||||
|
deadline = _time.monotonic() + self.settings.listen_timeout_s
|
||||||
|
silent_for = 0.0
|
||||||
|
frame_ms = 20
|
||||||
|
|
||||||
|
try:
|
||||||
|
while _time.monotonic() < deadline:
|
||||||
|
remaining = max(0.05, deadline - _time.monotonic())
|
||||||
|
frame = await tap.read_frame(timeout=min(0.5, remaining))
|
||||||
|
if frame is None:
|
||||||
|
silent_for += 0.5
|
||||||
|
else:
|
||||||
|
audio.extend(frame)
|
||||||
|
if self._frame_is_silent(frame):
|
||||||
|
silent_for += frame_ms / 1000.0
|
||||||
|
else:
|
||||||
|
silent_for = 0.0
|
||||||
|
if silent_for >= self.settings.end_of_utterance_silence_s and audio:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
tap.close()
|
||||||
|
|
||||||
|
if not audio:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return await self.gateway._transcription.transcribe(bytes(audio))
|
||||||
|
|
||||||
|
async def _classify(
|
||||||
|
self,
|
||||||
|
call: ActiveCall,
|
||||||
|
transcript: str,
|
||||||
|
routing_decision: Optional[RoutingDecision],
|
||||||
|
) -> dict:
|
||||||
|
"""Ask the LLM to interpret the caller's utterance."""
|
||||||
|
from services.hold_slayer import _get_llm
|
||||||
|
|
||||||
|
llm = _get_llm()
|
||||||
|
if llm is None or not transcript.strip():
|
||||||
|
return {
|
||||||
|
"intent": transcript or "unknown",
|
||||||
|
"urgency": "normal",
|
||||||
|
"recommended_action": "ring",
|
||||||
|
"device_hint": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
rules_summary = ""
|
||||||
|
if routing_decision and routing_decision.matched_rule_name:
|
||||||
|
rules_summary = (
|
||||||
|
f"A routing rule already matched: '{routing_decision.matched_rule_name}' "
|
||||||
|
f"(action: {routing_decision.action.type.value})."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await llm.chat_json(
|
||||||
|
user_message=(
|
||||||
|
f"Caller: {call.remote_number}\n"
|
||||||
|
f"Transcript: {transcript}\n"
|
||||||
|
f"{rules_summary}\n\n"
|
||||||
|
"Return JSON with keys: intent (short string), "
|
||||||
|
"urgency (low|normal|high), "
|
||||||
|
"recommended_action (ring|message|reject), "
|
||||||
|
"device_hint (string or null)."
|
||||||
|
),
|
||||||
|
system=self.settings.llm_persona,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Receptionist LLM classify failed: {e}")
|
||||||
|
return {
|
||||||
|
"intent": transcript,
|
||||||
|
"urgency": "normal",
|
||||||
|
"recommended_action": "ring",
|
||||||
|
"device_hint": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _decide(
|
||||||
|
self,
|
||||||
|
routing_decision: Optional[RoutingDecision],
|
||||||
|
classification: dict,
|
||||||
|
) -> RoutingAction:
|
||||||
|
"""Rules win on conflict; otherwise use the LLM's recommendation."""
|
||||||
|
if routing_decision and routing_decision.action.type not in (
|
||||||
|
RoutingActionType.TAKE_MESSAGE,
|
||||||
|
):
|
||||||
|
return routing_decision.action
|
||||||
|
|
||||||
|
recommended = (classification.get("recommended_action") or "ring").lower()
|
||||||
|
if recommended == "reject":
|
||||||
|
return RoutingAction(type=RoutingActionType.REJECT,
|
||||||
|
message="Sorry, I can't connect that call right now.")
|
||||||
|
if recommended == "message":
|
||||||
|
return RoutingAction(type=RoutingActionType.TAKE_MESSAGE)
|
||||||
|
return RoutingAction(type=RoutingActionType.RING_CHAIN)
|
||||||
|
|
||||||
|
def _resolve_device_list(
|
||||||
|
self, action: RoutingAction, classification: dict
|
||||||
|
) -> list[str]:
|
||||||
|
if action.type == RoutingActionType.RING_DEVICE and action.device_id:
|
||||||
|
return [action.device_id]
|
||||||
|
if action.device_ids:
|
||||||
|
return action.device_ids
|
||||||
|
# Default chain: every device that can take a call, in priority order
|
||||||
|
devices = sorted(
|
||||||
|
(d for d in self.gateway.devices.values() if d.can_receive_call),
|
||||||
|
key=lambda d: d.priority,
|
||||||
|
)
|
||||||
|
return [d.id for d in devices]
|
||||||
|
|
||||||
|
async def _take_message(self, call: ActiveCall, sip_leg_id: str) -> None:
|
||||||
|
await self._speak(call, sip_leg_id, self.settings.message_prompt)
|
||||||
|
|
||||||
|
media = self.gateway.media_pipeline
|
||||||
|
recording_svc = getattr(self.gateway, "_recording_service", None)
|
||||||
|
if recording_svc is None or media is None:
|
||||||
|
logger.warning("Receptionist: recording unavailable, ending call")
|
||||||
|
await self._hangup(call, sip_leg_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# RecordingService writes a WAV file and the recordings row.
|
||||||
|
session = await recording_svc.start_recording(
|
||||||
|
call.id, media_pipeline=media, leg_ids=[sip_leg_id]
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(self.settings.message_max_seconds)
|
||||||
|
finally:
|
||||||
|
session = await recording_svc.stop_recording(
|
||||||
|
call.id, media_pipeline=media
|
||||||
|
)
|
||||||
|
|
||||||
|
message_text = ""
|
||||||
|
rec_path = session.filepath_mixed if session else None
|
||||||
|
if rec_path and Path(rec_path).exists():
|
||||||
|
try:
|
||||||
|
audio_bytes = Path(rec_path).read_bytes()
|
||||||
|
message_text = await self.gateway._transcription.transcribe(audio_bytes)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Receptionist transcribe failed: {e}")
|
||||||
|
|
||||||
|
if message_text:
|
||||||
|
call.transcript_chunks.append(f"caller_message: {message_text}")
|
||||||
|
|
||||||
|
await self.gateway.event_bus.publish(GatewayEvent(
|
||||||
|
type=EventType.RECEPTIONIST_MESSAGE_SAVED,
|
||||||
|
call_id=call.id,
|
||||||
|
data={
|
||||||
|
"path": rec_path,
|
||||||
|
"transcript": message_text,
|
||||||
|
"caller": call.remote_number,
|
||||||
|
},
|
||||||
|
message=f"📥 Message saved from {call.remote_number}",
|
||||||
|
))
|
||||||
|
|
||||||
|
await self._hangup(call, sip_leg_id)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ----------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _speak(self, call: ActiveCall, sip_leg_id: str, text: str) -> None:
|
||||||
|
tts = self.gateway._tts
|
||||||
|
media = self.gateway.media_pipeline
|
||||||
|
if tts is None or media is None or not text.strip():
|
||||||
|
return
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
fd, tmp_path = tempfile.mkstemp(suffix=".wav", prefix=f"recept_{call.id}_")
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
ok = await tts.synthesize_to_file(text, tmp_path)
|
||||||
|
if not ok:
|
||||||
|
return
|
||||||
|
await media.play_wav(sip_leg_id, tmp_path)
|
||||||
|
await self.gateway.event_bus.publish(GatewayEvent(
|
||||||
|
type=EventType.SPEAK_PLAYED,
|
||||||
|
call_id=call.id,
|
||||||
|
data={"text": text, "speaker": "receptionist"},
|
||||||
|
message=f"🗣️ {text[:80]}",
|
||||||
|
))
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _hangup(self, call: ActiveCall, sip_leg_id: str) -> None:
|
||||||
|
try:
|
||||||
|
await self.gateway.sip_engine.hangup(sip_leg_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Receptionist hangup failed: {e}")
|
||||||
|
await self.gateway.call_manager.end_call(call.id, CallStatus.COMPLETED)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _frame_is_silent(frame: bytes, threshold: int = 500) -> bool:
|
||||||
|
"""Crude RMS-style check on a 16-bit PCM frame (mono, signed LE)."""
|
||||||
|
if not frame or len(frame) < 2:
|
||||||
|
return True
|
||||||
|
# Inline RMS — `audioop` was removed in Python 3.13.
|
||||||
|
import struct
|
||||||
|
|
||||||
|
n = len(frame) // 2
|
||||||
|
if n == 0:
|
||||||
|
return True
|
||||||
|
samples = struct.unpack_from(f"<{n}h", frame)
|
||||||
|
sq = 0
|
||||||
|
for s in samples:
|
||||||
|
sq += s * s
|
||||||
|
rms = (sq / n) ** 0.5
|
||||||
|
return rms < threshold
|
||||||
@@ -138,6 +138,9 @@ class RecordingService:
|
|||||||
# Store metadata
|
# Store metadata
|
||||||
self._metadata.append(session.to_dict())
|
self._metadata.append(session.to_dict())
|
||||||
|
|
||||||
|
# Persist a recording row so the dashboard can find it later
|
||||||
|
await self._persist_recording(session)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"⏹ Recording stopped: {call_id} "
|
f"⏹ Recording stopped: {call_id} "
|
||||||
f"({session.duration_seconds}s, "
|
f"({session.duration_seconds}s, "
|
||||||
@@ -145,6 +148,29 @@ class RecordingService:
|
|||||||
)
|
)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _persist_recording(session: "RecordingSession") -> None:
|
||||||
|
"""Write a recordings row for this session. Failures are non-fatal."""
|
||||||
|
try:
|
||||||
|
import uuid as _uuid
|
||||||
|
from db.database import RecordingRecord, get_session_factory
|
||||||
|
|
||||||
|
async with get_session_factory()() as db:
|
||||||
|
db.add(RecordingRecord(
|
||||||
|
id=f"rec_{_uuid.uuid4().hex[:10]}",
|
||||||
|
call_id=session.call_id,
|
||||||
|
path=session.filepath_mixed or "",
|
||||||
|
format="wav",
|
||||||
|
duration_s=float(session.duration_seconds or 0),
|
||||||
|
size_bytes=int(session.file_size_bytes or 0),
|
||||||
|
channels=1,
|
||||||
|
started_at=session.started_at,
|
||||||
|
ended_at=session.stopped_at,
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Recording persistence failed: {e}")
|
||||||
|
|
||||||
async def _recording_timeout(self, call_id: str) -> None:
|
async def _recording_timeout(self, call_id: str) -> None:
|
||||||
"""Auto-stop recording after max duration."""
|
"""Auto-stop recording after max duration."""
|
||||||
await asyncio.sleep(self._max_recording_seconds)
|
await asyncio.sleep(self._max_recording_seconds)
|
||||||
|
|||||||
258
services/routing.py
Normal file
258
services/routing.py
Normal 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
|
||||||
90
services/tts.py
Normal file
90
services/tts.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""
|
||||||
|
TTS Service — Rhema (OpenAI-compatible) text-to-speech client.
|
||||||
|
|
||||||
|
Synthesizes speech for the SPEAK call-flow step and the AI Receptionist.
|
||||||
|
Rhema exposes POST /v1/audio/speech (OpenAI-compatible) with Kokoro voices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from config import TTSSettings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TTSService:
|
||||||
|
"""Client for Rhema TTS service."""
|
||||||
|
|
||||||
|
def __init__(self, settings: TTSSettings):
|
||||||
|
self.settings = settings
|
||||||
|
self._client: Optional[httpx.AsyncClient] = None
|
||||||
|
|
||||||
|
async def _get_client(self) -> httpx.AsyncClient:
|
||||||
|
if self._client is None or self._client.is_closed:
|
||||||
|
headers = {}
|
||||||
|
if self.settings.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.settings.api_key}"
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
base_url=self.settings.base_url,
|
||||||
|
timeout=httpx.Timeout(self.settings.timeout, connect=5.0),
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
async def synthesize(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
voice: Optional[str] = None,
|
||||||
|
response_format: str = "wav",
|
||||||
|
) -> bytes:
|
||||||
|
"""Synthesize speech and return audio bytes."""
|
||||||
|
if not text or not text.strip():
|
||||||
|
return b""
|
||||||
|
|
||||||
|
client = await self._get_client()
|
||||||
|
body = {
|
||||||
|
"model": self.settings.model,
|
||||||
|
"input": text,
|
||||||
|
"voice": voice or self.settings.voice,
|
||||||
|
"response_format": response_format,
|
||||||
|
"sample_rate": self.settings.sample_rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.post("/v1/audio/speech", json=body)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.content
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
logger.error(f"Rhema TTS error: {e.response.status_code} {e.response.text}")
|
||||||
|
return b""
|
||||||
|
except httpx.ConnectError:
|
||||||
|
logger.error(f"Cannot connect to Rhema at {self.settings.base_url}")
|
||||||
|
return b""
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"TTS synthesis failed: {e}")
|
||||||
|
return b""
|
||||||
|
|
||||||
|
async def synthesize_to_file(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
filepath: str | Path,
|
||||||
|
voice: Optional[str] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Synthesize to a WAV file. Returns True on success."""
|
||||||
|
audio = await self.synthesize(text, voice=voice, response_format="wav")
|
||||||
|
if not audio:
|
||||||
|
return False
|
||||||
|
path = Path(filepath)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(audio)
|
||||||
|
logger.debug(f"TTS wrote {len(audio)} bytes to {path}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._client and not self._client.is_closed:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
74
tests/test_receptionist.py
Normal file
74
tests/test_receptionist.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Tests for the AI Receptionist decision logic (services/receptionist.py)."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from config import ReceptionistSettings, Settings
|
||||||
|
from models.routing import (
|
||||||
|
RoutingAction,
|
||||||
|
RoutingActionType,
|
||||||
|
RoutingDecision,
|
||||||
|
)
|
||||||
|
from services.receptionist import ReceptionistService
|
||||||
|
|
||||||
|
|
||||||
|
def _make_gateway():
|
||||||
|
settings = Settings()
|
||||||
|
settings.receptionist = ReceptionistSettings()
|
||||||
|
return SimpleNamespace(settings=settings, devices={})
|
||||||
|
|
||||||
|
|
||||||
|
class TestReceptionistDecide:
|
||||||
|
def test_rule_wins_over_llm_when_rule_is_actionable(self):
|
||||||
|
gw = _make_gateway()
|
||||||
|
svc = ReceptionistService(gw)
|
||||||
|
rule_action = RoutingAction(type=RoutingActionType.REJECT, message="nope")
|
||||||
|
decision = RoutingDecision(action=rule_action, reason="rule said so")
|
||||||
|
chosen = svc._decide(decision, {"recommended_action": "ring"})
|
||||||
|
assert chosen.type == RoutingActionType.REJECT
|
||||||
|
|
||||||
|
def test_falls_back_to_llm_when_rule_is_default_take_message(self):
|
||||||
|
gw = _make_gateway()
|
||||||
|
svc = ReceptionistService(gw)
|
||||||
|
decision = RoutingDecision(
|
||||||
|
action=RoutingAction(type=RoutingActionType.TAKE_MESSAGE),
|
||||||
|
reason="default",
|
||||||
|
)
|
||||||
|
chosen = svc._decide(decision, {"recommended_action": "ring"})
|
||||||
|
assert chosen.type == RoutingActionType.RING_CHAIN
|
||||||
|
|
||||||
|
def test_llm_reject_recommendation_is_honored(self):
|
||||||
|
gw = _make_gateway()
|
||||||
|
svc = ReceptionistService(gw)
|
||||||
|
chosen = svc._decide(None, {"recommended_action": "reject"})
|
||||||
|
assert chosen.type == RoutingActionType.REJECT
|
||||||
|
assert chosen.message # should carry a polite decline message
|
||||||
|
|
||||||
|
|
||||||
|
class TestReceptionistDeviceList:
|
||||||
|
def test_ring_device_returns_explicit_device(self):
|
||||||
|
gw = _make_gateway()
|
||||||
|
svc = ReceptionistService(gw)
|
||||||
|
action = RoutingAction(type=RoutingActionType.RING_DEVICE, device_id="dev_a")
|
||||||
|
assert svc._resolve_device_list(action, {}) == ["dev_a"]
|
||||||
|
|
||||||
|
def test_ring_chain_uses_action_list_when_present(self):
|
||||||
|
gw = _make_gateway()
|
||||||
|
svc = ReceptionistService(gw)
|
||||||
|
action = RoutingAction(
|
||||||
|
type=RoutingActionType.RING_CHAIN,
|
||||||
|
device_ids=["dev_a", "dev_b"],
|
||||||
|
)
|
||||||
|
assert svc._resolve_device_list(action, {}) == ["dev_a", "dev_b"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestFrameSilenceHeuristic:
|
||||||
|
def test_zeroes_are_silent(self):
|
||||||
|
# 16-bit PCM zeros → silent
|
||||||
|
assert ReceptionistService._frame_is_silent(b"\x00\x00" * 80) is True
|
||||||
|
|
||||||
|
def test_loud_pattern_not_silent(self):
|
||||||
|
# Loud values → not silent
|
||||||
|
loud = b"\xff\x7f" * 80 # max int16 every sample
|
||||||
|
assert ReceptionistService._frame_is_silent(loud) is False
|
||||||
133
tests/test_routing.py
Normal file
133
tests/test_routing.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""Tests for the routing evaluator (services/routing.py)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from models.routing import (
|
||||||
|
RoutingAction,
|
||||||
|
RoutingActionType,
|
||||||
|
RoutingMatch,
|
||||||
|
RoutingRule,
|
||||||
|
TimeRange,
|
||||||
|
)
|
||||||
|
from services.routing import RoutingService
|
||||||
|
|
||||||
|
|
||||||
|
def _rule(
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
priority: int = 100,
|
||||||
|
enabled: bool = True,
|
||||||
|
caller: str | None = None,
|
||||||
|
dnis: str | None = None,
|
||||||
|
time_range: TimeRange | None = None,
|
||||||
|
action_type: RoutingActionType = RoutingActionType.RING_CHAIN,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> RoutingRule:
|
||||||
|
return RoutingRule(
|
||||||
|
id=f"rule_{name}",
|
||||||
|
name=name,
|
||||||
|
priority=priority,
|
||||||
|
enabled=enabled,
|
||||||
|
match=RoutingMatch(caller_pattern=caller, dnis=dnis, time_range=time_range),
|
||||||
|
action=RoutingAction(type=action_type, device_id=device_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoutingEvaluator:
|
||||||
|
def _make_service(self, rules):
|
||||||
|
svc = RoutingService(gateway=None)
|
||||||
|
svc._rules = rules
|
||||||
|
return svc
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_rules_returns_default_take_message(self):
|
||||||
|
svc = self._make_service([])
|
||||||
|
decision = await svc.evaluate("+15551112222", "+15553334444")
|
||||||
|
assert decision.action.type == RoutingActionType.TAKE_MESSAGE
|
||||||
|
assert decision.matched_rule_id is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_priority_lower_wins(self):
|
||||||
|
svc = self._make_service([
|
||||||
|
_rule("low", priority=10, action_type=RoutingActionType.RING_DEVICE,
|
||||||
|
device_id="dev_a"),
|
||||||
|
_rule("high", priority=200, action_type=RoutingActionType.REJECT),
|
||||||
|
])
|
||||||
|
decision = await svc.evaluate("+15551112222", "+15553334444")
|
||||||
|
assert decision.matched_rule_name == "low"
|
||||||
|
assert decision.action.type == RoutingActionType.RING_DEVICE
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disabled_rule_skipped(self):
|
||||||
|
svc = self._make_service([
|
||||||
|
_rule("first", priority=10, enabled=False,
|
||||||
|
action_type=RoutingActionType.REJECT),
|
||||||
|
_rule("second", priority=20,
|
||||||
|
action_type=RoutingActionType.RING_CHAIN),
|
||||||
|
])
|
||||||
|
decision = await svc.evaluate("+1", "+2")
|
||||||
|
assert decision.matched_rule_name == "second"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_caller_glob_pattern(self):
|
||||||
|
svc = self._make_service([
|
||||||
|
_rule("tollfree", caller="+1800*",
|
||||||
|
action_type=RoutingActionType.REJECT),
|
||||||
|
])
|
||||||
|
toll = await svc.evaluate("+18001234567", "+15551112222")
|
||||||
|
assert toll.action.type == RoutingActionType.REJECT
|
||||||
|
normal = await svc.evaluate("+14155551212", "+15551112222")
|
||||||
|
assert normal.action.type == RoutingActionType.TAKE_MESSAGE
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dnis_must_match_exactly(self):
|
||||||
|
svc = self._make_service([
|
||||||
|
_rule("by_did", dnis="+15553334444",
|
||||||
|
action_type=RoutingActionType.RING_CHAIN),
|
||||||
|
])
|
||||||
|
match = await svc.evaluate("+1", "+15553334444")
|
||||||
|
assert match.matched_rule_name == "by_did"
|
||||||
|
miss = await svc.evaluate("+1", "+19998887777")
|
||||||
|
assert miss.matched_rule_name is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_time_range_in_window(self):
|
||||||
|
# Mon 10:30 UTC is inside Mon-Fri 09:00-17:00 UTC
|
||||||
|
tr = TimeRange(start="09:00", end="17:00", tz="UTC", days=[0, 1, 2, 3, 4])
|
||||||
|
svc = self._make_service([
|
||||||
|
_rule("business_hours", time_range=tr,
|
||||||
|
action_type=RoutingActionType.RING_CHAIN),
|
||||||
|
])
|
||||||
|
monday_10_30 = datetime(2026, 1, 5, 10, 30, tzinfo=ZoneInfo("UTC"))
|
||||||
|
decision = await svc.evaluate("+1", "+2", now=monday_10_30)
|
||||||
|
assert decision.matched_rule_name == "business_hours"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_time_range_outside_window(self):
|
||||||
|
tr = TimeRange(start="09:00", end="17:00", tz="UTC", days=[0, 1, 2, 3, 4])
|
||||||
|
svc = self._make_service([
|
||||||
|
_rule("business_hours", time_range=tr,
|
||||||
|
action_type=RoutingActionType.RING_CHAIN),
|
||||||
|
])
|
||||||
|
saturday_noon = datetime(2026, 1, 10, 12, 0, tzinfo=ZoneInfo("UTC"))
|
||||||
|
decision = await svc.evaluate("+1", "+2", now=saturday_noon)
|
||||||
|
# Weekend → no match → default take_message
|
||||||
|
assert decision.matched_rule_name is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_time_range_wraps_midnight(self):
|
||||||
|
# Overnight 22:00 → 06:00, every day
|
||||||
|
tr = TimeRange(start="22:00", end="06:00", tz="UTC", days=list(range(7)))
|
||||||
|
svc = self._make_service([
|
||||||
|
_rule("overnight", time_range=tr,
|
||||||
|
action_type=RoutingActionType.REJECT),
|
||||||
|
])
|
||||||
|
late = datetime(2026, 1, 5, 23, 30, tzinfo=ZoneInfo("UTC"))
|
||||||
|
early = datetime(2026, 1, 6, 4, 0, tzinfo=ZoneInfo("UTC"))
|
||||||
|
mid_day = datetime(2026, 1, 5, 12, 0, tzinfo=ZoneInfo("UTC"))
|
||||||
|
assert (await svc.evaluate("+1", "+2", now=late)).matched_rule_name == "overnight"
|
||||||
|
assert (await svc.evaluate("+1", "+2", now=early)).matched_rule_name == "overnight"
|
||||||
|
assert (await svc.evaluate("+1", "+2", now=mid_day)).matched_rule_name is None
|
||||||
72
tests/test_tts.py
Normal file
72
tests/test_tts.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Tests for the Rhema TTS client (services/tts.py)."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from config import TTSSettings
|
||||||
|
from services.tts import TTSService
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**overrides) -> TTSSettings:
|
||||||
|
defaults = {
|
||||||
|
"base_url": "http://localhost:9000",
|
||||||
|
"model": "speaches-ai/Kokoro-82M-v1.0-ONNX",
|
||||||
|
"voice": "af_heart",
|
||||||
|
"api_key": "",
|
||||||
|
"timeout": 5.0,
|
||||||
|
}
|
||||||
|
defaults.update(overrides)
|
||||||
|
return TTSSettings(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTTSService:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_synthesize_sends_expected_body(self):
|
||||||
|
svc = TTSService(_settings())
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.content = b"RIFFfake-wav-bytes"
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.is_closed = False
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
|
||||||
|
with patch.object(svc, "_get_client", AsyncMock(return_value=mock_client)):
|
||||||
|
audio = await svc.synthesize("hello world", voice="am_michael")
|
||||||
|
|
||||||
|
assert audio == b"RIFFfake-wav-bytes"
|
||||||
|
mock_client.post.assert_awaited_once()
|
||||||
|
call_args = mock_client.post.call_args
|
||||||
|
assert call_args.args[0] == "/v1/audio/speech"
|
||||||
|
body = call_args.kwargs["json"]
|
||||||
|
assert body["input"] == "hello world"
|
||||||
|
assert body["voice"] == "am_michael"
|
||||||
|
assert body["response_format"] == "wav"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_synthesize_empty_text_returns_empty(self):
|
||||||
|
svc = TTSService(_settings())
|
||||||
|
assert await svc.synthesize("") == b""
|
||||||
|
assert await svc.synthesize(" ") == b""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_synthesize_to_file_writes_audio(self, tmp_path):
|
||||||
|
svc = TTSService(_settings())
|
||||||
|
target = tmp_path / "out.wav"
|
||||||
|
|
||||||
|
with patch.object(svc, "synthesize", AsyncMock(return_value=b"WAVE-bytes")):
|
||||||
|
ok = await svc.synthesize_to_file("hello", str(target))
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
assert target.read_bytes() == b"WAVE-bytes"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bearer_header_used_when_api_key_set(self):
|
||||||
|
svc = TTSService(_settings(api_key="secret-token"))
|
||||||
|
client = await svc._get_client()
|
||||||
|
try:
|
||||||
|
assert client.headers.get("authorization") == "Bearer secret-token"
|
||||||
|
finally:
|
||||||
|
await svc.close()
|
||||||
Reference in New Issue
Block a user