Engine mode is now explicit: USE_MOCK_SIP=true is the only way to get the mock engine; an unconfigured trunk fails startup with guidance instead of silently degrading. Root-caused why the engine always ran mock: nested pydantic-settings never read .env (no env_file on the sub-settings classes) — all 8 now declare it. /health stops lying: reports engine mode (sippy|mock), a live DB SELECT 1, trunk registration state with reason, and TTS/STT availability from their last real request; "healthy" now requires ready + db + sippy + registered trunk. Error policy: leaf services (tts/transcription/llm_client) raise and track availability; call-loop callers catch, publish EventType.ERROR naming the failed service, and apply an explicit fallback. Persistence writes get one bounded 3x exponential retry, then an ERROR log — no more silent data loss. Event bus: a full subscriber queue drops its oldest event (counted) instead of silently evicting the subscription; subscribe(replay_last=N) delivers the advertised history replay, used by /ws/events (25). Receptionist correctness: a matched TAKE_MESSAGE rule beats the LLM; voicemail polls for early hangup and stops/transcribes/hangs up in finally; RecordingSession finally keeps its leg_ids so taps detach. Dead code removed: models/contact.py + Contact table, dtmf_buffer, transcribe_stream stub, SMS stub in notification.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
92 lines
3.3 KiB
Python
92 lines
3.3 KiB
Python
"""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,
|
|
matched_rule_id="rule_1",
|
|
matched_rule_name="block",
|
|
reason="rule said so",
|
|
)
|
|
chosen = svc._decide(decision, {"recommended_action": "ring"})
|
|
assert chosen.type == RoutingActionType.REJECT
|
|
|
|
def test_matched_take_message_rule_beats_llm(self):
|
|
gw = _make_gateway()
|
|
svc = ReceptionistService(gw)
|
|
decision = RoutingDecision(
|
|
action=RoutingAction(type=RoutingActionType.TAKE_MESSAGE),
|
|
matched_rule_id="rule_2",
|
|
matched_rule_name="voicemail-hours",
|
|
reason="matched rule 'voicemail-hours'",
|
|
)
|
|
chosen = svc._decide(decision, {"recommended_action": "ring"})
|
|
assert chosen.type == RoutingActionType.TAKE_MESSAGE
|
|
|
|
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
|