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
266 lines
9.0 KiB
Python
266 lines
9.0 KiB
Python
"""
|
|
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
|