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:
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