feat: add initial Hold Slayer AI telephony gateway implementation

Complete project scaffolding and core implementation of an AI-powered
telephony system that calls companies, navigates IVR menus, waits on
hold, and transfers to the user when a human answers.

Key components:
- FastAPI server with REST API, WebSocket, and MCP (SSE) interfaces
- SIP/VoIP call management via PJSUA2 with RTP audio streaming
- LLM-powered IVR navigation using OpenAI/Anthropic with tool calling
- Hold detection service combining audio analysis and silence detection
- Real-time STT (Whisper/Deepgram) and TTS (OpenAI/Piper) pipelines
- Call recording with per-channel and mixed audio capture
- Event bus (asyncio pub/sub) for real-time client updates
- Web dashboard with live call monitoring
- SQLite persistence via SQLAlchemy with call history and analytics
- Notification support (email, SMS, webhook, desktop)
- Docker Compose deployment with Opal VoIP and Opal Media containers
- Comprehensive test suite with unit, integration, and E2E tests
- Simplified .gitignore and full project documentation in README
This commit is contained in:
2026-03-21 19:23:26 +00:00
parent c9ff60702b
commit ecf37658ce
56 changed files with 11601 additions and 164 deletions

265
tests/test_hold_slayer.py Normal file
View File

@@ -0,0 +1,265 @@
"""
Tests for the Hold Slayer service.
Uses MockSIPEngine to test the state machine without real SIP.
"""
import asyncio
import pytest
from config import Settings
from core.call_manager import CallManager
from core.event_bus import EventBus
from core.sip_engine import MockSIPEngine
from models.call import ActiveCall, AudioClassification, CallMode, CallStatus
from models.call_flow import ActionType, CallFlow, CallFlowStep
from services.hold_slayer import HoldSlayerService
class TestMenuNavigation:
"""Test the IVR menu navigation logic."""
@pytest.fixture
def hold_slayer(self):
"""Create a HoldSlayerService with mock dependencies."""
from config import ClassifierSettings, SpeachesSettings
from services.audio_classifier import AudioClassifier
from services.transcription import TranscriptionService
settings = Settings()
event_bus = EventBus()
call_manager = CallManager(event_bus)
sip_engine = MockSIPEngine()
classifier = AudioClassifier(ClassifierSettings())
transcription = TranscriptionService(SpeachesSettings())
return HoldSlayerService(
gateway=None, # Not needed for menu tests
call_manager=call_manager,
sip_engine=sip_engine,
classifier=classifier,
transcription=transcription,
settings=settings,
)
def test_decide_cancel_card(self, hold_slayer):
"""Should match 'cancel' intent to card cancellation option."""
transcript = (
"Press 1 for account balance, press 2 for recent transactions, "
"press 3 to report a lost or stolen card, press 4 to cancel your card, "
"press 0 to speak with a representative."
)
result = hold_slayer._decide_menu_option(
transcript, "cancel my credit card", None
)
assert result == "4"
def test_decide_dispute_charge(self, hold_slayer):
"""Should match 'dispute' intent to billing option."""
transcript = (
"Press 1 for account balance, press 2 for billing and disputes, "
"press 3 for payments, press 0 for agent."
)
result = hold_slayer._decide_menu_option(
transcript, "dispute a charge on my statement", None
)
assert result == "2"
def test_decide_agent_fallback(self, hold_slayer):
"""Should fall back to agent option when no match."""
transcript = (
"Press 1 for mortgage, press 2 for auto loans, "
"press 3 for investments, press 0 to speak with a representative."
)
result = hold_slayer._decide_menu_option(
transcript, "cancel my credit card", None
)
# Should choose representative since no direct match
assert result == "0"
def test_decide_no_options_found(self, hold_slayer):
"""Return None when transcript has no recognizable menu."""
transcript = "Please hold while we transfer your call."
result = hold_slayer._decide_menu_option(
transcript, "cancel my card", None
)
assert result is None
def test_decide_alternate_pattern(self, hold_slayer):
"""Handle 'for X, press N' pattern."""
transcript = (
"For account balance, press 1. For billing inquiries, press 2. "
"For card cancellation, press 3."
)
result = hold_slayer._decide_menu_option(
transcript, "cancel my card", None
)
# Should match card cancellation
assert result == "3"
def test_decide_fraud_intent(self, hold_slayer):
"""Match fraud-related intent."""
transcript = (
"Press 1 for balance, press 2 for payments, "
"press 3 to report fraud or unauthorized transactions, "
"press 0 for an agent."
)
result = hold_slayer._decide_menu_option(
transcript, "report unauthorized charge on my card", None
)
assert result == "3"
class TestEventBus:
"""Test the event bus pub/sub system."""
@pytest.fixture
def event_bus(self):
return EventBus()
def test_subscribe(self, event_bus):
sub = event_bus.subscribe()
assert event_bus.subscriber_count == 1
sub.close()
assert event_bus.subscriber_count == 0
@pytest.mark.asyncio
async def test_publish_receive(self, event_bus):
from models.events import EventType, GatewayEvent
sub = event_bus.subscribe()
event = GatewayEvent(
type=EventType.CALL_INITIATED,
call_id="test_123",
message="Test event",
)
await event_bus.publish(event)
received = await asyncio.wait_for(sub.__anext__(), timeout=1.0)
assert received.type == EventType.CALL_INITIATED
assert received.call_id == "test_123"
sub.close()
def test_history(self, event_bus):
assert len(event_bus.recent_events) == 0
class TestCallManager:
"""Test call manager state tracking."""
@pytest.fixture
def call_manager(self):
event_bus = EventBus()
return CallManager(event_bus)
@pytest.mark.asyncio
async def test_create_call(self, call_manager):
call = await call_manager.create_call(
remote_number="+18005551234",
mode=CallMode.HOLD_SLAYER,
intent="cancel my card",
)
assert call.id.startswith("call_")
assert call.remote_number == "+18005551234"
assert call.mode == CallMode.HOLD_SLAYER
assert call.intent == "cancel my card"
assert call.status == CallStatus.INITIATING
@pytest.mark.asyncio
async def test_update_status(self, call_manager):
call = await call_manager.create_call(
remote_number="+18005551234",
mode=CallMode.DIRECT,
)
await call_manager.update_status(call.id, CallStatus.RINGING)
updated = call_manager.get_call(call.id)
assert updated.status == CallStatus.RINGING
@pytest.mark.asyncio
async def test_end_call(self, call_manager):
call = await call_manager.create_call(
remote_number="+18005551234",
mode=CallMode.DIRECT,
)
ended = await call_manager.end_call(call.id)
assert ended is not None
assert ended.status == CallStatus.COMPLETED
assert call_manager.get_call(call.id) is None
@pytest.mark.asyncio
async def test_active_call_count(self, call_manager):
assert call_manager.active_call_count == 0
await call_manager.create_call("+18005551234", CallMode.DIRECT)
assert call_manager.active_call_count == 1
await call_manager.create_call("+18005559999", CallMode.HOLD_SLAYER)
assert call_manager.active_call_count == 2
@pytest.mark.asyncio
async def test_add_transcript(self, call_manager):
call = await call_manager.create_call("+18005551234", CallMode.HOLD_SLAYER)
await call_manager.add_transcript(call.id, "Press 1 for English")
await call_manager.add_transcript(call.id, "Press 2 for French")
updated = call_manager.get_call(call.id)
assert "Press 1 for English" in updated.transcript
assert "Press 2 for French" in updated.transcript
class TestMockSIPEngine:
"""Test the mock SIP engine."""
@pytest.fixture
def engine(self):
return MockSIPEngine()
@pytest.mark.asyncio
async def test_lifecycle(self, engine):
assert not await engine.is_ready()
await engine.start()
assert await engine.is_ready()
await engine.stop()
assert not await engine.is_ready()
@pytest.mark.asyncio
async def test_make_call(self, engine):
await engine.start()
leg_id = await engine.make_call("+18005551234")
assert leg_id.startswith("mock_leg_")
assert leg_id in engine._active_legs
@pytest.mark.asyncio
async def test_hangup(self, engine):
await engine.start()
leg_id = await engine.make_call("+18005551234")
await engine.hangup(leg_id)
assert leg_id not in engine._active_legs
@pytest.mark.asyncio
async def test_send_dtmf(self, engine):
await engine.start()
leg_id = await engine.make_call("+18005551234")
await engine.send_dtmf(leg_id, "1")
await engine.send_dtmf(leg_id, "0")
assert engine._active_legs[leg_id]["dtmf_sent"] == ["1", "0"]
@pytest.mark.asyncio
async def test_bridge(self, engine):
await engine.start()
leg_a = await engine.make_call("+18005551234")
leg_b = await engine.make_call("+18005559999")
bridge_id = await engine.bridge_calls(leg_a, leg_b)
assert bridge_id in engine._bridges
await engine.unbridge(bridge_id)
assert bridge_id not in engine._bridges
@pytest.mark.asyncio
async def test_trunk_status(self, engine):
status = await engine.get_trunk_status()
assert status["registered"] is False
await engine.start()
status = await engine.get_trunk_status()
assert status["registered"] is True