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:
265
tests/test_hold_slayer.py
Normal file
265
tests/test_hold_slayer.py
Normal 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
|
||||
Reference in New Issue
Block a user