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:
116
core/gateway.py
116
core/gateway.py
@@ -24,6 +24,20 @@ from models.events import EventType, GatewayEvent
|
||||
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:
|
||||
"""Build the appropriate SIP engine from config."""
|
||||
trunk = settings.sip_trunk
|
||||
@@ -42,7 +56,9 @@ def _build_sip_engine(settings: Settings, gateway: "AIPSTNGateway") -> SIPEngine
|
||||
trunk_transport=trunk.transport,
|
||||
domain=gw_sip.domain,
|
||||
did=trunk.did,
|
||||
media_pipeline=gateway.media_pipeline,
|
||||
on_device_registered=gateway._on_sip_device_registered,
|
||||
on_incoming_call=gateway._on_sip_incoming_call,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not create SippyEngine: {e} — using mock")
|
||||
@@ -71,12 +87,16 @@ class AIPSTNGateway:
|
||||
self.settings = settings
|
||||
self.event_bus = EventBus()
|
||||
self.call_manager = CallManager(self.event_bus)
|
||||
self.media_pipeline = MediaPipeline(sample_rate=16000)
|
||||
self.sip_engine: SIPEngine = sip_engine or MockSIPEngine()
|
||||
|
||||
# Services (initialized in start())
|
||||
self._hold_slayer = None
|
||||
self._audio_classifier = None
|
||||
self._transcription = None
|
||||
self._tts = None
|
||||
self._routing = None
|
||||
self._receptionist = None
|
||||
|
||||
# Device registry (loaded from DB on start)
|
||||
self._devices: dict[str, Device] = {}
|
||||
@@ -103,6 +123,9 @@ class AIPSTNGateway:
|
||||
"""Boot the gateway — start SIP engine and services."""
|
||||
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
|
||||
await self.sip_engine.start()
|
||||
logger.info(f" SIP Engine: ready")
|
||||
@@ -110,9 +133,21 @@ class AIPSTNGateway:
|
||||
# Import services here to avoid circular imports
|
||||
from services.audio_classifier import AudioClassifier
|
||||
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._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()
|
||||
|
||||
@@ -150,6 +185,18 @@ class AIPSTNGateway:
|
||||
# Stop SIP engine
|
||||
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
|
||||
logger.info("Gateway shut down cleanly.")
|
||||
|
||||
@@ -215,6 +262,7 @@ class AIPSTNGateway:
|
||||
classifier=self._audio_classifier,
|
||||
transcription=self._transcription,
|
||||
settings=self.settings,
|
||||
tts=self._tts,
|
||||
)
|
||||
# Launch as background task — don't block
|
||||
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]:
|
||||
"""Get the highest-priority online device."""
|
||||
online_devices = [
|
||||
|
||||
Reference in New Issue
Block a user