first commit
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
tests/adapters/__init__.py
Normal file
1
tests/adapters/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
134
tests/adapters/test_audio_gain.py
Normal file
134
tests/adapters/test_audio_gain.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
import numpy as np
|
||||
|
||||
from app.livekit.adapters.audio_gain import (
|
||||
GainEmitter,
|
||||
SoftClipGain,
|
||||
tts_output_gain_from_env,
|
||||
)
|
||||
|
||||
|
||||
def _pcm(*samples: int) -> bytes:
|
||||
return struct.pack("<" + "h" * len(samples), *samples)
|
||||
|
||||
|
||||
def _samples(pcm: bytes) -> list[int]:
|
||||
return list(struct.unpack("<" + "h" * (len(pcm) // 2), pcm))
|
||||
|
||||
|
||||
def test_gain_1_0_is_disabled_and_passthrough() -> None:
|
||||
g = SoftClipGain(gain=1.0)
|
||||
assert g.enabled is False
|
||||
pcm = _pcm(1000, -2000, 3000)
|
||||
assert g.process(pcm) == pcm
|
||||
|
||||
|
||||
def test_normal_level_is_boosted_near_linear() -> None:
|
||||
g = SoftClipGain(gain=2.0) # ceiling default -1 dBFS
|
||||
# sinal baixo (~ -30 dBFS): boost deve ser praticamente 2x
|
||||
out = _samples(g.process(_pcm(1000, -1000)))
|
||||
assert abs(out[0] - 2000) <= 40
|
||||
assert abs(out[1] + 2000) <= 40
|
||||
|
||||
|
||||
def test_hot_peaks_never_clip_past_ceiling() -> None:
|
||||
ceiling = 0.891 # ~ -1 dBFS
|
||||
g = SoftClipGain(gain=2.0, ceiling=ceiling)
|
||||
limit = int(ceiling * 32768) + 1
|
||||
# picos quentes que, com 2x linear, estourariam o fundo de escala
|
||||
out = _samples(g.process(_pcm(30000, -30000, 25000, -25000)))
|
||||
assert all(abs(s) <= limit for s in out), out
|
||||
# e continua monotonicamente crescente (sem wraparound/inversao de fase)
|
||||
assert out[0] > 0 and out[1] < 0
|
||||
|
||||
|
||||
def test_monotonic_transfer_curve() -> None:
|
||||
g = SoftClipGain(gain=2.0)
|
||||
xs = list(range(0, 32000, 1000))
|
||||
ys = [_samples(g.process(_pcm(x)))[0] for x in xs]
|
||||
assert all(b >= a for a, b in zip(ys, ys[1:])), ys
|
||||
|
||||
|
||||
def test_odd_length_bytes_do_not_crash() -> None:
|
||||
g = SoftClipGain(gain=2.0)
|
||||
pcm = _pcm(1000, -1000) + b"\x7f" # 1 byte solto
|
||||
out = g.process(pcm)
|
||||
assert len(out) == len(pcm)
|
||||
assert out[-1:] == b"\x7f"
|
||||
|
||||
|
||||
def test_empty_input() -> None:
|
||||
assert SoftClipGain(gain=2.0).process(b"") == b""
|
||||
|
||||
|
||||
def test_env_loader_defaults_to_disabled(monkeypatch) -> None:
|
||||
monkeypatch.delenv("TTS_OUTPUT_GAIN", raising=False)
|
||||
monkeypatch.delenv("TTS_OUTPUT_CEILING_DBFS", raising=False)
|
||||
g = tts_output_gain_from_env()
|
||||
assert g.gain == 1.0
|
||||
assert g.enabled is False
|
||||
|
||||
|
||||
def test_env_loader_reads_gain_and_ceiling(monkeypatch) -> None:
|
||||
monkeypatch.setenv("TTS_OUTPUT_GAIN", "2.0")
|
||||
monkeypatch.setenv("TTS_OUTPUT_CEILING_DBFS", "-6")
|
||||
g = tts_output_gain_from_env()
|
||||
assert g.gain == 2.0
|
||||
assert abs(g.ceiling - 10 ** (-6 / 20.0)) < 1e-6
|
||||
|
||||
|
||||
class _FakeEmitter:
|
||||
def __init__(self) -> None:
|
||||
self.pushed: list[bytes] = []
|
||||
self.initialized = False
|
||||
self.flushed = False
|
||||
|
||||
def initialize(self, **kwargs) -> None:
|
||||
self.initialized = True
|
||||
|
||||
def push(self, data: bytes) -> None:
|
||||
self.pushed.append(data)
|
||||
|
||||
def flush(self) -> None:
|
||||
self.flushed = True
|
||||
|
||||
|
||||
def test_gain_emitter_transforms_push_and_forwards_rest() -> None:
|
||||
inner = _FakeEmitter()
|
||||
em = GainEmitter(inner, SoftClipGain(gain=2.0))
|
||||
|
||||
em.initialize(sample_rate=24000)
|
||||
em.push(_pcm(1000, -1000))
|
||||
em.flush()
|
||||
|
||||
assert inner.initialized is True
|
||||
assert inner.flushed is True
|
||||
assert len(inner.pushed) == 1
|
||||
# o que chegou ao emitter real foi amplificado
|
||||
out = _samples(inner.pushed[0])
|
||||
assert abs(out[0] - 2000) <= 40
|
||||
|
||||
|
||||
def test_gain_emitter_matches_numpy_reference() -> None:
|
||||
gain = SoftClipGain(gain=2.0, ceiling=0.891)
|
||||
pcm = _pcm(500, -12000, 28000, -31000, 0)
|
||||
ref_x = np.frombuffer(pcm, dtype="<i2").astype(np.float32) / 32768.0
|
||||
ref_y = 0.891 * np.tanh((2.0 / 0.891) * ref_x)
|
||||
ref = np.clip(np.rint(ref_y * 32768.0), -32768.0, 32767.0).astype("<i2")
|
||||
assert gain.process(pcm) == ref.tobytes()
|
||||
|
||||
|
||||
def test_gain_configuration_rejects_non_finite_and_out_of_range_values(monkeypatch) -> None:
|
||||
monkeypatch.setenv("TTS_OUTPUT_GAIN", "nan")
|
||||
monkeypatch.setenv("TTS_OUTPUT_CEILING_DBFS", "inf")
|
||||
gain = tts_output_gain_from_env()
|
||||
assert gain.gain == 1.0
|
||||
|
||||
monkeypatch.setenv("TTS_OUTPUT_GAIN", "-2")
|
||||
monkeypatch.setenv("TTS_OUTPUT_CEILING_DBFS", "-200")
|
||||
gain = tts_output_gain_from_env()
|
||||
assert gain.ceiling == 0.05
|
||||
assert gain.gain == 0.0
|
||||
47
tests/adapters/test_azure_rest_tts.py
Normal file
47
tests/adapters/test_azure_rest_tts.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import httpx
|
||||
|
||||
from app.livekit.adapters.azure_rest_tts import AzureRESTTTS
|
||||
|
||||
|
||||
class AzureRESTTTSTests(unittest.TestCase):
|
||||
def test_request_endpoints_keep_explicit_tts_path(self) -> None:
|
||||
tts = AzureRESTTTS(
|
||||
voice="pt-BR-FranciscaNeural",
|
||||
speech_key="key-123",
|
||||
speech_region="brazilsouth",
|
||||
speech_endpoint="https://speech.example.cognitiveservices.azure.com/tts/cognitiveservices/v1",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
tts._request_endpoints(),
|
||||
["https://speech.example.cognitiveservices.azure.com/tts/cognitiveservices/v1"],
|
||||
)
|
||||
|
||||
def test_synthesize_pcm_tries_voice_path_after_base_404_for_custom_voice(self) -> None:
|
||||
tts = AzureRESTTTS(
|
||||
voice="pt-BR-FranciscaNeural",
|
||||
speech_key="key-123",
|
||||
speech_endpoint="https://speech.example.cognitiveservices.azure.com/cognitiveservices/v1",
|
||||
deployment_id="deployment-42",
|
||||
)
|
||||
|
||||
base_url = "https://speech.example.cognitiveservices.azure.com/cognitiveservices/v1?deploymentId=deployment-42"
|
||||
voice_url = "https://speech.example.cognitiveservices.azure.com/voice/cognitiveservices/v1?deploymentId=deployment-42"
|
||||
|
||||
response_404 = httpx.Response(404, request=httpx.Request("POST", base_url))
|
||||
response_ok = httpx.Response(200, content=b"\x00\x00", request=httpx.Request("POST", voice_url))
|
||||
|
||||
with mock.patch.object(httpx.Client, "post", side_effect=[response_404, response_ok]) as mocked_post:
|
||||
audio = tts.synthesize_pcm("teste")
|
||||
|
||||
self.assertEqual(audio, b"\x00\x00")
|
||||
self.assertEqual([call.args[0] for call in mocked_post.call_args_list], [base_url, voice_url])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
117
tests/adapters/test_fake_tts.py
Normal file
117
tests/adapters/test_fake_tts.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
def _install_fake_livekit() -> None:
|
||||
if "livekit.agents" in sys.modules:
|
||||
return
|
||||
|
||||
try:
|
||||
importlib.import_module("livekit.agents")
|
||||
return
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
livekit_pkg = importlib.import_module("livekit")
|
||||
except ImportError:
|
||||
livekit_pkg = types.ModuleType("livekit")
|
||||
livekit_pkg.__path__ = []
|
||||
sys.modules["livekit"] = livekit_pkg
|
||||
|
||||
agents_module = types.ModuleType("livekit.agents")
|
||||
types_module = types.ModuleType("livekit.agents.types")
|
||||
|
||||
class TTSCapabilities:
|
||||
def __init__(self, *, streaming: bool, aligned_transcript: bool) -> None:
|
||||
self.streaming = streaming
|
||||
self.aligned_transcript = aligned_transcript
|
||||
|
||||
class AudioEmitter:
|
||||
def __init__(self) -> None:
|
||||
self._data = bytearray()
|
||||
self.sample_rate = 0
|
||||
self.num_channels = 0
|
||||
|
||||
def initialize(self, *, request_id: str, sample_rate: int, num_channels: int, mime_type: str) -> None:
|
||||
self.request_id = request_id
|
||||
self.sample_rate = sample_rate
|
||||
self.num_channels = num_channels
|
||||
self.mime_type = mime_type
|
||||
|
||||
def push(self, data: bytes) -> None:
|
||||
if data:
|
||||
self._data.extend(data)
|
||||
|
||||
def flush(self) -> None:
|
||||
return None
|
||||
|
||||
def snapshot(self):
|
||||
return SimpleNamespace(
|
||||
sample_rate=self.sample_rate,
|
||||
num_channels=self.num_channels,
|
||||
data=bytes(self._data),
|
||||
)
|
||||
|
||||
class BaseTTS:
|
||||
def __init__(self, *, capabilities: TTSCapabilities, sample_rate: int, num_channels: int) -> None:
|
||||
self.capabilities = capabilities
|
||||
self.sample_rate = sample_rate
|
||||
self.num_channels = num_channels
|
||||
|
||||
class BaseChunkedStream:
|
||||
def __init__(self, *, tts: BaseTTS, input_text: str, conn_options) -> None:
|
||||
self._tts = tts
|
||||
self._input_text = input_text
|
||||
self._conn_options = conn_options
|
||||
|
||||
async def collect(self):
|
||||
emitter = AudioEmitter()
|
||||
await self._run(emitter)
|
||||
return emitter.snapshot()
|
||||
|
||||
class APIConnectOptions:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
self.kwargs = kwargs
|
||||
|
||||
tts_module = SimpleNamespace(
|
||||
TTS=BaseTTS,
|
||||
TTSCapabilities=TTSCapabilities,
|
||||
ChunkedStream=BaseChunkedStream,
|
||||
AudioEmitter=AudioEmitter,
|
||||
)
|
||||
|
||||
agents_module.tts = tts_module
|
||||
agents_module.utils = SimpleNamespace(shortuuid=lambda: "req-test")
|
||||
|
||||
types_module.APIConnectOptions = APIConnectOptions
|
||||
types_module.DEFAULT_API_CONNECT_OPTIONS = APIConnectOptions()
|
||||
|
||||
setattr(livekit_pkg, "agents", agents_module)
|
||||
sys.modules["livekit.agents"] = agents_module
|
||||
sys.modules["livekit.agents.types"] = types_module
|
||||
|
||||
|
||||
_install_fake_livekit()
|
||||
|
||||
from app.livekit.adapters.fake_tts import FakeTTS
|
||||
|
||||
|
||||
class FakeLiveKitTTSTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_synthesize_collects_pcm_audio(self) -> None:
|
||||
tts = FakeTTS()
|
||||
stream = tts.synthesize("teste fake")
|
||||
frame = await stream.collect()
|
||||
|
||||
self.assertEqual(frame.sample_rate, 16000)
|
||||
self.assertEqual(frame.num_channels, 1)
|
||||
self.assertGreater(len(frame.data), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
407
tests/adapters/test_xai_tts.py
Normal file
407
tests/adapters/test_xai_tts.py
Normal file
@@ -0,0 +1,407 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.livekit.adapters import xai_tts as xai_tts_module
|
||||
from app.livekit.adapters.xai_tts import (
|
||||
AUTH_METHOD_API_KEY,
|
||||
DEFAULT_LANGUAGE,
|
||||
DEFAULT_VOICE,
|
||||
OraclexAITTS,
|
||||
)
|
||||
|
||||
|
||||
def _event(event_type: str, **values: object) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
type=aiohttp.WSMsgType.TEXT,
|
||||
data=json.dumps({"type": event_type, **values}),
|
||||
)
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
def __init__(self, events: list[SimpleNamespace]) -> None:
|
||||
self.events = list(events)
|
||||
self.sent: list[dict[str, object]] = []
|
||||
self.closed = False
|
||||
|
||||
def exception(self):
|
||||
return None
|
||||
|
||||
async def send_str(self, payload: str) -> None:
|
||||
self.sent.append(json.loads(payload))
|
||||
|
||||
async def receive(self) -> SimpleNamespace:
|
||||
if not self.events:
|
||||
await asyncio.sleep(60)
|
||||
return self.events.pop(0)
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _Emitter:
|
||||
def __init__(self) -> None:
|
||||
self.audio = bytearray()
|
||||
|
||||
def push(self, payload: bytes) -> None:
|
||||
self.audio.extend(payload)
|
||||
|
||||
|
||||
class _Stream:
|
||||
_segment_id = "segment-test"
|
||||
|
||||
def _mark_started(self) -> None:
|
||||
return None
|
||||
|
||||
def _note_provider_ttfb(self, _provider_ttfb: float) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _EventOwner:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[tuple[str, dict[str, object]]] = []
|
||||
|
||||
def _take_initial_greeting_capture(self, _text: str) -> None:
|
||||
return None
|
||||
|
||||
def emit(self, event_name: str, event: dict[str, object]) -> None:
|
||||
self.events.append((event_name, event))
|
||||
|
||||
|
||||
class XAITTSUpgradeTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_underflow_limit_defaults_to_one_second(self) -> None:
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
self.assertEqual(xai_tts_module._underflow_error_ms(), 1000)
|
||||
self.assertEqual(xai_tts_module._turn_total_timeout_s(), 60.0)
|
||||
|
||||
def test_estimated_pcm_balance_uses_first_pcm_release_without_prebuffer(self) -> None:
|
||||
self.assertEqual(
|
||||
xai_tts_module._estimated_pcm_balance_s(
|
||||
pcm_duration_s=1.0, first_pcm_released_at=100.0, now=101.125
|
||||
),
|
||||
-0.125,
|
||||
)
|
||||
|
||||
def _connection(
|
||||
self, events: list[SimpleNamespace], owner: _EventOwner | None = None
|
||||
):
|
||||
options = xai_tts_module._TTSOptions(
|
||||
base_url="wss://example.test/tts",
|
||||
voice=DEFAULT_VOICE,
|
||||
language=DEFAULT_LANGUAGE,
|
||||
)
|
||||
auth = xai_tts_module._AuthOptions(
|
||||
method=AUTH_METHOD_API_KEY,
|
||||
api_key="key-123",
|
||||
)
|
||||
connection = xai_tts_module._Connection(
|
||||
opts=options,
|
||||
auth=auth,
|
||||
session=object(),
|
||||
owner=owner,
|
||||
)
|
||||
connection._ws = _FakeWebSocket(events)
|
||||
connection._note_activity()
|
||||
return connection
|
||||
|
||||
async def _synthesize(self, events: list[SimpleNamespace]):
|
||||
connection = self._connection(events)
|
||||
emitter = _Emitter()
|
||||
result = await connection.synthesize_turn(
|
||||
"nova fala",
|
||||
output_emitter=emitter,
|
||||
stream=_Stream(),
|
||||
timeout=1.0,
|
||||
turn_index=0,
|
||||
connection_reused=True,
|
||||
)
|
||||
return connection, emitter, result
|
||||
|
||||
def test_legacy_public_name_and_websocket_url_are_preserved(self) -> None:
|
||||
tts = OraclexAITTS(api_key="key-123", websocket_url="wss://legacy.test/tts")
|
||||
|
||||
self.assertIs(xai_tts_module.TTS, OraclexAITTS)
|
||||
self.assertEqual(tts._opts.base_url, "wss://legacy.test/tts")
|
||||
self.assertEqual(tts._opts.voice, DEFAULT_VOICE)
|
||||
self.assertEqual(tts._opts.language, DEFAULT_LANGUAGE)
|
||||
self.assertEqual(tts.model, DEFAULT_VOICE)
|
||||
|
||||
def test_api_key_auth_and_iam_auth_validation_are_available(self) -> None:
|
||||
with mock.patch.dict(os.environ, {"XAI_API_KEY": "env-key"}, clear=True):
|
||||
tts = OraclexAITTS()
|
||||
|
||||
self.assertEqual(tts._auth.method, AUTH_METHOD_API_KEY)
|
||||
self.assertEqual(tts._auth.api_key, "env-key")
|
||||
self.assertEqual(
|
||||
xai_tts_module._request_headers(tts._auth, "wss://example.test/tts"),
|
||||
{"Authorization": "Bearer env-key"},
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "compartment_id"):
|
||||
OraclexAITTS(auth_method="INSTANCE_PRINCIPAL")
|
||||
|
||||
def test_cached_greeting_read_failure_falls_back_to_tts(self) -> None:
|
||||
class BrokenCache:
|
||||
def __init__(self) -> None:
|
||||
self.key = SimpleNamespace(digest="cache-key")
|
||||
self.discarded = []
|
||||
|
||||
def key_for(self, **_kwargs):
|
||||
return self.key
|
||||
|
||||
def has(self, _key) -> bool:
|
||||
return True
|
||||
|
||||
def frames(self, _key):
|
||||
raise FileNotFoundError("cached WAV disappeared")
|
||||
|
||||
def discard(self, key) -> None:
|
||||
self.discarded.append(key)
|
||||
|
||||
cache = BrokenCache()
|
||||
tts = OraclexAITTS(
|
||||
api_key="key-123",
|
||||
websocket_url="wss://example.test/tts",
|
||||
initial_greeting_audio_cache=cache,
|
||||
initial_greeting_agent="conta",
|
||||
)
|
||||
|
||||
self.assertIsNone(tts.initial_greeting_audio("Olá, como posso ajudar?"))
|
||||
self.assertEqual(cache.discarded, [cache.key])
|
||||
self.assertIs(tts._initial_greeting_capture_key, cache.key)
|
||||
|
||||
def test_cached_greeting_hit_is_logged(self) -> None:
|
||||
cache = mock.Mock()
|
||||
cache_key = SimpleNamespace(digest="cache-key")
|
||||
cached_audio = object()
|
||||
cache.key_for.return_value = cache_key
|
||||
cache.has.return_value = True
|
||||
cache.frames.return_value = cached_audio
|
||||
tts = OraclexAITTS(
|
||||
api_key="key-123",
|
||||
websocket_url="wss://example.test/tts",
|
||||
initial_greeting_audio_cache=cache,
|
||||
initial_greeting_agent="conta",
|
||||
)
|
||||
|
||||
with mock.patch.object(xai_tts_module, "_runtime_logger") as runtime_logger:
|
||||
self.assertIs(
|
||||
cached_audio, tts.initial_greeting_audio("Olá, como posso ajudar?")
|
||||
)
|
||||
|
||||
runtime_logger.return_value.info.assert_called_once_with(
|
||||
"INITIAL_GREETING_AUDIO_CACHE_HIT | key=%s", "cache-key"
|
||||
)
|
||||
cache.discard.assert_not_called()
|
||||
|
||||
async def test_connect_exposes_a_deterministic_prewarm_operation(self) -> None:
|
||||
tts = OraclexAITTS(api_key="key-123")
|
||||
with mock.patch.object(tts, "_current_connection", new=mock.AsyncMock()) as current_connection:
|
||||
await tts.connect(1.5)
|
||||
|
||||
current_connection.assert_awaited_once_with(1.5)
|
||||
await tts.aclose()
|
||||
|
||||
async def test_stream_merges_text_chunks_into_one_provider_turn(self) -> None:
|
||||
greeting_chunks = (
|
||||
"Olá! Eu sou a Especialista em Contas e vou ajudar você a entender a sua fatura. ",
|
||||
"Posso explicar valores, detalhar serviços e itens eventuais, identificar cobranças que você não reconhece e, se for o caso, realizar ajustes necessários ou solicitações relacionadas à sua conta. ",
|
||||
"Então vamos lá, me conte o que você gostaria de entender ou resolver na sua conta.",
|
||||
)
|
||||
greeting = "".join(greeting_chunks)
|
||||
pcm = b"\x01\x00" * 4800
|
||||
websocket = _FakeWebSocket(
|
||||
[
|
||||
_event("audio.clear"),
|
||||
_event("audio.delta", delta=base64.b64encode(pcm).decode()),
|
||||
_event("audio.done", trace_id="trace-chunked"),
|
||||
]
|
||||
)
|
||||
session = SimpleNamespace(
|
||||
ws_connect=mock.AsyncMock(return_value=websocket),
|
||||
)
|
||||
cache_key = SimpleNamespace(digest="greeting-key", text=greeting)
|
||||
cache = mock.Mock()
|
||||
cache.key_for.return_value = cache_key
|
||||
cache.has.return_value = False
|
||||
cache.store_pcm = mock.AsyncMock()
|
||||
|
||||
provider = OraclexAITTS(
|
||||
api_key="key-123",
|
||||
websocket_url="wss://example.test/tts",
|
||||
http_session=session,
|
||||
initial_greeting_audio_cache=cache,
|
||||
initial_greeting_agent="contas",
|
||||
)
|
||||
|
||||
self.assertIsNone(provider.initial_greeting_audio(greeting))
|
||||
|
||||
stream = provider.stream()
|
||||
for chunk in greeting_chunks:
|
||||
stream.push_text(chunk)
|
||||
stream.end_input()
|
||||
try:
|
||||
audio_events = [event async for event in stream]
|
||||
finally:
|
||||
await stream.aclose()
|
||||
await provider.aclose()
|
||||
|
||||
self.assertIsInstance(stream, xai_tts_module.SynthesizeStream)
|
||||
self.assertEqual(b"".join(event.frame.data.tobytes() for event in audio_events), pcm)
|
||||
self.assertEqual(
|
||||
[message["type"] for message in websocket.sent],
|
||||
["text.clear", "text.delta", "text.done"],
|
||||
)
|
||||
self.assertEqual(websocket.sent[1]["delta"], greeting)
|
||||
cache.store_pcm.assert_awaited_once_with(cache_key, pcm)
|
||||
|
||||
def test_text_sanitization_is_preserved(self) -> None:
|
||||
self.assertEqual(
|
||||
xai_tts_module._sanitize_tts_text(
|
||||
"TIM_GAMES_KIDS_MES custa R$ 14,99 no dia 29/05/26; a/b?"
|
||||
),
|
||||
"TIM GAMES KIDS MES custa R 14,99 no dia 29/05/26; a ou b?",
|
||||
)
|
||||
|
||||
async def test_turn_requires_clear_ack_before_emitting_audio(self) -> None:
|
||||
payload = base64.b64encode(b"novo").decode()
|
||||
connection, emitter, result = await self._synthesize(
|
||||
[_event("audio.clear"), _event("audio.delta", delta=payload), _event("audio.done", trace_id="trace-1")]
|
||||
)
|
||||
|
||||
self.assertEqual(bytes(emitter.audio), b"novo")
|
||||
self.assertEqual(
|
||||
[message["type"] for message in connection._ws.sent],
|
||||
["text.clear", "text.delta", "text.done"],
|
||||
)
|
||||
self.assertEqual(result.trace_id, "trace-1")
|
||||
self.assertEqual(result.timing.discarded_messages, [])
|
||||
|
||||
async def test_residual_audio_is_discarded_before_clear_ack(self) -> None:
|
||||
old_payload = base64.b64encode(b"velho").decode()
|
||||
new_payload = base64.b64encode(b"novo").decode()
|
||||
_connection, emitter, result = await self._synthesize(
|
||||
[
|
||||
_event("audio.delta", delta=old_payload),
|
||||
_event("audio.done"),
|
||||
_event("audio.clear"),
|
||||
_event("audio.delta", delta=new_payload),
|
||||
_event("audio.done", trace_id="trace-new"),
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(bytes(emitter.audio), b"novo")
|
||||
self.assertEqual(result.trace_id, "trace-new")
|
||||
self.assertEqual(result.timing.discarded_messages, ["audio.delta", "audio.done"])
|
||||
self.assertEqual(result.timing.clear_discarded_message_count, 2)
|
||||
self.assertEqual(result.timing.clear_discarded_audio_bytes, len(b"velho"))
|
||||
|
||||
async def test_unexpected_clear_after_boundary_fails_and_retires_socket(self) -> None:
|
||||
payload = base64.b64encode(b"parcial").decode()
|
||||
connection = self._connection(
|
||||
[_event("audio.clear"), _event("audio.delta", delta=payload), _event("audio.clear")]
|
||||
)
|
||||
emitter = _Emitter()
|
||||
|
||||
with self.assertRaisesRegex(xai_tts_module._XAIPartialAudioFailure, "unexpected_audio_clear"):
|
||||
await connection.synthesize_turn(
|
||||
"fala",
|
||||
output_emitter=emitter,
|
||||
stream=_Stream(),
|
||||
timeout=1.0,
|
||||
turn_index=0,
|
||||
connection_reused=True,
|
||||
)
|
||||
|
||||
self.assertEqual(bytes(emitter.audio), b"parcial")
|
||||
self.assertIsNone(connection._ws)
|
||||
|
||||
async def test_first_frame_timeout_is_configurable(self) -> None:
|
||||
connection = self._connection([_event("audio.clear")])
|
||||
connection._session = SimpleNamespace(
|
||||
ws_connect=mock.AsyncMock(return_value=_FakeWebSocket([]))
|
||||
)
|
||||
emitter = _Emitter()
|
||||
with mock.patch.dict(os.environ, {"TTS_FIRST_FRAME_TIMEOUT_S": "0.01"}, clear=False):
|
||||
with self.assertRaisesRegex(xai_tts_module.APIConnectionError, "timed out before audio"):
|
||||
await connection.synthesize_turn(
|
||||
"fala",
|
||||
output_emitter=emitter,
|
||||
stream=_Stream(),
|
||||
timeout=1.0,
|
||||
turn_index=0,
|
||||
connection_reused=True,
|
||||
)
|
||||
|
||||
self.assertIsNone(connection._ws)
|
||||
|
||||
async def test_audio_done_without_pcm_retries_once_on_same_socket(self) -> None:
|
||||
pcm = b"\x01\x00" * 480
|
||||
connection, emitter, result = await self._synthesize([
|
||||
_event("audio.clear"),
|
||||
_event("audio.done"),
|
||||
_event("audio.clear"),
|
||||
_event("audio.delta", delta=base64.b64encode(pcm).decode()),
|
||||
_event("audio.done", trace_id="trace-after-empty"),
|
||||
])
|
||||
self.assertEqual(bytes(emitter.audio), pcm)
|
||||
self.assertEqual(result.timing.attempts, 2)
|
||||
self.assertFalse(connection._ws.closed)
|
||||
self.assertEqual(
|
||||
[item["type"] for item in connection._ws.sent],
|
||||
["text.clear", "text.delta", "text.done"] * 2,
|
||||
)
|
||||
|
||||
async def test_partial_audio_resync_discards_socket_without_replay(self) -> None:
|
||||
pcm = b"\x01\x00" * 480
|
||||
owner = _EventOwner()
|
||||
connection = self._connection([
|
||||
_event("audio.clear"),
|
||||
_event("audio.delta", delta=base64.b64encode(pcm).decode()),
|
||||
], owner=owner)
|
||||
emitter = _Emitter()
|
||||
websocket = connection._ws
|
||||
with self.assertRaisesRegex(xai_tts_module._XAIPartialAudioFailure, "socket_resynchronized=0"):
|
||||
await connection.synthesize_turn("fala", output_emitter=emitter, stream=_Stream(), timeout=1.0, turn_index=0, connection_reused=True)
|
||||
self.assertEqual([item["type"] for item in websocket.sent].count("text.delta"), 1)
|
||||
self.assertEqual([item["type"] for item in websocket.sent].count("text.clear"), 2)
|
||||
self.assertEqual(len(owner.events), 1)
|
||||
event_name, event = owner.events[0]
|
||||
self.assertEqual(event_name, "xai_tts_turn_failed")
|
||||
self.assertEqual(event["segment_id"], "segment-test")
|
||||
self.assertEqual(event["reason"], "underflow_error")
|
||||
self.assertEqual(event["xai_micro_underflows"], 1)
|
||||
self.assertGreaterEqual(event["max_playout_underrun_0ms"], 10)
|
||||
self.assertEqual(event["attempts"], 1)
|
||||
self.assertGreater(event["pcm_bytes"], 0)
|
||||
self.assertGreater(event["pcm_duration_ms"], 0)
|
||||
self.assertTrue(event["connection_reused"])
|
||||
self.assertFalse(event["reconnected"])
|
||||
self.assertIsNone(connection._ws)
|
||||
|
||||
async def test_continuous_underflow_discards_socket_without_replaying_text(self) -> None:
|
||||
connection = self._connection([
|
||||
_event("audio.clear"),
|
||||
_event("audio.delta", delta=base64.b64encode(b"\x01\x00").decode()),
|
||||
])
|
||||
emitter = _Emitter()
|
||||
websocket = connection._ws
|
||||
with mock.patch.dict(os.environ, {"TTS_UNDERFLOW_ERROR_MS": "10", "TTS_FIRST_FRAME_TIMEOUT_S": "0.01"}, clear=False):
|
||||
with self.assertRaisesRegex(xai_tts_module._XAIPartialAudioFailure, "underflow_error") as raised:
|
||||
await connection.synthesize_turn("fala", output_emitter=emitter, stream=_Stream(), timeout=1.0, turn_index=0, connection_reused=True)
|
||||
self.assertIn("xai_micro_underflows=1", str(raised.exception))
|
||||
self.assertIn("xai_avg_underrun_ms=", str(raised.exception))
|
||||
self.assertEqual([item["type"] for item in websocket.sent].count("text.delta"), 1)
|
||||
self.assertIsNone(connection._ws)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1
tests/config/__init__.py
Normal file
1
tests/config/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
116
tests/config/test_azure_speech.py
Normal file
116
tests/config/test_azure_speech.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.livekit.azure_speech import resolve_azure_speech_tts_config
|
||||
|
||||
|
||||
class AzureSpeechTTSTests(unittest.TestCase):
|
||||
def test_resolve_uses_region_voice_and_optional_language(self) -> None:
|
||||
config, missing = resolve_azure_speech_tts_config(
|
||||
{},
|
||||
environ={
|
||||
"AZURE_SPEECH_KEY": "key-123",
|
||||
"AZURE_SPEECH_REGION": "brazilsouth",
|
||||
"AZURE_SPEECH_VOICE": "pt-BR-FranciscaNeural",
|
||||
"AZURE_SPEECH_LANGUAGE": "pt-BR",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(missing, [])
|
||||
self.assertEqual(config["speech_key"], "key-123")
|
||||
self.assertEqual(config["speech_region"], "brazilsouth")
|
||||
self.assertIsNone(config["speech_endpoint"])
|
||||
self.assertEqual(config["voice"], "pt-BR-FranciscaNeural")
|
||||
self.assertEqual(config["language"], "pt-BR")
|
||||
self.assertIsNone(config["deployment_id"])
|
||||
|
||||
def test_resolve_keeps_explicit_custom_endpoint_for_standard_voice(self) -> None:
|
||||
config, missing = resolve_azure_speech_tts_config(
|
||||
{},
|
||||
environ={
|
||||
"AZURE_SPEECH_KEY": "key-123",
|
||||
"AZURE_SPEECH_REGION": "brazilsouth",
|
||||
"AZURE_SPEECH_ENDPOINT": "https://speech.example.cognitiveservices.azure.com/",
|
||||
"AZURE_SPEECH_VOICE": "pt-BR-FranciscaNeural",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(missing, [])
|
||||
self.assertEqual(config["speech_region"], "brazilsouth")
|
||||
self.assertEqual(
|
||||
config["speech_endpoint"],
|
||||
"https://speech.example.cognitiveservices.azure.com/tts/cognitiveservices/v1",
|
||||
)
|
||||
|
||||
def test_resolve_prefers_endpoint_and_maps_legacy_override_names(self) -> None:
|
||||
config, missing = resolve_azure_speech_tts_config(
|
||||
{
|
||||
"voice_id": "pt-BR-FranciscaNeural",
|
||||
"model_id": "deployment-42",
|
||||
},
|
||||
environ={
|
||||
"AZURE_SPEECH_KEY": "key-123",
|
||||
"AZURE_SPEECH_REGION": "brazilsouth",
|
||||
"AZURE_SPEECH_ENDPOINT": "https://speech.example.cognitiveservices.azure.com/",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(missing, [])
|
||||
self.assertEqual(config["deployment_id"], "deployment-42")
|
||||
self.assertEqual(
|
||||
config["speech_endpoint"],
|
||||
"https://speech.example.cognitiveservices.azure.com/voice/cognitiveservices/v1",
|
||||
)
|
||||
self.assertEqual(config["speech_region"], "brazilsouth")
|
||||
|
||||
def test_resolve_accepts_host_alias_and_auth_token(self) -> None:
|
||||
config, missing = resolve_azure_speech_tts_config(
|
||||
{},
|
||||
environ={
|
||||
"AZURE_SPEECH_AUTH_TOKEN": "token-123",
|
||||
"AZURE_SPEECH_HOST": "https://speech.example.cognitiveservices.azure.com/",
|
||||
"AZURE_SPEECH_VOICE": "pt-BR-FranciscaNeural",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(missing, [])
|
||||
self.assertIsNone(config["speech_key"])
|
||||
self.assertEqual(config["speech_auth_token"], "token-123")
|
||||
self.assertEqual(
|
||||
config["speech_endpoint"],
|
||||
"https://speech.example.cognitiveservices.azure.com/tts/cognitiveservices/v1",
|
||||
)
|
||||
|
||||
def test_resolve_keeps_full_endpoint_path(self) -> None:
|
||||
config, missing = resolve_azure_speech_tts_config(
|
||||
{},
|
||||
environ={
|
||||
"AZURE_SPEECH_KEY": "key-123",
|
||||
"AZURE_SPEECH_ENDPOINT": "https://speech.example.cognitiveservices.azure.com/tts/cognitiveservices/v1",
|
||||
"AZURE_SPEECH_VOICE": "pt-BR-FranciscaNeural",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(missing, [])
|
||||
self.assertEqual(
|
||||
config["speech_endpoint"],
|
||||
"https://speech.example.cognitiveservices.azure.com/tts/cognitiveservices/v1",
|
||||
)
|
||||
|
||||
def test_resolve_reports_missing_required_settings(self) -> None:
|
||||
config, missing = resolve_azure_speech_tts_config({}, environ={})
|
||||
|
||||
self.assertEqual(config, {})
|
||||
self.assertEqual(
|
||||
missing,
|
||||
[
|
||||
"AZURE_SPEECH_VOICE",
|
||||
"AZURE_SPEECH_ENDPOINT|AZURE_SPEECH_HOST|AZURE_SPEECH_REGION",
|
||||
"AZURE_SPEECH_KEY|AZURE_SPEECH_AUTH_TOKEN",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
254
tests/config/test_call_config.py
Normal file
254
tests/config/test_call_config.py
Normal file
@@ -0,0 +1,254 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.livekit.call_config import (
|
||||
normalize_call_config,
|
||||
resolve_fake_agent_overrides,
|
||||
resolve_agent_backend_name,
|
||||
resolve_stt_overrides,
|
||||
resolve_tts_overrides,
|
||||
resolve_vad_logging_overrides,
|
||||
resolve_vad_overrides,
|
||||
resolve_ws_overrides,
|
||||
)
|
||||
from app.ws_gateway.call_config import build_call_config
|
||||
|
||||
|
||||
class CallConfigTests(unittest.TestCase):
|
||||
def test_build_and_normalize_call_config(self) -> None:
|
||||
payload = {
|
||||
"agentBackend": "remote_ws_fake",
|
||||
"stt": {
|
||||
"provider": "internal_http",
|
||||
"language": "pt-BR",
|
||||
"configOverride": "{\"processor\":{\"strategy\":\"faster_default\"}}",
|
||||
"minProbSingleWord": "0.15",
|
||||
"disableVosk": True,
|
||||
},
|
||||
"tts": {
|
||||
"provider": "elevenlabs",
|
||||
"voiceId": "voice-123",
|
||||
"modelId": "model-456",
|
||||
},
|
||||
"vad": {
|
||||
"minSpeechDuration": 0.04,
|
||||
"activationThreshold": 0.18,
|
||||
"deactivationThreshold": 0.10,
|
||||
"minSilenceDuration": 0.7,
|
||||
"prefixPaddingDuration": 0.75,
|
||||
"preBackendWaitNoticeFastOnVadPause": True,
|
||||
"deferredInterruptionMinAudioMs": 1350,
|
||||
"deferredInterruptionEnabled": False,
|
||||
},
|
||||
"vadLogging": {
|
||||
"logDecisions": True,
|
||||
"logActivity": False,
|
||||
"activityMinProbability": 0.03,
|
||||
},
|
||||
"ws": {
|
||||
"outputGain": 1.2,
|
||||
"audioInputBacklogShedEnabled": True,
|
||||
"audioInputBacklogShedThresholdMs": 700,
|
||||
"audioInputBacklogShedKeepMs": 250,
|
||||
"audioInputLatencyMetricsEnabled": True,
|
||||
"audioInputLatencyAlertMs": 900,
|
||||
"audioInputLatencyLogIntervalS": 10.5,
|
||||
"livekitAudioSourceQueueSizeMs": 400,
|
||||
"livekitAudioSourceClearOnShed": False,
|
||||
"audioInputBacklogEnergyShedEnabled": True,
|
||||
"audioInputBacklogEnergyShedMaxExcessMs": 600,
|
||||
"audioInputBacklogSilenceDbfs": -60,
|
||||
},
|
||||
"agentFake": {
|
||||
"delayMs": 2500,
|
||||
"responses": (
|
||||
"Esta e a primeira resposta simulada com tamanho intermediario;"
|
||||
"Esta e a resposta final simulada encerrando o atendimento"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
built = build_call_config(payload)
|
||||
normalized = normalize_call_config(payload)
|
||||
|
||||
self.assertEqual(built, normalized)
|
||||
self.assertEqual(resolve_agent_backend_name(payload, "remote_ws"), "remote_ws_fake")
|
||||
self.assertEqual(resolve_stt_overrides(payload)["language"], "pt-BR")
|
||||
self.assertEqual(resolve_stt_overrides(payload)["disable_vosk"], "True")
|
||||
self.assertEqual(
|
||||
resolve_stt_overrides(payload)["config_override"],
|
||||
"{\"processor\":{\"strategy\":\"faster_default\"}}",
|
||||
)
|
||||
self.assertEqual(resolve_tts_overrides(payload)["voice_id"], "voice-123")
|
||||
self.assertEqual(resolve_vad_overrides(payload)["min_speech_duration"], "0.04")
|
||||
self.assertEqual(resolve_vad_overrides(payload)["activation_threshold"], "0.18")
|
||||
self.assertEqual(
|
||||
resolve_vad_overrides(payload)["pre_backend_wait_notice_fast_on_vad_pause"],
|
||||
"True",
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_vad_overrides(payload)["deferred_interruption_min_audio_ms"],
|
||||
"1350",
|
||||
)
|
||||
self.assertEqual(
|
||||
resolve_vad_overrides(payload)["deferred_interruption_enabled"],
|
||||
"False",
|
||||
)
|
||||
self.assertEqual(resolve_vad_logging_overrides(payload)["log_decisions"], "True")
|
||||
self.assertEqual(resolve_vad_logging_overrides(payload)["log_activity"], "False")
|
||||
self.assertEqual(resolve_ws_overrides(payload)["output_gain"], "1.2")
|
||||
self.assertEqual(resolve_ws_overrides(payload)["audio_in_backlog_shed_enabled"], "True")
|
||||
self.assertEqual(resolve_ws_overrides(payload)["audio_in_backlog_shed_threshold_ms"], "700")
|
||||
self.assertEqual(resolve_ws_overrides(payload)["audio_in_backlog_shed_keep_ms"], "250")
|
||||
self.assertEqual(resolve_ws_overrides(payload)["audio_in_latency_metrics_enabled"], "True")
|
||||
self.assertEqual(resolve_ws_overrides(payload)["audio_in_latency_alert_ms"], "900")
|
||||
self.assertEqual(resolve_ws_overrides(payload)["audio_in_latency_log_interval_s"], "10.5")
|
||||
self.assertEqual(resolve_ws_overrides(payload)["livekit_audio_source_queue_size_ms"], "400")
|
||||
self.assertEqual(resolve_ws_overrides(payload)["livekit_audio_source_clear_on_shed"], "False")
|
||||
self.assertEqual(resolve_ws_overrides(payload)["audio_in_backlog_energy_shed_enabled"], "True")
|
||||
self.assertEqual(resolve_ws_overrides(payload)["audio_in_backlog_energy_shed_max_excess_ms"], "600")
|
||||
self.assertEqual(resolve_ws_overrides(payload)["audio_in_backlog_silence_dbfs"], "-60")
|
||||
fake = resolve_fake_agent_overrides(payload)
|
||||
self.assertEqual(fake["delay_ms"], 2500)
|
||||
self.assertEqual(len(fake["responses"]), 2)
|
||||
|
||||
def test_empty_payload_falls_back_to_defaults(self) -> None:
|
||||
payload = {}
|
||||
|
||||
self.assertEqual(
|
||||
build_call_config(payload),
|
||||
{
|
||||
"agent_backend": "",
|
||||
"stt": {
|
||||
"provider": "",
|
||||
"language": "",
|
||||
"api_key": "",
|
||||
"initial_prompt": "",
|
||||
"config_override": "",
|
||||
"min_prob_single_word": "",
|
||||
"disable_vosk": "",
|
||||
},
|
||||
"tts": {
|
||||
"provider": "",
|
||||
"voice_id": "",
|
||||
"model_id": "",
|
||||
"language": "",
|
||||
},
|
||||
"vad": {
|
||||
"min_speech_duration": "",
|
||||
"activation_threshold": "",
|
||||
"deactivation_threshold": "",
|
||||
"min_silence_duration": "",
|
||||
"prefix_padding_duration": "",
|
||||
"pre_backend_wait_notice_fast_on_vad_pause": "",
|
||||
"deferred_interruption_min_audio_ms": "",
|
||||
"deferred_interruption_enabled": "",
|
||||
},
|
||||
"vad_logging": {
|
||||
"log_decisions": "",
|
||||
"log_activity": "",
|
||||
"activity_min_probability": "",
|
||||
},
|
||||
"ws": {
|
||||
"output_gain": "",
|
||||
"audio_in_backlog_shed_enabled": "",
|
||||
"audio_in_backlog_shed_threshold_ms": "",
|
||||
"audio_in_backlog_shed_keep_ms": "",
|
||||
"audio_in_latency_metrics_enabled": "",
|
||||
"audio_in_latency_alert_ms": "",
|
||||
"audio_in_latency_log_interval_s": "",
|
||||
"livekit_audio_source_queue_size_ms": "",
|
||||
"livekit_audio_source_clear_on_shed": "",
|
||||
"audio_in_backlog_energy_shed_enabled": "",
|
||||
"audio_in_backlog_energy_shed_max_excess_ms": "",
|
||||
"audio_in_backlog_silence_dbfs": "",
|
||||
},
|
||||
"agent_fake": {"delay_ms": "", "responses": ""},
|
||||
},
|
||||
)
|
||||
self.assertEqual(resolve_agent_backend_name(payload, "remote_ws"), "remote_ws")
|
||||
|
||||
def test_fake_responses_are_trimmed_and_default_delay_is_applied(self) -> None:
|
||||
payload = {
|
||||
"agentBackend": "remote_ws_fake",
|
||||
"agentFake": {
|
||||
"responses": (
|
||||
" Primeira resposta simulada com comprimento intermediario ;"
|
||||
" Segunda resposta simulada encerrando corretamente a chamada "
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
fake = resolve_fake_agent_overrides(payload)
|
||||
|
||||
self.assertEqual(fake["delay_ms"], 2500)
|
||||
self.assertEqual(
|
||||
fake["responses"],
|
||||
[
|
||||
"Primeira resposta simulada com comprimento intermediario",
|
||||
"Segunda resposta simulada encerrando corretamente a chamada",
|
||||
],
|
||||
)
|
||||
|
||||
def test_fake_responses_reject_invalid_contract(self) -> None:
|
||||
valid = "Esta resposta simulada possui tamanho intermediario adequado"
|
||||
invalid_cases = [
|
||||
{"agentBackend": "remote_ws", "agentFake": {"responses": f"{valid};{valid}"}},
|
||||
{"agentBackend": "remote_ws_fake", "agentFake": {"responses": valid}},
|
||||
{"agentBackend": "remote_ws_fake", "agentFake": {"responses": f"{valid};;{valid}"}},
|
||||
{"agentBackend": "remote_ws_fake", "agentFake": {"responses": f"curta;{valid}"}},
|
||||
{
|
||||
"agentBackend": "remote_ws_fake",
|
||||
"agentFake": {"responses": f"{valid};{valid}", "delayMs": 180001},
|
||||
},
|
||||
]
|
||||
|
||||
for payload in invalid_cases:
|
||||
with self.subTest(payload=payload), self.assertRaises(ValueError):
|
||||
resolve_fake_agent_overrides(payload)
|
||||
|
||||
def test_disable_vosk_and_fake_backend_are_normalized_per_call(self) -> None:
|
||||
payload = {
|
||||
"agentBackend": "remote_ws_fake",
|
||||
"stt": {"provider": "internal_http", "disableVosk": True},
|
||||
}
|
||||
|
||||
self.assertEqual(resolve_agent_backend_name(payload, "remote_ws"), "remote_ws_fake")
|
||||
self.assertEqual(resolve_stt_overrides(payload)["disable_vosk"], "True")
|
||||
|
||||
def test_fast_vad_pause_override_lives_with_vad_overrides(self) -> None:
|
||||
payload = {"vad": {"preBackendWaitNoticeFastOnVadPause": True}}
|
||||
|
||||
self.assertEqual(
|
||||
resolve_vad_overrides(payload)["pre_backend_wait_notice_fast_on_vad_pause"],
|
||||
"True",
|
||||
)
|
||||
|
||||
def test_deferred_interruption_min_audio_override_lives_with_vad_overrides(self) -> None:
|
||||
payload = {"vad": {"deferredInterruptionMinAudioMs": 1250}}
|
||||
|
||||
self.assertEqual(
|
||||
resolve_vad_overrides(payload)["deferred_interruption_min_audio_ms"],
|
||||
"1250",
|
||||
)
|
||||
|
||||
def test_deferred_interruption_min_audio_accepts_root_env_style_key(self) -> None:
|
||||
payload = {"DEFERRED_INTERRUPTION_MIN_AUDIO_MS": 1400}
|
||||
|
||||
self.assertEqual(
|
||||
resolve_vad_overrides(payload)["deferred_interruption_min_audio_ms"],
|
||||
"1400",
|
||||
)
|
||||
|
||||
def test_deferred_interruption_enabled_accepts_root_env_style_key(self) -> None:
|
||||
payload = {"DEFERRED_INTERRUPTION_ENABLED": False}
|
||||
|
||||
self.assertEqual(
|
||||
resolve_vad_overrides(payload)["deferred_interruption_enabled"],
|
||||
"False",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
9
tests/conftest.py
Normal file
9
tests/conftest.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent.parent
|
||||
SRC_DIR = ROOT_DIR / "src"
|
||||
|
||||
if str(SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SRC_DIR))
|
||||
1
tests/livekit/__init__.py
Normal file
1
tests/livekit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
21
tests/livekit/test_agent_finalization.py
Normal file
21
tests/livekit/test_agent_finalization.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.livekit.policies.agent_finalization import (
|
||||
final_stop_from_agent_result,
|
||||
stop_status_for_agent_result_type,
|
||||
)
|
||||
|
||||
|
||||
def test_stop_status_for_agent_result_type_uses_conta_contract() -> None:
|
||||
assert stop_status_for_agent_result_type("resolvido") == "stop_resolvido_e_finalizado"
|
||||
assert stop_status_for_agent_result_type("nao_resolvido") == "stop_nao_resolvido"
|
||||
assert stop_status_for_agent_result_type("resolvido_outros_assuntos") == "stop_outro_assunto"
|
||||
assert stop_status_for_agent_result_type("outros_assuntos") == "stop_outro_assunto"
|
||||
assert stop_status_for_agent_result_type("erro_falha_sistema") == "stop_falha_sistema"
|
||||
assert stop_status_for_agent_result_type("erro_no_match") == "stop_no_match"
|
||||
|
||||
|
||||
def test_final_stop_from_agent_result_ignores_non_terminal_result_types() -> None:
|
||||
assert final_stop_from_agent_result({"type": "final", "content": "texto"}) is None
|
||||
assert final_stop_from_agent_result({"content": "texto"}) is None
|
||||
assert final_stop_from_agent_result(None) is None
|
||||
68
tests/livekit/test_bridge_gateway.py
Normal file
68
tests/livekit/test_bridge_gateway.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from app.livekit.adapters.bridge_gateway import BridgeGateway
|
||||
|
||||
|
||||
class _Participant:
|
||||
def __init__(self) -> None:
|
||||
self.calls = []
|
||||
|
||||
async def publish_data(self, payload, **kwargs) -> None:
|
||||
self.calls.append((json.loads(payload), kwargs))
|
||||
|
||||
|
||||
class _Room:
|
||||
name = "room-load-1"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.local_participant = _Participant()
|
||||
|
||||
|
||||
def test_publish_debug_event_targets_originating_bridge() -> None:
|
||||
async def _run() -> None:
|
||||
room = _Room()
|
||||
gateway = BridgeGateway(
|
||||
room=room,
|
||||
bridge_identity="bridge-load-1",
|
||||
protocol="LOAD-1",
|
||||
stress_test=True,
|
||||
)
|
||||
|
||||
await gateway.publish_debug_event(
|
||||
"stt.completed", duration_ms=123, text="texto reconhecido"
|
||||
)
|
||||
|
||||
payload, kwargs = room.local_participant.calls[0]
|
||||
assert payload["type"] == "debug_event"
|
||||
assert payload["event"] == "stt.completed"
|
||||
assert payload["stress_test"] is True
|
||||
assert payload["data"] == {
|
||||
"duration_ms": 123,
|
||||
"text": "texto reconhecido",
|
||||
}
|
||||
assert kwargs == {
|
||||
"reliable": True,
|
||||
"destination_identities": ["bridge-load-1"],
|
||||
"topic": "agent.debug",
|
||||
}
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_publish_debug_event_is_suppressed_outside_stress_test() -> None:
|
||||
async def _run() -> None:
|
||||
room = _Room()
|
||||
gateway = BridgeGateway(
|
||||
room=room,
|
||||
bridge_identity="bridge-regular-1",
|
||||
protocol="REGULAR-1",
|
||||
)
|
||||
|
||||
await gateway.publish_debug_event("stt.completed", duration_ms=123)
|
||||
|
||||
assert room.local_participant.calls == []
|
||||
|
||||
asyncio.run(_run())
|
||||
73
tests/livekit/test_compat.py
Normal file
73
tests/livekit/test_compat.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from app.livekit.compat import patch_inference_executor_is_alive
|
||||
|
||||
|
||||
def test_patch_inference_executor_is_alive_handles_closed_process(monkeypatch) -> None:
|
||||
fake_module = types.SimpleNamespace()
|
||||
|
||||
class FakeInferenceProcExecutor:
|
||||
def is_alive(self) -> bool:
|
||||
raise ValueError("process object is closed")
|
||||
|
||||
fake_module.InferenceProcExecutor = FakeInferenceProcExecutor
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.livekit.compat.import_module",
|
||||
lambda name: fake_module,
|
||||
)
|
||||
|
||||
patched = patch_inference_executor_is_alive()
|
||||
|
||||
assert patched is True
|
||||
assert FakeInferenceProcExecutor().is_alive() is False
|
||||
|
||||
|
||||
def test_patch_inference_executor_is_alive_preserves_other_value_errors(monkeypatch) -> None:
|
||||
fake_module = types.SimpleNamespace()
|
||||
|
||||
class FakeInferenceProcExecutor:
|
||||
def is_alive(self) -> bool:
|
||||
raise ValueError("unexpected failure")
|
||||
|
||||
fake_module.InferenceProcExecutor = FakeInferenceProcExecutor
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.livekit.compat.import_module",
|
||||
lambda name: fake_module,
|
||||
)
|
||||
|
||||
patch_inference_executor_is_alive()
|
||||
|
||||
with pytest.raises(ValueError, match="unexpected failure"):
|
||||
FakeInferenceProcExecutor().is_alive()
|
||||
|
||||
|
||||
def test_patch_inference_executor_is_alive_is_idempotent(monkeypatch) -> None:
|
||||
fake_module = types.SimpleNamespace()
|
||||
|
||||
class FakeInferenceProcExecutor:
|
||||
calls = 0
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
type(self).calls += 1
|
||||
raise ValueError("process object is closed")
|
||||
|
||||
fake_module.InferenceProcExecutor = FakeInferenceProcExecutor
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.livekit.compat.import_module",
|
||||
lambda name: fake_module,
|
||||
)
|
||||
|
||||
first = patch_inference_executor_is_alive()
|
||||
second = patch_inference_executor_is_alive()
|
||||
|
||||
assert first is True
|
||||
assert second is False
|
||||
assert FakeInferenceProcExecutor().is_alive() is False
|
||||
assert FakeInferenceProcExecutor.calls == 1
|
||||
96
tests/livekit/test_fake_remote_ws_adapter.py
Normal file
96
tests/livekit/test_fake_remote_ws_adapter.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.livekit.adapters.agent_backend import BackendReply
|
||||
from app.livekit.adapters.fake_remote_ws_adapter import FakeRemoteWSAdapter
|
||||
|
||||
|
||||
class FakeRemoteWSAdapterTests(unittest.IsolatedAsyncioTestCase):
|
||||
RESPONSES = (
|
||||
"Primeira resposta deterministica com comprimento intermediario",
|
||||
"Segunda resposta deterministica preparando a formalizacao",
|
||||
"Terceira resposta deterministica encerrando o atendimento",
|
||||
)
|
||||
|
||||
async def test_run_returns_mocked_reply_with_transcribed_text(self) -> None:
|
||||
adapter = FakeRemoteWSAdapter(
|
||||
intro="oi",
|
||||
request_context={
|
||||
"agent": "oferta",
|
||||
"RouterCallKeyDay": "20260329",
|
||||
"RouterCallKey": "0001",
|
||||
"ANI": "3133334444",
|
||||
"GSM": "31999999999",
|
||||
"callIdGed": "GED-123",
|
||||
},
|
||||
)
|
||||
await adapter.prepare(True, "PRT-123")
|
||||
|
||||
reply = await adapter.run({"text": "quero detalhes da oferta"})
|
||||
|
||||
self.assertEqual(reply.stage, "ARGUMENTATION")
|
||||
self.assertFalse(reply.done)
|
||||
self.assertIn("quero detalhes da oferta", reply.text.lower())
|
||||
|
||||
async def test_end_service_once_returns_done_without_endpoint_dependency(self) -> None:
|
||||
adapter = FakeRemoteWSAdapter(
|
||||
intro="oi",
|
||||
request_context={"agent": "conta", "GSM": "5511999999999"},
|
||||
)
|
||||
await adapter.prepare(True, "PRT-999")
|
||||
|
||||
reply = await adapter.end_service_once()
|
||||
|
||||
self.assertEqual(
|
||||
reply,
|
||||
BackendReply(
|
||||
stage="DONE",
|
||||
text="Atendimento simulado de conta encerrado. Obrigado.",
|
||||
done=True,
|
||||
export_payload={
|
||||
"type": "final",
|
||||
"content": "Atendimento simulado de conta encerrado. Obrigado.",
|
||||
"tool_calls": [],
|
||||
"result": [{"status": "ok", "reason": "fake_done"}],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
async def test_scripted_responses_are_sequential_and_end_idempotently(self) -> None:
|
||||
adapter = FakeRemoteWSAdapter(
|
||||
intro="saudacao normal",
|
||||
request_context={"agent": "oferta"},
|
||||
delay_ms=0,
|
||||
responses=self.RESPONSES,
|
||||
)
|
||||
await adapter.prepare(True, "PRT-SEQUENTIAL")
|
||||
|
||||
first = await adapter.run({"text": "texto do STT que nao influencia a resposta"})
|
||||
second = await adapter.run({"text": "outro texto arbitrario reconhecido"})
|
||||
third = await adapter.run({"text": "ultimo texto arbitrario reconhecido"})
|
||||
repeated = await adapter.run({"text": "texto posterior ao encerramento"})
|
||||
|
||||
self.assertEqual(
|
||||
[first.stage, second.stage, third.stage],
|
||||
["ARGUMENTATION", "FORMALIZATION", "DONE"],
|
||||
)
|
||||
self.assertEqual([first.text, second.text, third.text], list(self.RESPONSES))
|
||||
self.assertTrue(third.done)
|
||||
self.assertIs(repeated, third)
|
||||
|
||||
async def test_scripted_sequence_is_isolated_per_adapter_session(self) -> None:
|
||||
first_call = FakeRemoteWSAdapter(intro="oi", delay_ms=0, responses=self.RESPONSES)
|
||||
second_call = FakeRemoteWSAdapter(intro="oi", delay_ms=0, responses=self.RESPONSES)
|
||||
await first_call.prepare(True, "PRT-1")
|
||||
await second_call.prepare(True, "PRT-2")
|
||||
|
||||
await first_call.run("fala um")
|
||||
first_reply_second_call = await second_call.run("fala independente")
|
||||
|
||||
self.assertEqual(first_reply_second_call.text, self.RESPONSES[0])
|
||||
self.assertEqual(first_reply_second_call.stage, "ARGUMENTATION")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
64
tests/livekit/test_initial_greeting_audio_cache.py
Normal file
64
tests/livekit/test_initial_greeting_audio_cache.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from app.livekit.runtime.initial_greeting_audio_cache import InitialGreetingAudioCache
|
||||
|
||||
|
||||
class InitialGreetingAudioCacheTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_cache_defaults_are_enabled_and_bounded(self) -> None:
|
||||
with mock.patch.dict("os.environ", {}, clear=True):
|
||||
cache = InitialGreetingAudioCache()
|
||||
|
||||
self.assertTrue(cache.enabled)
|
||||
self.assertEqual(cache._max_chars, 1_000)
|
||||
self.assertEqual(cache._ttl_s, 3_600)
|
||||
self.assertEqual(cache._max_entries, 32)
|
||||
self.assertEqual(cache._max_bytes, 16 * 1024 * 1024)
|
||||
self.assertEqual(cache._max_agent_variants, 1)
|
||||
self.assertEqual(cache._disable_ttl_s, 300)
|
||||
key = cache.key_for(
|
||||
agent="conta",
|
||||
text="Olá, como posso ajudar?",
|
||||
provider="xAI",
|
||||
voice="ara",
|
||||
language="pt-BR",
|
||||
sample_rate=24000,
|
||||
)
|
||||
self.assertIsNotNone(key)
|
||||
assert key is not None
|
||||
self.assertEqual(key.agent, "conta")
|
||||
|
||||
async def test_store_writes_atomic_pcm_wav_for_matching_key(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"INITIAL_GREETING_AUDIO_CACHE_ENABLED": "1",
|
||||
"INITIAL_GREETING_AUDIO_CACHE_DIR": directory,
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
cache = InitialGreetingAudioCache()
|
||||
key = cache.key_for(
|
||||
agent="conta",
|
||||
text="Olá, como posso ajudar?",
|
||||
provider="xAI",
|
||||
voice="ara",
|
||||
language="pt-BR",
|
||||
sample_rate=24000,
|
||||
)
|
||||
assert key is not None
|
||||
await cache.store_pcm(key, b"\x00\x00" * 240)
|
||||
|
||||
path = Path(directory) / f"{key.digest}.wav"
|
||||
self.assertTrue(path.is_file())
|
||||
with wave.open(str(path), "rb") as rendered:
|
||||
self.assertEqual(rendered.getnchannels(), 1)
|
||||
self.assertEqual(rendered.getsampwidth(), 2)
|
||||
self.assertEqual(rendered.getframerate(), 24000)
|
||||
self.assertEqual(rendered.getnframes(), 240)
|
||||
1129
tests/livekit/test_remote_agent_sse_adapter.py
Normal file
1129
tests/livekit/test_remote_agent_sse_adapter.py
Normal file
File diff suppressed because it is too large
Load Diff
1078
tests/livekit/test_remote_agent_ws_adapter.py
Normal file
1078
tests/livekit/test_remote_agent_ws_adapter.py
Normal file
File diff suppressed because it is too large
Load Diff
4802
tests/livekit/test_runtime.py
Normal file
4802
tests/livekit/test_runtime.py
Normal file
File diff suppressed because it is too large
Load Diff
125
tests/livekit/test_vad_flow_logging.py
Normal file
125
tests/livekit/test_vad_flow_logging.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
from livekit.agents import vad as agents_vad
|
||||
|
||||
from app.livekit.main import FlowLoggingVADStream
|
||||
|
||||
|
||||
def _event(
|
||||
event_type: agents_vad.VADEventType,
|
||||
*,
|
||||
speech_s: float,
|
||||
raw_speech_s: float | None = None,
|
||||
silence_s: float = 0.0,
|
||||
speaking: bool = False,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
type=event_type,
|
||||
speech_duration=speech_s,
|
||||
raw_accumulated_speech=speech_s if raw_speech_s is None else raw_speech_s,
|
||||
silence_duration=silence_s,
|
||||
raw_accumulated_silence=silence_s,
|
||||
probability=0.0,
|
||||
speaking=speaking,
|
||||
)
|
||||
|
||||
|
||||
def _stream(on_speech_end) -> FlowLoggingVADStream:
|
||||
return FlowLoggingVADStream(
|
||||
SimpleNamespace(),
|
||||
stream_id=0,
|
||||
should_log=True,
|
||||
release_logging_stream=lambda _stream_id: None,
|
||||
call_logger=logging.getLogger(__name__),
|
||||
min_interrupt_s=0.5,
|
||||
vad_config={
|
||||
"min_speech_duration": 0.15,
|
||||
"min_silence_duration": 1.0,
|
||||
"activation_threshold": 0.3,
|
||||
"deactivation_threshold": 0.15,
|
||||
},
|
||||
log_decisions=False,
|
||||
log_activity=False,
|
||||
activity_min_prob=0.0,
|
||||
on_speech_end=on_speech_end,
|
||||
)
|
||||
|
||||
|
||||
def test_speech_end_uses_final_duration_without_terminal_vad_silence() -> None:
|
||||
durations_ms: list[int] = []
|
||||
stream = _stream(durations_ms.append)
|
||||
|
||||
stream._log_vad_decision(
|
||||
_event(agents_vad.VADEventType.START_OF_SPEECH, speech_s=0.15)
|
||||
)
|
||||
# While Silero waits for the endpoint, INFERENCE_DONE keeps increasing
|
||||
# speech_duration with the terminal silence.
|
||||
stream._log_vad_decision(
|
||||
_event(
|
||||
agents_vad.VADEventType.INFERENCE_DONE,
|
||||
speech_s=1.2,
|
||||
raw_speech_s=0.2,
|
||||
silence_s=1.0,
|
||||
)
|
||||
)
|
||||
# END_OF_SPEECH reports the corrected duration after removing that silence.
|
||||
stream._log_vad_decision(
|
||||
_event(
|
||||
agents_vad.VADEventType.END_OF_SPEECH,
|
||||
speech_s=0.2,
|
||||
raw_speech_s=0.2,
|
||||
silence_s=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert durations_ms == [200]
|
||||
|
||||
|
||||
def test_short_audio_stays_short_after_a_previous_long_round() -> None:
|
||||
durations_ms: list[int] = []
|
||||
stream = _stream(durations_ms.append)
|
||||
|
||||
stream._log_vad_decision(
|
||||
_event(agents_vad.VADEventType.START_OF_SPEECH, speech_s=0.15)
|
||||
)
|
||||
stream._log_vad_decision(
|
||||
_event(
|
||||
agents_vad.VADEventType.INFERENCE_DONE,
|
||||
speech_s=2.2,
|
||||
raw_speech_s=1.2,
|
||||
silence_s=1.0,
|
||||
)
|
||||
)
|
||||
stream._log_vad_decision(
|
||||
_event(
|
||||
agents_vad.VADEventType.END_OF_SPEECH,
|
||||
speech_s=1.2,
|
||||
raw_speech_s=1.2,
|
||||
silence_s=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
stream._log_vad_decision(
|
||||
_event(agents_vad.VADEventType.START_OF_SPEECH, speech_s=0.15)
|
||||
)
|
||||
stream._log_vad_decision(
|
||||
_event(
|
||||
agents_vad.VADEventType.INFERENCE_DONE,
|
||||
speech_s=1.2,
|
||||
raw_speech_s=0.2,
|
||||
silence_s=1.0,
|
||||
)
|
||||
)
|
||||
stream._log_vad_decision(
|
||||
_event(
|
||||
agents_vad.VADEventType.END_OF_SPEECH,
|
||||
speech_s=0.2,
|
||||
raw_speech_s=0.2,
|
||||
silence_s=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert durations_ms == [1200, 200]
|
||||
64
tests/livekit/test_wav_audio.py
Normal file
64
tests/livekit/test_wav_audio.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import types
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from app.livekit.runtime.wav_audio import wav_audio_frames, wav_duration_ms
|
||||
|
||||
|
||||
class _AudioFrame:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
data: bytes,
|
||||
sample_rate: int,
|
||||
num_channels: int,
|
||||
samples_per_channel: int,
|
||||
) -> None:
|
||||
self.data = data
|
||||
self.sample_rate = sample_rate
|
||||
self.num_channels = num_channels
|
||||
self.samples_per_channel = samples_per_channel
|
||||
|
||||
|
||||
def _write_wav(path: Path, *, samples: int, sample_rate: int = 1000) -> None:
|
||||
with wave.open(str(path), "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(sample_rate)
|
||||
wav.writeframes(b"\x01\x02" * samples)
|
||||
|
||||
|
||||
def test_wav_audio_frames_pads_last_frame_and_adds_tail_silence(tmp_path: Path) -> None:
|
||||
wav_path = tmp_path / "audio.wav"
|
||||
_write_wav(wav_path, samples=25)
|
||||
|
||||
livekit_module = types.ModuleType("livekit")
|
||||
rtc_module = types.ModuleType("livekit.rtc")
|
||||
rtc_module.AudioFrame = _AudioFrame
|
||||
livekit_module.rtc = rtc_module
|
||||
|
||||
async def _collect():
|
||||
with mock.patch.dict(sys.modules, {"livekit": livekit_module, "livekit.rtc": rtc_module}):
|
||||
return [
|
||||
frame
|
||||
async for frame in wav_audio_frames(
|
||||
str(wav_path),
|
||||
frame_duration_ms=20,
|
||||
tail_silence_ms=40,
|
||||
)
|
||||
]
|
||||
|
||||
frames = asyncio.run(_collect())
|
||||
|
||||
assert len(frames) == 4
|
||||
assert [frame.samples_per_channel for frame in frames] == [20, 20, 20, 20]
|
||||
assert frames[1].data[:10] == b"\x01\x02" * 5
|
||||
assert frames[1].data[10:] == b"\x00" * 30
|
||||
assert frames[2].data == b"\x00" * 40
|
||||
assert frames[3].data == b"\x00" * 40
|
||||
assert wav_duration_ms(str(wav_path)) == 25
|
||||
1
tests/providers/__init__.py
Normal file
1
tests/providers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from __future__ import annotations
|
||||
57
tests/providers/test_fake_stt.py
Normal file
57
tests/providers/test_fake_stt.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
import unittest
|
||||
|
||||
from livekit import rtc
|
||||
|
||||
from app.providers.stt_fake import FakeSTT
|
||||
|
||||
|
||||
def _audio_frame(samples_per_channel: int) -> rtc.AudioFrame:
|
||||
return rtc.AudioFrame(
|
||||
data=b"\x00\x00" * samples_per_channel,
|
||||
sample_rate=16000,
|
||||
num_channels=1,
|
||||
samples_per_channel=samples_per_channel,
|
||||
)
|
||||
|
||||
|
||||
class FakeSTTTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_fake_stt_returns_configured_transcripts_in_order(self) -> None:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"FAKE_STT_TRANSCRIPTS": "primeira|segunda",
|
||||
"FAKE_STT_MODE": "repeat_last",
|
||||
"FAKE_STT_MIN_AUDIO_MS": "0",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
stt = FakeSTT(language="pt-BR")
|
||||
first = await stt.recognize([_audio_frame(3200)])
|
||||
second = await stt.recognize([_audio_frame(3200)])
|
||||
third = await stt.recognize([_audio_frame(3200)])
|
||||
|
||||
self.assertEqual(first.alternatives[0].text, "primeira")
|
||||
self.assertEqual(second.alternatives[0].text, "segunda")
|
||||
self.assertEqual(third.alternatives[0].text, "segunda")
|
||||
|
||||
async def test_fake_stt_skips_too_short_audio(self) -> None:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"FAKE_STT_TRANSCRIPTS": "fala",
|
||||
"FAKE_STT_MIN_AUDIO_MS": "200",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
stt = FakeSTT(language="pt-BR")
|
||||
event = await stt.recognize([_audio_frame(800)])
|
||||
|
||||
self.assertEqual(event.alternatives[0].text, "")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
465
tests/providers/test_internal_http_stt.py
Normal file
465
tests/providers/test_internal_http_stt.py
Normal file
@@ -0,0 +1,465 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import unittest
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
import httpx
|
||||
|
||||
from app.providers.stt_internal_livekit import (
|
||||
InternalHTTPSTT,
|
||||
InternalSTTConfig,
|
||||
_prepend_pcm16le_silence,
|
||||
stt_text_with_single_word_threshold,
|
||||
)
|
||||
from app.providers.stt_config import override_config
|
||||
from app.utils import logging as logging_utils
|
||||
from app.utils.turn_ids import (
|
||||
peek_started_turn_message_id,
|
||||
register_started_turn_message_id,
|
||||
reset_turn_message_sequence,
|
||||
)
|
||||
|
||||
|
||||
def _assert_uuid(testcase: unittest.TestCase, value: object) -> str:
|
||||
text = str(value or "")
|
||||
testcase.assertEqual(str(uuid.UUID(text)), text)
|
||||
return text
|
||||
|
||||
|
||||
def _single_word_payload(text: str, probability: float) -> dict:
|
||||
return {
|
||||
"data": {
|
||||
"text": text,
|
||||
"words": [{"word": text, "probability": probability}],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class InternalHTTPSTTTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_provider_can_be_instantiated(self) -> None:
|
||||
client = httpx.AsyncClient()
|
||||
try:
|
||||
stt = InternalHTTPSTT(
|
||||
InternalSTTConfig(
|
||||
url="https://example.invalid/stt",
|
||||
api_key="test-key",
|
||||
),
|
||||
client=client,
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
self.assertEqual(stt.provider, "unknown")
|
||||
|
||||
def test_default_sofya_vad_padding_keeps_more_prefix_audio(self) -> None:
|
||||
vad_params = override_config["processor"]["config"]["extra_params"]["vad_parameters"]
|
||||
|
||||
self.assertEqual(vad_params["speech_pad_ms"], 1000)
|
||||
|
||||
def test_stt_input_prefix_padding_prepends_silence(self) -> None:
|
||||
pcm = b"\x01\x00" * 16000
|
||||
|
||||
padded, padding_ms = _prepend_pcm16le_silence(
|
||||
pcm,
|
||||
sample_rate=16000,
|
||||
channels=1,
|
||||
padding_ms=250,
|
||||
)
|
||||
|
||||
self.assertEqual(padding_ms, 250)
|
||||
self.assertEqual(len(padded), len(pcm) + 8000)
|
||||
self.assertTrue(padded.startswith(b"\x00" * 8000))
|
||||
self.assertTrue(padded.endswith(pcm))
|
||||
|
||||
async def test_config_override_is_used_as_json_object(self) -> None:
|
||||
client = httpx.AsyncClient()
|
||||
try:
|
||||
stt = InternalHTTPSTT(
|
||||
InternalSTTConfig(
|
||||
url="https://example.invalid/stt",
|
||||
api_key="test-key",
|
||||
config_override='{"processor":{"strategy":"faster_default"}}',
|
||||
),
|
||||
client=client,
|
||||
)
|
||||
override = json.loads(stt._build_override_config_json())
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
self.assertEqual(override["processor"]["strategy"], "faster_default")
|
||||
|
||||
async def test_http_success_with_empty_text_does_not_create_structured_turn(self) -> None:
|
||||
published_events = []
|
||||
empty_transcript_calls = []
|
||||
captured_headers: dict[str, str] = {}
|
||||
|
||||
def _handler(request: httpx.Request) -> httpx.Response:
|
||||
captured_headers["connection"] = request.headers.get("connection", "")
|
||||
return httpx.Response(200, json={"data": {"text": ""}}, request=request)
|
||||
|
||||
logger = logging.getLogger("test.internal_http_stt.structured")
|
||||
logger.handlers = [logging.NullHandler()]
|
||||
logger.setLevel(logging.INFO)
|
||||
structured_context = logging_utils.StructuredLogContext(
|
||||
callid="call-1",
|
||||
session_id="session-1",
|
||||
num_telefone="5511999999999",
|
||||
cod_ani="1234",
|
||||
nome_agente="conta",
|
||||
)
|
||||
reset_turn_message_sequence(structured_context, clear_pending=True)
|
||||
upload_message_id = "12345678-1234-4234-9234-123456789abc"
|
||||
register_started_turn_message_id(structured_context, upload_message_id)
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(_handler))
|
||||
try:
|
||||
stt = InternalHTTPSTT(
|
||||
InternalSTTConfig(
|
||||
url="https://example.invalid/stt",
|
||||
api_key="test-key",
|
||||
output_mode="api_text",
|
||||
),
|
||||
client=client,
|
||||
structured_log_context=structured_context,
|
||||
structured_logger=logger,
|
||||
empty_transcript_handler=lambda: empty_transcript_calls.append("called"),
|
||||
)
|
||||
with (
|
||||
mock.patch("app.utils.logging.publish_structured_event", side_effect=published_events.append),
|
||||
mock.patch("app.utils.logging.publish_structured_span"),
|
||||
mock.patch("app.providers.stt_internal_livekit.log_flow_event") as flow_log,
|
||||
mock.patch("app.providers.stt_internal_livekit.logger.info") as logger_info,
|
||||
):
|
||||
event = await stt._post_internal_http(
|
||||
b"\0\0",
|
||||
b"fake-wav",
|
||||
req_id="req-1",
|
||||
started_ns=1_700_000_000_000_000_000,
|
||||
language="pt-BR",
|
||||
message_id=upload_message_id,
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
self.assertEqual(event.alternatives[0].text, "")
|
||||
self.assertEqual(captured_headers["connection"], "close")
|
||||
self.assertEqual(published_events, [])
|
||||
self.assertEqual(peek_started_turn_message_id(structured_context), "")
|
||||
self.assertTrue(
|
||||
any(
|
||||
call.args[1] == "stt_done"
|
||||
and call.kwargs["request_id"] == "req-1"
|
||||
and call.kwargs["text_len"] == 0
|
||||
for call in flow_log.call_args_list
|
||||
)
|
||||
)
|
||||
self.assertEqual(empty_transcript_calls, ["called"])
|
||||
logger_info.assert_any_call(
|
||||
"[stt][response_json] req_id=%s status=%s took=%.0fms json=%s",
|
||||
"req-1",
|
||||
200,
|
||||
mock.ANY,
|
||||
'{"data": {"text": ""}}',
|
||||
)
|
||||
|
||||
async def test_http_success_marks_structured_interruption(self) -> None:
|
||||
published_events = []
|
||||
provider_metrics = []
|
||||
|
||||
def _handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"data": {"text": "sim"}}, request=request)
|
||||
|
||||
logger = logging.getLogger("test.internal_http_stt.interruption")
|
||||
logger.handlers = [logging.NullHandler()]
|
||||
logger.setLevel(logging.INFO)
|
||||
structured_context = logging_utils.StructuredLogContext(
|
||||
callid="call-1",
|
||||
session_id="session-1",
|
||||
num_telefone="5511999999999",
|
||||
cod_ani="1234",
|
||||
nome_agente="conta",
|
||||
)
|
||||
reset_turn_message_sequence(structured_context, clear_pending=True)
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(_handler))
|
||||
try:
|
||||
stt = InternalHTTPSTT(
|
||||
InternalSTTConfig(
|
||||
url="https://example.invalid/stt",
|
||||
api_key="test-key",
|
||||
output_mode="api_text",
|
||||
),
|
||||
client=client,
|
||||
structured_log_context=structured_context,
|
||||
structured_logger=logger,
|
||||
structured_interruption_flag=lambda: True,
|
||||
metrics_handler=provider_metrics.append,
|
||||
)
|
||||
with (
|
||||
mock.patch("app.utils.logging.publish_structured_event", side_effect=published_events.append),
|
||||
mock.patch("app.utils.logging.publish_structured_span"),
|
||||
):
|
||||
await stt._post_internal_http(
|
||||
b"\0\0",
|
||||
b"fake-wav",
|
||||
req_id="req-1",
|
||||
started_ns=1_700_000_000_000_000_000,
|
||||
language="pt-BR",
|
||||
audio_duration_ms=1250,
|
||||
original_audio_duration_ms=1000,
|
||||
input_padding_ms=250,
|
||||
level_dbfs=-24.5,
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
self.assertEqual(len(published_events), 1)
|
||||
self.assertEqual(published_events[0]["interrupcao"], 1)
|
||||
self.assertEqual(len(provider_metrics), 1)
|
||||
self.assertEqual(provider_metrics[0]["event"], "completed")
|
||||
self.assertEqual(provider_metrics[0]["audio_duration_ms"], 1250)
|
||||
self.assertEqual(provider_metrics[0]["original_audio_duration_ms"], 1000)
|
||||
self.assertEqual(provider_metrics[0]["input_padding_ms"], 250)
|
||||
self.assertEqual(provider_metrics[0]["input_dbfs"], -24.5)
|
||||
self.assertEqual(provider_metrics[0]["retry_count"], 0)
|
||||
self.assertEqual(provider_metrics[0]["http_status"], 200)
|
||||
self.assertFalse(provider_metrics[0]["empty_transcript"])
|
||||
self.assertEqual(provider_metrics[0]["text_length"], 3)
|
||||
|
||||
async def test_http_success_uses_supplied_message_id_for_structured_event(self) -> None:
|
||||
published_events = []
|
||||
|
||||
def _handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"data": {"text": "sim"}}, request=request)
|
||||
|
||||
logger = logging.getLogger("test.internal_http_stt.message_id")
|
||||
logger.handlers = [logging.NullHandler()]
|
||||
logger.setLevel(logging.INFO)
|
||||
structured_context = logging_utils.StructuredLogContext(
|
||||
callid="call-1",
|
||||
session_id="session-1",
|
||||
num_telefone="5511999999999",
|
||||
cod_ani="1234",
|
||||
nome_agente="conta",
|
||||
)
|
||||
reset_turn_message_sequence(structured_context, clear_pending=True)
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(_handler))
|
||||
try:
|
||||
stt = InternalHTTPSTT(
|
||||
InternalSTTConfig(
|
||||
url="https://example.invalid/stt",
|
||||
api_key="test-key",
|
||||
output_mode="api_text",
|
||||
),
|
||||
client=client,
|
||||
structured_log_context=structured_context,
|
||||
structured_logger=logger,
|
||||
)
|
||||
with (
|
||||
mock.patch("app.utils.logging.publish_structured_event", side_effect=published_events.append),
|
||||
mock.patch("app.utils.logging.publish_structured_span"),
|
||||
):
|
||||
await stt._post_internal_http(
|
||||
b"\0\0",
|
||||
b"fake-wav",
|
||||
req_id="req-1",
|
||||
started_ns=1_700_000_000_000_000_000,
|
||||
language="pt-BR",
|
||||
message_id="message-from-upload-path",
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
self.assertEqual(len(published_events), 1)
|
||||
self.assertEqual(published_events[0]["message_id"], "message-from-upload-path")
|
||||
|
||||
async def test_http_500_retries_once_then_uses_success(self) -> None:
|
||||
attempts = 0
|
||||
|
||||
def _handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
return httpx.Response(500, text="temporary", request=request)
|
||||
return httpx.Response(200, json={"data": {"text": "sim"}}, request=request)
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(_handler))
|
||||
try:
|
||||
stt = InternalHTTPSTT(
|
||||
InternalSTTConfig(
|
||||
url="https://example.invalid/stt",
|
||||
api_key="test-key",
|
||||
output_mode="api_text",
|
||||
),
|
||||
client=client,
|
||||
)
|
||||
with mock.patch("app.providers.stt_internal_livekit.log_flow_event") as flow_log:
|
||||
event = await stt._post_internal_http(
|
||||
b"\0\0",
|
||||
b"fake-wav",
|
||||
req_id="req-retry",
|
||||
started_ns=1_700_000_000_000_000_000,
|
||||
language="pt-BR",
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
self.assertEqual(attempts, 2)
|
||||
self.assertEqual(event.alternatives[0].text, "sim")
|
||||
self.assertTrue(
|
||||
any(
|
||||
call.args[1] == "stt_http_retry"
|
||||
and call.kwargs["request_id"] == "req-retry"
|
||||
and call.kwargs["attempt"] == 1
|
||||
and call.kwargs["max_retries"] == 1
|
||||
for call in flow_log.call_args_list
|
||||
)
|
||||
)
|
||||
|
||||
async def test_http_500_after_retry_returns_empty_transcript_without_raising(self) -> None:
|
||||
attempts = 0
|
||||
published_events = []
|
||||
|
||||
def _handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return httpx.Response(500, text="still failing", request=request)
|
||||
|
||||
logger = logging.getLogger("test.internal_http_stt.http_500_nonfatal")
|
||||
logger.handlers = [logging.NullHandler()]
|
||||
logger.setLevel(logging.INFO)
|
||||
structured_context = logging_utils.StructuredLogContext(
|
||||
callid="call-1",
|
||||
session_id="session-1",
|
||||
num_telefone="5511999999999",
|
||||
cod_ani="1234",
|
||||
nome_agente="conta",
|
||||
)
|
||||
reset_turn_message_sequence(structured_context, clear_pending=True)
|
||||
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(_handler))
|
||||
try:
|
||||
stt = InternalHTTPSTT(
|
||||
InternalSTTConfig(
|
||||
url="https://example.invalid/stt",
|
||||
api_key="test-key",
|
||||
output_mode="api_text",
|
||||
),
|
||||
client=client,
|
||||
structured_log_context=structured_context,
|
||||
structured_logger=logger,
|
||||
)
|
||||
with (
|
||||
mock.patch("app.utils.logging.publish_structured_event", side_effect=published_events.append),
|
||||
mock.patch("app.utils.logging.publish_structured_span"),
|
||||
mock.patch("app.providers.stt_internal_livekit.log_flow_event") as flow_log,
|
||||
):
|
||||
event = await stt._post_internal_http(
|
||||
b"\0\0",
|
||||
b"fake-wav",
|
||||
req_id="req-500",
|
||||
started_ns=1_700_000_000_000_000_000,
|
||||
language="pt-BR",
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
self.assertEqual(attempts, 2)
|
||||
self.assertEqual(event.alternatives[0].text, "")
|
||||
self.assertEqual(len(published_events), 1)
|
||||
self.assertEqual(published_events[0]["erro_msg"], "Falha STT")
|
||||
self.assertEqual(published_events[0]["http_cod_status"], 500)
|
||||
self.assertTrue(
|
||||
any(
|
||||
call.args[1] == "stt_error_nonfatal"
|
||||
and call.kwargs["request_id"] == "req-500"
|
||||
and call.kwargs["action"] == "return_empty_transcript"
|
||||
for call in flow_log.call_args_list
|
||||
)
|
||||
)
|
||||
|
||||
def test_single_word_allowlist_accepts_low_confidence_sim(self) -> None:
|
||||
self.assertEqual(
|
||||
stt_text_with_single_word_threshold(
|
||||
_single_word_payload("sim", 0.02),
|
||||
min_prob_single_word=0.03,
|
||||
),
|
||||
"sim",
|
||||
)
|
||||
|
||||
def test_single_word_allowlist_accepts_extremely_low_confidence_sim(self) -> None:
|
||||
self.assertEqual(
|
||||
stt_text_with_single_word_threshold(
|
||||
_single_word_payload("sim", 0.009),
|
||||
min_prob_single_word=0.03,
|
||||
),
|
||||
"sim",
|
||||
)
|
||||
|
||||
def test_single_word_filter_rejects_non_allowlisted_low_confidence_word(self) -> None:
|
||||
self.assertIsNone(
|
||||
stt_text_with_single_word_threshold(
|
||||
_single_word_payload("talvez", 0.02),
|
||||
min_prob_single_word=0.03,
|
||||
)
|
||||
)
|
||||
|
||||
def test_single_word_filter_accepts_non_allowlisted_high_confidence_word(self) -> None:
|
||||
self.assertEqual(
|
||||
stt_text_with_single_word_threshold(
|
||||
_single_word_payload("talvez", 0.04),
|
||||
min_prob_single_word=0.03,
|
||||
),
|
||||
"talvez",
|
||||
)
|
||||
|
||||
def test_single_word_filter_keeps_api_text_empty_as_empty(self) -> None:
|
||||
self.assertIsNone(
|
||||
stt_text_with_single_word_threshold(
|
||||
{
|
||||
"data": {
|
||||
"text": "",
|
||||
"words": [{"word": "sim", "probability": 0.5}],
|
||||
}
|
||||
},
|
||||
min_prob_single_word=0.03,
|
||||
)
|
||||
)
|
||||
|
||||
async def test_single_word_allowlist_logs_low_confidence_reason(self) -> None:
|
||||
client = httpx.AsyncClient()
|
||||
try:
|
||||
stt = InternalHTTPSTT(
|
||||
InternalSTTConfig(
|
||||
url="https://example.invalid/stt",
|
||||
api_key="test-key",
|
||||
min_prob_single_word=0.03,
|
||||
),
|
||||
client=client,
|
||||
)
|
||||
with mock.patch("app.providers.stt_internal_livekit.log_flow_event") as flow_log:
|
||||
text = stt._format_stt_output(
|
||||
_single_word_payload("sim", 0.02),
|
||||
request_id="req-allowlist",
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
self.assertEqual(text, "sim")
|
||||
self.assertTrue(
|
||||
any(
|
||||
call.args[1] == "stt_payload"
|
||||
and call.kwargs["request_id"] == "req-allowlist"
|
||||
and call.kwargs["filter_reason"] == "single_word_allowlist_low_confidence"
|
||||
and call.kwargs["allowlist_min_prob"] == 0.0
|
||||
for call in flow_log.call_args_list
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
51
tests/providers/test_tts.py
Normal file
51
tests/providers/test_tts.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest import mock
|
||||
import unittest
|
||||
|
||||
from app.providers import tts as tts_module
|
||||
from app.providers.tts import FakeTTS, build_tts_provider_from_env
|
||||
|
||||
|
||||
class ProviderTTSTests(unittest.TestCase):
|
||||
def test_build_tts_provider_from_env_returns_reason_when_provider_is_unsupported(self) -> None:
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
provider, reason = build_tts_provider_from_env("azure")
|
||||
|
||||
self.assertIsNone(provider)
|
||||
self.assertEqual(reason, "unsupported_tts_provider:azure")
|
||||
|
||||
def test_build_tts_provider_from_env_returns_reason_when_elevenlabs_sdk_is_missing(self) -> None:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"ELEVENLABS_API_KEY": "key-123",
|
||||
"ELEVENLABS_VOICE_ID": "voice-123",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
with mock.patch.object(tts_module, "_is_elevenlabs_available", return_value=False):
|
||||
provider, reason = build_tts_provider_from_env("elevenlabs")
|
||||
|
||||
self.assertIsNone(provider)
|
||||
self.assertEqual(reason, "missing_elevenlabs_sdk")
|
||||
|
||||
def test_build_tts_provider_from_env_returns_fake_provider(self) -> None:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"FAKE_TTS_TONE_HZ": "512",
|
||||
"FAKE_TTS_CHAR_DURATION_MS": "18",
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
provider, reason = build_tts_provider_from_env("fake")
|
||||
|
||||
self.assertIsInstance(provider, FakeTTS)
|
||||
self.assertIsNone(reason)
|
||||
self.assertGreater(len(provider.synthesize_pcm16k("teste fake")), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
52
tests/services/test_session_context.py
Normal file
52
tests/services/test_session_context.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.session_context import extract_protocol
|
||||
|
||||
|
||||
def test_extract_protocol_prefers_start_payload_data() -> None:
|
||||
protocol = extract_protocol(
|
||||
{
|
||||
"data": {
|
||||
"protocolo": "PRT-123",
|
||||
}
|
||||
},
|
||||
{"protocolo": "PRT-999"},
|
||||
)
|
||||
|
||||
assert protocol == "PRT-123"
|
||||
|
||||
|
||||
def test_extract_protocol_falls_back_to_session_data() -> None:
|
||||
protocol = extract_protocol(
|
||||
{"data": {}},
|
||||
{"protocolo": "PRT-999"},
|
||||
)
|
||||
|
||||
assert protocol == "PRT-999"
|
||||
|
||||
|
||||
def test_extract_protocol_falls_back_to_router_call_key_when_protocol_is_missing() -> None:
|
||||
protocol = extract_protocol(
|
||||
{
|
||||
"data": {
|
||||
"routerCallKey": "RCK-123",
|
||||
}
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
assert protocol == "RCK-123"
|
||||
|
||||
|
||||
def test_extract_protocol_accepts_protocol_id() -> None:
|
||||
protocol = extract_protocol(
|
||||
{
|
||||
"data": {
|
||||
"protocol_id": "PRT-777",
|
||||
"routerCallKey": "RCK-123",
|
||||
}
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
assert protocol == "PRT-777"
|
||||
1
tests/tools/__init__.py
Normal file
1
tests/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for operational tooling."""
|
||||
258
tests/tools/test_local_stresstest.py
Normal file
258
tests/tools/test_local_stresstest.py
Normal file
@@ -0,0 +1,258 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import math
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
from app.tools.local_stresstest.audio import (
|
||||
AudioSample,
|
||||
audio_metrics,
|
||||
build_variations,
|
||||
read_wav_mono16,
|
||||
vad_proxy_metrics,
|
||||
write_wav,
|
||||
)
|
||||
from app.tools.local_stresstest.report import render_markdown_report, write_csv, write_mermaid_files
|
||||
from app.tools.local_stresstest.runner import StressConfig, wait_for_local_services
|
||||
from app.tools.local_stresstest.scenarios import scenario_description
|
||||
from app.tools.local_stresstest.text import compare_text, normalize_text, word_error_rate
|
||||
from app.tools.local_stresstest.timeline import excerpt_timeline, read_timeline, timeline_has_error
|
||||
|
||||
|
||||
def _tone_pcm(*, sample_rate: int = 16_000, duration_ms: int = 300, hz: float = 440.0) -> bytes:
|
||||
samples = round(sample_rate * duration_ms / 1000)
|
||||
out = bytearray()
|
||||
for idx in range(samples):
|
||||
value = round(math.sin(2 * math.pi * hz * idx / sample_rate) * 9000)
|
||||
out.extend(int(value).to_bytes(2, byteorder="little", signed=True))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def test_normalize_text_removes_accents_case_and_punctuation() -> None:
|
||||
assert normalize_text(" Teste UNITÁRIO, Sofya! ") == "teste unitario sofya"
|
||||
|
||||
|
||||
def test_word_error_rate_counts_insertions_deletions_and_substitutions() -> None:
|
||||
wer, substitutions, deletions, insertions = word_error_rate(
|
||||
"teste unitario do stt sofya",
|
||||
"teste unitario stt sofia agora",
|
||||
)
|
||||
|
||||
assert round(wer, 2) == 0.6
|
||||
assert substitutions == 1
|
||||
assert deletions == 1
|
||||
assert insertions == 1
|
||||
|
||||
|
||||
def test_compare_text_reports_missing_critical_terms() -> None:
|
||||
comparison = compare_text(
|
||||
expected="Teste unitario do STT Sofya",
|
||||
actual="Teste unitario do STT",
|
||||
critical_terms=["teste", "sofya"],
|
||||
)
|
||||
|
||||
assert comparison.terms_ok is False
|
||||
assert comparison.missing_terms == ("sofya",)
|
||||
|
||||
|
||||
def test_audio_variations_keep_expected_names_and_are_nonempty() -> None:
|
||||
sample = AudioSample(name="base", pcm=_tone_pcm())
|
||||
variations = build_variations(sample)
|
||||
|
||||
assert [item.name for item in variations] == [
|
||||
"clean",
|
||||
"low_volume",
|
||||
"very_low_volume",
|
||||
"high_volume",
|
||||
"clipped_high_volume",
|
||||
"leading_trailing_silence",
|
||||
"short_leading_silence",
|
||||
"long_leading_silence",
|
||||
"noise_snr_20",
|
||||
"noise_snr_15",
|
||||
"noise_snr_10",
|
||||
"pre_noise_300ms",
|
||||
"pre_noise_800ms",
|
||||
"telephony_profile",
|
||||
"telephony_low_volume",
|
||||
"initial_fade_in_250ms",
|
||||
"initial_fade_in_500ms",
|
||||
"initial_fade_in_900ms",
|
||||
"initial_dip_300ms",
|
||||
"initial_dip_700ms",
|
||||
"prefix_300ms_15pct",
|
||||
"prefix_600ms_20pct",
|
||||
"prefix_900ms_25pct",
|
||||
"low_prefix_noise_600ms",
|
||||
"low_prefix_telephony_700ms",
|
||||
]
|
||||
assert all(item.pcm for item in variations)
|
||||
assert len(variations[5].pcm) > len(sample.pcm)
|
||||
assert len(variations) == 25
|
||||
|
||||
|
||||
def test_audio_metrics_detects_audible_tone_and_duration() -> None:
|
||||
metrics = audio_metrics(_tone_pcm(duration_ms=500))
|
||||
|
||||
assert metrics.duration_ms == 500
|
||||
assert metrics.audible is True
|
||||
assert metrics.clipping_ratio == 0.0
|
||||
|
||||
|
||||
def test_vad_proxy_metrics_flags_soft_start_risk() -> None:
|
||||
sample = AudioSample(name="base", pcm=(b"\x00" * 1600) + _tone_pcm(duration_ms=300))
|
||||
metrics = vad_proxy_metrics(sample, threshold_dbfs=-45.0, prefix_padding_ms=20)
|
||||
|
||||
assert metrics.first_voice_ms > 0
|
||||
assert metrics.unrecovered_prefix_ms > 0
|
||||
assert metrics.low_start_risk is True
|
||||
|
||||
|
||||
def test_wav_read_converts_to_16k_mono(tmp_path: Path) -> None:
|
||||
stereo_path = tmp_path / "stereo.wav"
|
||||
left = _tone_pcm(sample_rate=8_000, duration_ms=100)
|
||||
stereo = bytearray()
|
||||
for idx in range(0, len(left), 2):
|
||||
stereo.extend(left[idx : idx + 2])
|
||||
stereo.extend(left[idx : idx + 2])
|
||||
with wave.open(str(stereo_path), "wb") as handle:
|
||||
handle.setnchannels(2)
|
||||
handle.setsampwidth(2)
|
||||
handle.setframerate(8_000)
|
||||
handle.writeframes(bytes(stereo))
|
||||
|
||||
sample = read_wav_mono16(stereo_path)
|
||||
|
||||
assert sample.sample_rate == 16_000
|
||||
assert sample.channels == 1
|
||||
assert audio_metrics(sample.pcm).duration_ms == 100
|
||||
|
||||
|
||||
def test_write_wav_creates_parent_directory(tmp_path: Path) -> None:
|
||||
out = write_wav(tmp_path / "nested" / "tone.wav", AudioSample(name="tone", pcm=_tone_pcm()))
|
||||
|
||||
assert out.exists()
|
||||
assert read_wav_mono16(out).pcm
|
||||
|
||||
|
||||
def test_render_markdown_report_contains_tables_and_mermaid() -> None:
|
||||
report = render_markdown_report(
|
||||
{
|
||||
"passed": True,
|
||||
"started_at": "2026-06-16T00:00:00Z",
|
||||
"duration_ms": 123,
|
||||
"expected_text": "Teste",
|
||||
"baseline": {"path": "baseline.wav", "synthetic": True},
|
||||
"stt_results": [{"scenario": "clean", "passed": True, "wer": 0.0}],
|
||||
"tts_results": [{"scenario": "short", "passed": True, "duration_ms": 1000}],
|
||||
"e2e_results": [{"scenario": "clean", "passed": True, "ready_received": True}],
|
||||
"startup_checks": {
|
||||
"bridge": {
|
||||
"service": "bridge",
|
||||
"url": "http://127.0.0.1:8000/health",
|
||||
"status": "ready",
|
||||
"attempts": 1,
|
||||
"detail": "HTTP 200",
|
||||
}
|
||||
},
|
||||
"artifacts": {"summary_json": "summary.json"},
|
||||
}
|
||||
)
|
||||
|
||||
assert "Aviso: esta execucao usou um audio base sintetico" in report
|
||||
assert "## Prontidao Local" in report
|
||||
assert "Versao textual:" in report
|
||||
assert "" in report
|
||||
assert "Codigo Mermaid:" in report
|
||||
assert "```mermaid" in report
|
||||
assert "| cenario | descricao | status | prefixo_ok |" in report
|
||||
assert "Audio base sem degradacao" in report
|
||||
|
||||
|
||||
def test_scenario_description_ignores_repeat_suffix() -> None:
|
||||
assert scenario_description("noise_snr_20_r2").startswith("Audio com ruido leve")
|
||||
|
||||
|
||||
def test_write_mermaid_files_creates_standalone_diagrams(tmp_path: Path) -> None:
|
||||
files = write_mermaid_files(tmp_path)
|
||||
|
||||
assert Path(files["stt_mermaid"]).read_text(encoding="utf-8").startswith("flowchart LR")
|
||||
assert "sequenceDiagram" in Path(files["e2e_mermaid"]).read_text(encoding="utf-8")
|
||||
assert Path(files["stt_svg"]).read_text(encoding="utf-8").startswith("<svg")
|
||||
|
||||
|
||||
def _stress_config(tmp_path: Path) -> StressConfig:
|
||||
return StressConfig(
|
||||
env_file=Path(".env.dev"),
|
||||
report_dir=tmp_path,
|
||||
expected_text="Teste",
|
||||
synthesis_text="Teste",
|
||||
critical_terms=("teste",),
|
||||
stt_wer_threshold=0.2,
|
||||
bridge_url="ws://127.0.0.1:8000/ws/agent",
|
||||
bridge_health_url="http://bridge.local/health",
|
||||
agent_health_url="http://agent.local/",
|
||||
startup_wait_s=5.0,
|
||||
startup_poll_s=0.01,
|
||||
skip_local_wait=False,
|
||||
repeat=1,
|
||||
concurrency=1,
|
||||
e2e_turns=1,
|
||||
e2e_timeout_s=5.0,
|
||||
stress_audio=None,
|
||||
prefix_text="Teste",
|
||||
prefix_words=1,
|
||||
vad_proxy_threshold_dbfs=-45.0,
|
||||
vad_proxy_prefix_padding_ms=1000,
|
||||
vad_proxy_min_speech_ms=100,
|
||||
)
|
||||
|
||||
|
||||
def test_wait_for_local_services_retries_until_agent_is_ready(tmp_path: Path) -> None:
|
||||
config = _stress_config(tmp_path)
|
||||
attempts: dict[str, int] = {}
|
||||
|
||||
async def probe(url: str) -> tuple[bool, str]:
|
||||
attempts[url] = attempts.get(url, 0) + 1
|
||||
if "agent" in url and attempts[url] < 3:
|
||||
return False, "connection refused"
|
||||
return True, "HTTP 200"
|
||||
|
||||
async def no_sleep(_: float) -> None:
|
||||
return None
|
||||
|
||||
states = asyncio.run(wait_for_local_services(config, probe=probe, sleep=no_sleep))
|
||||
|
||||
assert states["bridge"]["ok"] is True
|
||||
assert states["bridge"]["attempts"] == 1
|
||||
assert states["agent_runtime"]["ok"] is True
|
||||
assert states["agent_runtime"]["attempts"] == 3
|
||||
|
||||
|
||||
def test_write_csv_handles_empty_rows(tmp_path: Path) -> None:
|
||||
out = write_csv(tmp_path / "empty.csv", [])
|
||||
|
||||
assert out.read_text(encoding="utf-8").strip() == ""
|
||||
|
||||
|
||||
def test_timeline_helpers_parse_excerpt_and_errors(tmp_path: Path) -> None:
|
||||
path = tmp_path / "timeline.jsonl"
|
||||
records = [
|
||||
{"event": "ready_sent"},
|
||||
{"event": "noise"},
|
||||
{"event": "user_transcript_final", "text": "ola"},
|
||||
{"event": "bridge_failed"},
|
||||
]
|
||||
path.write_text("\n".join(json.dumps(item) for item in records), encoding="utf-8")
|
||||
|
||||
parsed = read_timeline(path)
|
||||
|
||||
assert parsed == records
|
||||
assert [item["event"] for item in excerpt_timeline(parsed)] == [
|
||||
"ready_sent",
|
||||
"user_transcript_final",
|
||||
"bridge_failed",
|
||||
]
|
||||
assert timeline_has_error(parsed) is True
|
||||
119
tests/tools/test_oci_audio_download.py
Normal file
119
tests/tools/test_oci_audio_download.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from app.tools.oci_audio_download import (
|
||||
_parse_date,
|
||||
_parse_session_id,
|
||||
download_prefix,
|
||||
entire_calls_prefix,
|
||||
segments_prefix,
|
||||
)
|
||||
from app.utils.stt_audio_upload import OCIUploadConfig
|
||||
|
||||
|
||||
class _Raw:
|
||||
def __init__(self, content: bytes) -> None:
|
||||
self.content = content
|
||||
|
||||
def stream(self, _size: int, decode_content: bool = False):
|
||||
assert decode_content is False
|
||||
yield self.content
|
||||
|
||||
|
||||
class _Client:
|
||||
def __init__(self, objects: dict[str, bytes]) -> None:
|
||||
self.objects = objects
|
||||
self.downloaded: list[str] = []
|
||||
|
||||
def list_objects(self, **kwargs):
|
||||
prefix = kwargs["prefix"]
|
||||
items = [
|
||||
SimpleNamespace(name=name, size=len(content), etag="etag")
|
||||
for name, content in self.objects.items()
|
||||
if name.startswith(prefix)
|
||||
]
|
||||
return SimpleNamespace(data=SimpleNamespace(objects=items, next_start_with=None))
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
name = kwargs["object_name"]
|
||||
self.downloaded.append(name)
|
||||
return SimpleNamespace(data=SimpleNamespace(raw=_Raw(self.objects[name])))
|
||||
|
||||
|
||||
def _config() -> OCIUploadConfig:
|
||||
return OCIUploadConfig("local", "sa-saopaulo-1", "tia-audio", "namespace")
|
||||
|
||||
|
||||
def test_prefixes_match_object_storage_layout() -> None:
|
||||
assert segments_prefix("2026-07-24", "session-123") == "2026-07-24/session-123/"
|
||||
assert entire_calls_prefix("2026-07-24") == "2026-07-24/entire_call/"
|
||||
|
||||
|
||||
def test_argument_validation() -> None:
|
||||
assert _parse_date("2026-07-24") == "2026-07-24"
|
||||
assert _parse_session_id("session_123") == "session_123"
|
||||
with pytest.raises(Exception):
|
||||
_parse_date("24/07/2026")
|
||||
with pytest.raises(Exception):
|
||||
_parse_session_id("../entire_call")
|
||||
|
||||
|
||||
def test_download_prefix_downloads_and_creates_zip(tmp_path: Path) -> None:
|
||||
prefix = "2026-07-24/entire_call/"
|
||||
client = _Client(
|
||||
{
|
||||
f"{prefix}call-a.wav": b"audio-a",
|
||||
f"{prefix}call-b.wav": b"audio-b",
|
||||
"2026-07-23/entire_call/old.wav": b"old",
|
||||
}
|
||||
)
|
||||
output_dir = tmp_path / "calls"
|
||||
zip_path = tmp_path / "calls.zip"
|
||||
result = download_prefix(
|
||||
client=client,
|
||||
config=_config(),
|
||||
prefix=prefix,
|
||||
output_dir=output_dir,
|
||||
zip_path=zip_path,
|
||||
)
|
||||
assert result.object_count == 2
|
||||
assert result.downloaded_count == 2
|
||||
assert (output_dir / "call-a.wav").read_bytes() == b"audio-a"
|
||||
with zipfile.ZipFile(zip_path) as archive:
|
||||
assert sorted(archive.namelist()) == ["call-a.wav", "call-b.wav"]
|
||||
assert archive.testzip() is None
|
||||
|
||||
|
||||
def test_download_prefix_reuses_complete_file(tmp_path: Path) -> None:
|
||||
prefix = "2026-07-24/session-123/"
|
||||
name = f"{prefix}message.wav"
|
||||
client = _Client({name: b"segment"})
|
||||
output_dir = tmp_path / "segments"
|
||||
output_dir.mkdir()
|
||||
(output_dir / "message.wav").write_bytes(b"segment")
|
||||
result = download_prefix(
|
||||
client=client,
|
||||
config=_config(),
|
||||
prefix=prefix,
|
||||
output_dir=output_dir,
|
||||
zip_path=None,
|
||||
)
|
||||
assert result.cached_count == 1
|
||||
assert result.downloaded_count == 0
|
||||
assert client.downloaded == []
|
||||
|
||||
|
||||
def test_download_prefix_rejects_empty_prefix(tmp_path: Path) -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
download_prefix(
|
||||
client=_Client({}),
|
||||
config=_config(),
|
||||
prefix="2026-07-24/entire_call/",
|
||||
output_dir=tmp_path,
|
||||
zip_path=None,
|
||||
)
|
||||
1
tests/utils/__init__.py
Normal file
1
tests/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
109
tests/utils/test_audio_backlog.py
Normal file
109
tests/utils/test_audio_backlog.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
|
||||
from app.utils import audio_backlog
|
||||
|
||||
FRAME_SAMPLES = 320 # 20 ms @ 16 kHz mono
|
||||
BYTES_PER_FRAME = FRAME_SAMPLES * 2
|
||||
|
||||
|
||||
def _silent() -> bytes:
|
||||
return b"\x00" * BYTES_PER_FRAME
|
||||
|
||||
|
||||
def _voiced(amp: int = 8000) -> bytes:
|
||||
return struct.pack("<%dh" % FRAME_SAMPLES, *([amp] * FRAME_SAMPLES))
|
||||
|
||||
|
||||
def _queue(frames: list[bytes]) -> asyncio.Queue:
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
for frame in frames:
|
||||
q.put_nowait(frame)
|
||||
return q
|
||||
|
||||
|
||||
def _drain(q: asyncio.Queue) -> list[bytes]:
|
||||
out = []
|
||||
while True:
|
||||
try:
|
||||
out.append(q.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def test_rms_threshold_from_dbfs() -> None:
|
||||
assert audio_backlog.rms_threshold_from_dbfs(0.0) == 32768
|
||||
assert audio_backlog.rms_threshold_from_dbfs(-50.0) == 103
|
||||
assert audio_backlog.rms_threshold_from_dbfs(-200.0) == 0
|
||||
|
||||
|
||||
def test_frames_from_ms_rounds_up_with_minimum() -> None:
|
||||
assert audio_backlog.frames_from_ms(100, 20) == 5
|
||||
assert audio_backlog.frames_from_ms(90, 20) == 5 # arredonda pra cima
|
||||
assert audio_backlog.frames_from_ms(0, 20) == 1 # minimo
|
||||
assert audio_backlog.frames_from_ms(40, 20) == 2
|
||||
|
||||
|
||||
def test_is_silent_frame() -> None:
|
||||
thr = audio_backlog.rms_threshold_from_dbfs(-50.0)
|
||||
assert audio_backlog.is_silent_frame(_silent(), thr) is True
|
||||
assert audio_backlog.is_silent_frame(_voiced(), thr) is False
|
||||
|
||||
|
||||
def test_shed_below_keep_is_noop() -> None:
|
||||
q = _queue([_voiced()] * 2)
|
||||
result = audio_backlog.shed_queue_backlog(q, keep_frames=5, rms_threshold=103, blind=False)
|
||||
assert result.dropped == 0
|
||||
assert result.mode == "none"
|
||||
assert q.qsize() == 2
|
||||
|
||||
|
||||
def test_shed_blind_drops_oldest_and_keeps_recent_in_order() -> None:
|
||||
frames = [_voiced(1000 + i) for i in range(8)]
|
||||
q = _queue(frames)
|
||||
result = audio_backlog.shed_queue_backlog(q, keep_frames=2, rms_threshold=103, blind=True)
|
||||
assert result.dropped == 6
|
||||
assert result.dropped_voiced == 6
|
||||
assert result.mode == "blind"
|
||||
assert _drain(q) == frames[-2:]
|
||||
|
||||
|
||||
def test_shed_energy_drops_only_silence_preserving_speech_order() -> None:
|
||||
voiced = [_voiced(2000 + i * 100) for i in range(4)]
|
||||
# intercala voz e silencio: [V0, S, V1, S, V2, S, V3, S]
|
||||
frames = [voiced[0], _silent(), voiced[1], _silent(), voiced[2], _silent(), voiced[3], _silent()]
|
||||
q = _queue(frames)
|
||||
result = audio_backlog.shed_queue_backlog(q, keep_frames=2, rms_threshold=103, blind=False)
|
||||
# need = 8 - 2 = 6, mas so ha 4 frames silenciosos -> descarta os 4 silencios,
|
||||
# preserva os 4 de voz na ordem (nao corta fala).
|
||||
assert result.mode == "energy"
|
||||
assert result.dropped == 4
|
||||
assert result.dropped_silent == 4
|
||||
assert result.dropped_voiced == 0
|
||||
assert _drain(q) == voiced
|
||||
|
||||
|
||||
def test_shed_energy_stops_at_need_even_with_more_silence() -> None:
|
||||
voiced = [_voiced(3000 + i) for i in range(2)]
|
||||
# 2 de voz + 6 de silencio, keep=4 -> need=4 -> descarta 4 silencios, sobra 4
|
||||
frames = [voiced[0], _silent(), _silent(), _silent(), voiced[1], _silent(), _silent(), _silent()]
|
||||
q = _queue(frames)
|
||||
result = audio_backlog.shed_queue_backlog(q, keep_frames=4, rms_threshold=103, blind=False)
|
||||
assert result.dropped == 4
|
||||
assert result.dropped_silent == 4
|
||||
remaining = _drain(q)
|
||||
assert q.qsize() == 0
|
||||
# sobra os 2 de voz (na ordem) + 2 silencios mais recentes
|
||||
assert remaining[0] == voiced[0]
|
||||
assert voiced[1] in remaining
|
||||
assert len(remaining) == 4
|
||||
|
||||
|
||||
def test_shed_disabled_via_empty_queue() -> None:
|
||||
q = _queue([])
|
||||
result = audio_backlog.shed_queue_backlog(q, keep_frames=2, rms_threshold=103, blind=True)
|
||||
assert result.dropped == 0
|
||||
assert result.mode == "none"
|
||||
178
tests/utils/test_audio_output_tracker.py
Normal file
178
tests/utils/test_audio_output_tracker.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.utils import background as background_module
|
||||
from app.utils.background import (
|
||||
AudioOutputBacklogConfig,
|
||||
AudioOutputLatencyTracker,
|
||||
audio_output_backlog_config_from_env,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger("test.audio_out")
|
||||
|
||||
|
||||
def _capture(monkeypatch) -> list:
|
||||
events: list = []
|
||||
monkeypatch.setattr(
|
||||
background_module,
|
||||
"log_flow_event",
|
||||
lambda logger, step, **payload: events.append((step, payload)),
|
||||
)
|
||||
return events
|
||||
|
||||
|
||||
def test_output_config_from_env_defaults(monkeypatch) -> None:
|
||||
for name in (
|
||||
"AUDIO_OUT_LATENCY_METRICS_ENABLED",
|
||||
"AUDIO_OUT_LATENCY_ALERT_MS",
|
||||
"AUDIO_OUT_LATENCY_LOG_INTERVAL_S",
|
||||
"AUDIO_OUT_PACING_DEBT_THRESHOLD_MS",
|
||||
"AUDIO_OUT_BACKLOG_SHED_ENABLED",
|
||||
"AUDIO_OUT_BACKLOG_SHED_THRESHOLD_MS",
|
||||
"AUDIO_OUT_BACKLOG_SHED_KEEP_MS",
|
||||
"AUDIO_OUT_BACKLOG_SHED_CHECK_INTERVAL_FRAMES",
|
||||
"AUDIO_OUT_BACKLOG_SILENCE_DBFS",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
config = audio_output_backlog_config_from_env()
|
||||
|
||||
assert config.metrics_enabled is True
|
||||
assert config.shed_enabled is True
|
||||
assert config.shed_threshold_ms == 400
|
||||
assert config.shed_keep_ms == 200
|
||||
assert config.pacing_debt_threshold_ms == 200
|
||||
assert config.silence_dbfs == -60.0
|
||||
# frames dependem do frame_ms passado no uso
|
||||
assert config.shed_threshold_frames(20) == 20
|
||||
assert config.shed_keep_frames(20) == 10
|
||||
assert config.silence_rms_threshold == 32
|
||||
|
||||
|
||||
def test_note_pacing_debt_accumulates_and_logs(monkeypatch) -> None:
|
||||
events = _capture(monkeypatch)
|
||||
tracker = AudioOutputLatencyTracker(
|
||||
AudioOutputBacklogConfig(), frame_ms=20, flow_logger=_LOGGER
|
||||
)
|
||||
|
||||
tracker.note_pacing_debt(250, now=1.0)
|
||||
tracker.note_pacing_debt(300, now=2.0)
|
||||
tracker.note_pacing_debt(0, now=3.0) # ignora nao-positivo
|
||||
|
||||
assert tracker.pacing_debt_events == 2
|
||||
assert tracker.pacing_debt_dropped_ms == 550
|
||||
debt_events = [e for e in events if e[0] == "audio_out_pacing_debt"]
|
||||
assert len(debt_events) == 2
|
||||
assert debt_events[-1][1]["pacing_debt_dropped_ms"] == 550
|
||||
assert debt_events[-1][1]["debt_ms"] == 300
|
||||
|
||||
|
||||
def test_record_shed_accumulates_and_logs(monkeypatch) -> None:
|
||||
events = _capture(monkeypatch)
|
||||
debug_events = []
|
||||
tracker = AudioOutputLatencyTracker(
|
||||
AudioOutputBacklogConfig(),
|
||||
frame_ms=20,
|
||||
flow_logger=_LOGGER,
|
||||
debug_event_publisher=lambda event, payload: debug_events.append(
|
||||
(event, payload)
|
||||
),
|
||||
)
|
||||
|
||||
tracker.record_shed(
|
||||
dropped_frames=3,
|
||||
dropped_silent=3,
|
||||
mode="energy",
|
||||
queue_before_frames=30,
|
||||
queue_after_frames=27,
|
||||
)
|
||||
tracker.record_shed(
|
||||
dropped_frames=0, # ignora
|
||||
dropped_silent=0,
|
||||
mode="energy",
|
||||
queue_before_frames=10,
|
||||
queue_after_frames=10,
|
||||
)
|
||||
|
||||
assert tracker.total_dropped_frames == 3
|
||||
shed_events = [e for e in events if e[0] == "audio_out_latency_shed"]
|
||||
assert len(shed_events) == 1
|
||||
payload = shed_events[0][1]
|
||||
assert payload["mode"] == "energy"
|
||||
assert payload["dropped_ms"] == 60
|
||||
assert payload["dropped_silent_frames"] == 3
|
||||
assert payload["queue_before_ms"] == 600
|
||||
assert payload["total_dropped_ms"] == 60
|
||||
assert debug_events[-1][0] == "bridge.audio_out.shed"
|
||||
assert debug_events[-1][1]["dropped_ms"] == 60
|
||||
|
||||
|
||||
def test_maybe_log_primes_first_then_logs_on_interval(monkeypatch) -> None:
|
||||
events = _capture(monkeypatch)
|
||||
config = AudioOutputBacklogConfig(latency_alert_ms=1000, latency_log_interval_s=15.0)
|
||||
tracker = AudioOutputLatencyTracker(config, frame_ms=20, flow_logger=_LOGGER)
|
||||
|
||||
# primeira chamada apenas inicializa o relogio (sem log)
|
||||
tracker.maybe_log(queue_frames=5, now=100.0, reason="tick")
|
||||
assert [e for e in events if e[0] == "audio_out_latency"] == []
|
||||
assert tracker.peak_queue_ms == 100 # peak atualiza mesmo sem logar
|
||||
|
||||
# antes do intervalo: ainda nao loga
|
||||
tracker.maybe_log(queue_frames=5, now=110.0, reason="tick")
|
||||
assert [e for e in events if e[0] == "audio_out_latency"] == []
|
||||
|
||||
# passado o intervalo: loga
|
||||
tracker.maybe_log(queue_frames=5, now=116.0, reason="tick")
|
||||
out_events = [e for e in events if e[0] == "audio_out_latency"]
|
||||
assert len(out_events) == 1
|
||||
assert out_events[0][1]["queue_ms"] == 100
|
||||
|
||||
|
||||
def test_maybe_log_logs_on_alert_threshold(monkeypatch) -> None:
|
||||
events = _capture(monkeypatch)
|
||||
config = AudioOutputBacklogConfig(latency_alert_ms=1000, latency_log_interval_s=999.0)
|
||||
tracker = AudioOutputLatencyTracker(config, frame_ms=20, flow_logger=_LOGGER)
|
||||
|
||||
tracker.maybe_log(queue_frames=5, now=1.0, reason="tick") # prime
|
||||
# backlog >= alert (60 frames * 20 = 1200 ms >= 1000) -> loga fora do intervalo
|
||||
tracker.maybe_log(queue_frames=60, now=1.5, reason="tick")
|
||||
|
||||
out_events = [e for e in events if e[0] == "audio_out_latency"]
|
||||
assert len(out_events) == 1
|
||||
assert out_events[0][1]["queue_ms"] == 1200
|
||||
assert out_events[0][1]["peak_queue_ms"] == 1200
|
||||
|
||||
|
||||
def test_metrics_disabled_suppresses_all_logs(monkeypatch) -> None:
|
||||
events = _capture(monkeypatch)
|
||||
config = AudioOutputBacklogConfig(metrics_enabled=False)
|
||||
tracker = AudioOutputLatencyTracker(config, frame_ms=20, flow_logger=_LOGGER)
|
||||
|
||||
tracker.note_pacing_debt(500, now=1.0)
|
||||
tracker.record_shed(
|
||||
dropped_frames=5, dropped_silent=5, mode="energy",
|
||||
queue_before_frames=40, queue_after_frames=35,
|
||||
)
|
||||
tracker.maybe_log(queue_frames=100, now=1.0, reason="tick", force=True)
|
||||
|
||||
assert events == []
|
||||
# contadores ainda acumulam (metrica desligada nao perde estado interno)
|
||||
assert tracker.pacing_debt_dropped_ms == 500
|
||||
assert tracker.total_dropped_frames == 5
|
||||
|
||||
|
||||
def test_maybe_log_alert_is_rate_limited_until_it_recovers(monkeypatch) -> None:
|
||||
events = _capture(monkeypatch)
|
||||
config = AudioOutputBacklogConfig(latency_alert_ms=1000, latency_log_interval_s=15.0)
|
||||
tracker = AudioOutputLatencyTracker(config, frame_ms=20, flow_logger=_LOGGER)
|
||||
|
||||
tracker.maybe_log(queue_frames=5, now=1.0, reason="tick")
|
||||
tracker.maybe_log(queue_frames=60, now=1.5, reason="alert_start")
|
||||
tracker.maybe_log(queue_frames=60, now=1.52, reason="alert_still_active")
|
||||
tracker.maybe_log(queue_frames=5, now=2.0, reason="recovered")
|
||||
tracker.maybe_log(queue_frames=60, now=2.1, reason="alert_restart")
|
||||
|
||||
out_events = [event for event in events if event[0] == "audio_out_latency"]
|
||||
assert len(out_events) == 2
|
||||
assert [event[1]["reason"] for event in out_events] == ["alert_start", "alert_restart"]
|
||||
152
tests/utils/test_background.py
Normal file
152
tests/utils/test_background.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import struct
|
||||
|
||||
from fastapi import WebSocketDisconnect
|
||||
|
||||
from app.utils.background import BridgeOutputStats, ws_out_loop
|
||||
|
||||
|
||||
class _ClosingWebSocket:
|
||||
def __init__(self, exc: BaseException) -> None:
|
||||
self.exc = exc
|
||||
self.sent_frames = 0
|
||||
|
||||
async def send_bytes(self, frame: bytes) -> None:
|
||||
self.sent_frames += 1
|
||||
raise self.exc
|
||||
|
||||
|
||||
class _CollectThenDisconnectWebSocket:
|
||||
def __init__(self, disconnect_after: int) -> None:
|
||||
self.disconnect_after = disconnect_after
|
||||
self.sent_frames: list[bytes] = []
|
||||
|
||||
async def send_bytes(self, frame: bytes) -> None:
|
||||
self.sent_frames.append(frame)
|
||||
if len(self.sent_frames) >= self.disconnect_after:
|
||||
raise WebSocketDisconnect(code=1000)
|
||||
|
||||
class _CaptureThenDisconnectWebSocket:
|
||||
def __init__(self) -> None:
|
||||
self.sent_frames: list[bytes] = []
|
||||
|
||||
async def send_bytes(self, frame: bytes) -> None:
|
||||
self.sent_frames.append(frame)
|
||||
raise WebSocketDisconnect(code=1000)
|
||||
|
||||
|
||||
async def _capture_one_agent_frame(frame: bytes, **ws_out_kwargs) -> _CaptureThenDisconnectWebSocket:
|
||||
ws = _CaptureThenDisconnectWebSocket()
|
||||
agent_q: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
await agent_q.put(frame)
|
||||
|
||||
await asyncio.wait_for(
|
||||
ws_out_loop(
|
||||
ws,
|
||||
agent_q,
|
||||
frame_ms=20,
|
||||
bytes_per_frame=len(frame),
|
||||
**ws_out_kwargs,
|
||||
),
|
||||
timeout=0.2,
|
||||
)
|
||||
|
||||
return ws
|
||||
|
||||
|
||||
def test_ws_out_loop_exits_when_close_was_already_sent() -> None:
|
||||
async def _run() -> None:
|
||||
ws = _ClosingWebSocket(RuntimeError('Cannot call "send" once a close message has been sent.'))
|
||||
|
||||
await asyncio.wait_for(
|
||||
ws_out_loop(
|
||||
ws,
|
||||
asyncio.Queue(),
|
||||
frame_ms=20,
|
||||
bytes_per_frame=4,
|
||||
),
|
||||
timeout=0.2,
|
||||
)
|
||||
|
||||
assert ws.sent_frames == 1
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_ws_out_loop_applies_default_output_gain(monkeypatch) -> None:
|
||||
monkeypatch.delenv("WS_OUTPUT_GAIN", raising=False)
|
||||
|
||||
frame = struct.pack("<hh", 1000, -1000)
|
||||
ws = asyncio.run(_capture_one_agent_frame(frame))
|
||||
|
||||
assert struct.unpack("<hh", ws.sent_frames[0]) == (1000, -1000)
|
||||
|
||||
|
||||
def test_ws_out_loop_reads_output_gain_from_env(monkeypatch) -> None:
|
||||
monkeypatch.setenv("WS_OUTPUT_GAIN", "1.5")
|
||||
|
||||
frame = struct.pack("<hh", 1000, -1000)
|
||||
ws = asyncio.run(_capture_one_agent_frame(frame))
|
||||
|
||||
assert struct.unpack("<hh", ws.sent_frames[0]) == (1500, -1500)
|
||||
|
||||
|
||||
def test_ws_out_loop_prefers_call_config_output_gain(monkeypatch) -> None:
|
||||
monkeypatch.setenv("WS_OUTPUT_GAIN", "1.5")
|
||||
|
||||
frame = struct.pack("<hh", 1000, -1000)
|
||||
ws = asyncio.run(_capture_one_agent_frame(frame, output_gain=1.0))
|
||||
|
||||
assert struct.unpack("<hh", ws.sent_frames[0]) == (1000, -1000)
|
||||
|
||||
|
||||
def test_ws_out_loop_exits_on_websocket_disconnect() -> None:
|
||||
async def _run() -> None:
|
||||
ws = _ClosingWebSocket(WebSocketDisconnect(code=1000))
|
||||
|
||||
await asyncio.wait_for(
|
||||
ws_out_loop(
|
||||
ws,
|
||||
asyncio.Queue(),
|
||||
frame_ms=20,
|
||||
bytes_per_frame=4,
|
||||
),
|
||||
timeout=0.2,
|
||||
)
|
||||
|
||||
assert ws.sent_frames == 1
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_ws_out_loop_waits_for_speech_before_signaling_first_agent_audio() -> None:
|
||||
async def _run() -> None:
|
||||
silent_frame = struct.pack("<hh", 0, 0)
|
||||
voiced_frame = struct.pack("<hh", 2000, -2000)
|
||||
ws = _CollectThenDisconnectWebSocket(disconnect_after=3)
|
||||
agent_q: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
await agent_q.put(silent_frame)
|
||||
await agent_q.put(voiced_frame)
|
||||
first_audio = asyncio.Event()
|
||||
stats = BridgeOutputStats()
|
||||
|
||||
await asyncio.wait_for(
|
||||
ws_out_loop(
|
||||
ws,
|
||||
agent_q,
|
||||
frame_ms=20,
|
||||
bytes_per_frame=len(silent_frame),
|
||||
first_agent_audio_sent=first_audio,
|
||||
output_stats=stats,
|
||||
),
|
||||
timeout=0.2,
|
||||
)
|
||||
|
||||
assert ws.sent_frames[:2] == [silent_frame, voiced_frame]
|
||||
assert first_audio.is_set()
|
||||
assert stats.first_agent_audio_sent is True
|
||||
assert stats.agent_audio_bursts == 1
|
||||
|
||||
asyncio.run(_run())
|
||||
106
tests/utils/test_call_timeline.py
Normal file
106
tests/utils/test_call_timeline.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from app.utils.call_timeline import CallTimeline
|
||||
|
||||
|
||||
class CallTimelineTests(unittest.TestCase):
|
||||
def test_emit_writes_jsonl_with_relative_time(self) -> None:
|
||||
logger = logging.getLogger("test.call_timeline")
|
||||
logger.handlers = []
|
||||
logger.addHandler(logging.NullHandler())
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
old_dir = os.environ.get("CALL_TIMELINE_DIR")
|
||||
old_console = os.environ.get("CALL_TIMELINE_CONSOLE")
|
||||
old_enabled = os.environ.get("CALL_TIMELINE_ENABLED")
|
||||
|
||||
os.environ["CALL_TIMELINE_DIR"] = tmpdir
|
||||
os.environ["CALL_TIMELINE_CONSOLE"] = "0"
|
||||
os.environ["CALL_TIMELINE_ENABLED"] = "1"
|
||||
|
||||
try:
|
||||
timeline = CallTimeline(
|
||||
logger=logger,
|
||||
component="bridge",
|
||||
timeline_id="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
room="room-dev-123",
|
||||
session_id="PRT-1",
|
||||
phone_number="31999999999",
|
||||
origin_unix_ms=1000,
|
||||
)
|
||||
timeline.emit("call_start", ok=True, nested={"a": 1})
|
||||
timeline.emit("ready_sent")
|
||||
self.assertTrue(timeline.flush(timeout=5.0))
|
||||
|
||||
with timeline.path.open("r", encoding="utf-8") as handle:
|
||||
lines = [json.loads(line) for line in handle if line.strip()]
|
||||
finally:
|
||||
if old_dir is None:
|
||||
os.environ.pop("CALL_TIMELINE_DIR", None)
|
||||
else:
|
||||
os.environ["CALL_TIMELINE_DIR"] = old_dir
|
||||
|
||||
if old_console is None:
|
||||
os.environ.pop("CALL_TIMELINE_CONSOLE", None)
|
||||
else:
|
||||
os.environ["CALL_TIMELINE_CONSOLE"] = old_console
|
||||
|
||||
if old_enabled is None:
|
||||
os.environ.pop("CALL_TIMELINE_ENABLED", None)
|
||||
else:
|
||||
os.environ["CALL_TIMELINE_ENABLED"] = old_enabled
|
||||
|
||||
self.assertEqual(2, len(lines))
|
||||
self.assertEqual("bridge", lines[0]["component"])
|
||||
self.assertEqual("call_start", lines[0]["event"])
|
||||
self.assertEqual("room-dev-123", lines[0]["timeline_id"])
|
||||
self.assertEqual("PRT-1", lines[0]["protocol"])
|
||||
self.assertEqual("31999999999", lines[0]["phone_number"])
|
||||
self.assertTrue(lines[0]["ok"])
|
||||
self.assertEqual({"a": 1}, lines[0]["nested"])
|
||||
self.assertGreaterEqual(lines[0]["t_rel_ms"], 0)
|
||||
self.assertEqual("ready_sent", lines[1]["event"])
|
||||
|
||||
def test_background_writer_preserves_order_for_many_events(self) -> None:
|
||||
logger = logging.getLogger("test.call_timeline.order")
|
||||
logger.handlers = [logging.NullHandler()]
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
old_dir = os.environ.get("CALL_TIMELINE_DIR")
|
||||
old_enabled = os.environ.get("CALL_TIMELINE_ENABLED")
|
||||
os.environ["CALL_TIMELINE_DIR"] = tmpdir
|
||||
os.environ["CALL_TIMELINE_ENABLED"] = "1"
|
||||
try:
|
||||
timeline = CallTimeline(
|
||||
logger=logger,
|
||||
component="bridge",
|
||||
timeline_id="room-order",
|
||||
origin_unix_ms=1000,
|
||||
)
|
||||
for index in range(200):
|
||||
timeline.emit("tick", index=index)
|
||||
self.assertTrue(timeline.flush(timeout=5.0))
|
||||
with timeline.path.open("r", encoding="utf-8") as handle:
|
||||
lines = [json.loads(line) for line in handle if line.strip()]
|
||||
finally:
|
||||
if old_dir is None:
|
||||
os.environ.pop("CALL_TIMELINE_DIR", None)
|
||||
else:
|
||||
os.environ["CALL_TIMELINE_DIR"] = old_dir
|
||||
if old_enabled is None:
|
||||
os.environ.pop("CALL_TIMELINE_ENABLED", None)
|
||||
else:
|
||||
os.environ["CALL_TIMELINE_ENABLED"] = old_enabled
|
||||
|
||||
self.assertEqual(list(range(200)), [line["index"] for line in lines])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
47
tests/utils/test_call_timeline_async_writer.py
Normal file
47
tests/utils/test_call_timeline_async_writer.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.utils.call_timeline import _TimelineWriter
|
||||
|
||||
|
||||
def test_queue_full_is_counted_and_flush_honors_timeout() -> None:
|
||||
writer = _TimelineWriter(max_queue=1, warning_interval_s=3_600)
|
||||
writer.start = lambda: True # type: ignore[method-assign]
|
||||
writer._last_drop_warning_at = time.monotonic()
|
||||
|
||||
assert writer.submit(Path("unused"), "first") is True
|
||||
assert writer.submit(Path("unused"), "second") is False
|
||||
|
||||
started = time.monotonic()
|
||||
assert writer.flush(timeout=0.01) is False
|
||||
assert time.monotonic() - started < 0.5
|
||||
assert writer.dropped == 1
|
||||
|
||||
|
||||
def test_write_error_is_counted_without_killing_writer() -> None:
|
||||
writer = _TimelineWriter(max_queue=10, warning_interval_s=3_600)
|
||||
writer._last_error_warning_at = time.monotonic()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
invalid_path = Path(tmpdir)
|
||||
assert writer.submit(invalid_path, "line") is True
|
||||
assert writer.flush(timeout=2.0) is True
|
||||
|
||||
assert writer.write_errors == 1
|
||||
assert writer.shutdown(timeout=2.0) is True
|
||||
|
||||
|
||||
def test_shutdown_drains_pending_lines_and_rejects_new_work() -> None:
|
||||
writer = _TimelineWriter(max_queue=10)
|
||||
writer._last_drop_warning_at = time.monotonic()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = Path(tmpdir) / "timeline.jsonl"
|
||||
assert writer.submit(path, "one") is True
|
||||
assert writer.submit(path, "two") is True
|
||||
assert writer.shutdown(timeout=2.0) is True
|
||||
assert path.read_text(encoding="utf-8").splitlines() == ["one", "two"]
|
||||
assert writer.submit(path, "three") is False
|
||||
|
||||
assert writer.dropped == 1
|
||||
292
tests/utils/test_full_call_recording.py
Normal file
292
tests/utils/test_full_call_recording.py
Normal file
@@ -0,0 +1,292 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
import wave
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.utils import full_call_recording
|
||||
from app.utils.full_call_recording import (
|
||||
EntireCallRecorder,
|
||||
EntireCallUploadItem,
|
||||
build_entire_call_object_name,
|
||||
enqueue_entire_call_upload,
|
||||
)
|
||||
from app.utils.stt_audio_upload import OCIUploadConfig
|
||||
|
||||
|
||||
def test_entire_call_object_name_uses_date_folder_and_session_filename() -> None:
|
||||
object_name = build_entire_call_object_name(
|
||||
session_id="sessao muito longa/" * 30,
|
||||
now=datetime(2026, 7, 8, 10, 30, 0),
|
||||
)
|
||||
|
||||
parts = object_name.split("/")
|
||||
assert parts[0] == "2026-07-08"
|
||||
assert parts[1] == "entire_call"
|
||||
assert object_name.endswith(".wav")
|
||||
assert len(parts) == 3
|
||||
assert len(parts[2]) <= 68
|
||||
assert " " not in object_name
|
||||
|
||||
|
||||
def test_entire_call_recorder_writes_stereo_wav_without_upload(tmp_path: Path) -> None:
|
||||
async def _run() -> Path:
|
||||
recorder = EntireCallRecorder(
|
||||
session_id="session-1",
|
||||
sample_rate=1000,
|
||||
channels=1,
|
||||
sample_width=2,
|
||||
frame_ms=20,
|
||||
bytes_per_frame=4,
|
||||
tmp_dir=tmp_path,
|
||||
logger_override=logging.getLogger("test.full_call_recording.wav"),
|
||||
object_name="2026-07-08/entire_call/session-1.wav",
|
||||
)
|
||||
recorder.start()
|
||||
recorder.record_client_frame(struct.pack("<hh", 1000, -1000))
|
||||
recorder.record_output_frame(struct.pack("<hh", 2000, 500))
|
||||
result = await recorder.finalize(enqueue_upload=False)
|
||||
|
||||
assert result is not None
|
||||
assert result.frames == 1
|
||||
assert result.duration_ms == 20
|
||||
assert result.upload_enqueued is False
|
||||
return result.path
|
||||
|
||||
wav_path = asyncio.run(_run())
|
||||
with wave.open(str(wav_path), "rb") as handle:
|
||||
assert handle.getframerate() == 1000
|
||||
assert handle.getnchannels() == 2
|
||||
assert handle.getsampwidth() == 2
|
||||
# Stereo samples are interleaved as client-left, agent-right.
|
||||
assert struct.unpack("<hhhh", handle.readframes(2)) == (
|
||||
1000,
|
||||
2000,
|
||||
-1000,
|
||||
500,
|
||||
)
|
||||
|
||||
|
||||
def test_entire_call_recorder_keeps_each_side_in_its_own_channel(tmp_path: Path) -> None:
|
||||
async def _run() -> Path:
|
||||
recorder = EntireCallRecorder(
|
||||
session_id="session-separated",
|
||||
sample_rate=1000,
|
||||
channels=1,
|
||||
sample_width=2,
|
||||
frame_ms=20,
|
||||
bytes_per_frame=4,
|
||||
tmp_dir=tmp_path,
|
||||
logger_override=logging.getLogger("test.full_call_recording.separated"),
|
||||
)
|
||||
recorder.start()
|
||||
recorder.record_output_frame(struct.pack("<hh", 300, 400))
|
||||
recorder.record_client_frame(struct.pack("<hh", 100, 200))
|
||||
result = await recorder.finalize(enqueue_upload=False)
|
||||
assert result is not None
|
||||
return result.path
|
||||
|
||||
with wave.open(str(asyncio.run(_run())), "rb") as handle:
|
||||
assert handle.getnchannels() == 2
|
||||
samples = struct.unpack("<hhhhhhhh", handle.readframes(4))
|
||||
assert samples == (
|
||||
0,
|
||||
300,
|
||||
0,
|
||||
400,
|
||||
100,
|
||||
0,
|
||||
200,
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
def test_entire_call_recorder_accepts_metadata_with_reserved_names(tmp_path: Path) -> None:
|
||||
async def _run() -> None:
|
||||
recorder = EntireCallRecorder(
|
||||
session_id="session-1",
|
||||
sample_rate=1000,
|
||||
channels=1,
|
||||
sample_width=2,
|
||||
frame_ms=20,
|
||||
bytes_per_frame=4,
|
||||
tmp_dir=tmp_path,
|
||||
logger_override=logging.getLogger("test.full_call_recording.metadata"),
|
||||
metadata={"session_id": "session-1", "room": "room-1"},
|
||||
object_name="2026-07-08/entire_call/session-1.wav",
|
||||
)
|
||||
|
||||
recorder.start()
|
||||
recorder.record_output_frame(b"\x00\x00\x00\x00")
|
||||
result = await recorder.finalize(enqueue_upload=False)
|
||||
|
||||
assert result is not None
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_entire_call_recorder_queue_full_drops_without_raising(tmp_path: Path) -> None:
|
||||
recorder = EntireCallRecorder(
|
||||
session_id="session-1",
|
||||
sample_rate=1000,
|
||||
channels=1,
|
||||
sample_width=2,
|
||||
frame_ms=20,
|
||||
bytes_per_frame=4,
|
||||
tmp_dir=tmp_path,
|
||||
logger_override=logging.getLogger("test.full_call_recording.drop"),
|
||||
queue_size=1,
|
||||
)
|
||||
recorder._started = True
|
||||
recorder._queue.put_nowait(object())
|
||||
|
||||
recorder.record_client_frame(b"\x00\x00\x00\x00")
|
||||
|
||||
assert recorder.dropped_queue_frames == 1
|
||||
|
||||
|
||||
def test_upload_item_streams_file_to_oci(monkeypatch, tmp_path: Path) -> None:
|
||||
calls: dict[str, object] = {}
|
||||
wav_path = tmp_path / "call.wav"
|
||||
wav_path.write_bytes(b"fake-wav")
|
||||
config = OCIUploadConfig(
|
||||
auth_mode="oke_workload_identity",
|
||||
region="sa-saopaulo-1",
|
||||
bucket="tia-audio",
|
||||
namespace="namespace",
|
||||
)
|
||||
|
||||
class FakeClient:
|
||||
def put_object(self, **kwargs):
|
||||
calls["namespace_name"] = kwargs["namespace_name"]
|
||||
calls["bucket_name"] = kwargs["bucket_name"]
|
||||
calls["object_name"] = kwargs["object_name"]
|
||||
calls["body"] = kwargs["put_object_body"].read()
|
||||
return SimpleNamespace(status=200)
|
||||
|
||||
monkeypatch.setattr(full_call_recording, "_oci_upload_config_from_env", lambda: config)
|
||||
monkeypatch.setattr(full_call_recording, "_oci_client", lambda _config: FakeClient())
|
||||
|
||||
item = EntireCallUploadItem(
|
||||
object_name="2026-07-08/entire_call/session-1.wav",
|
||||
path=wav_path,
|
||||
session_id="session-1",
|
||||
bytes=8,
|
||||
duration_ms=20,
|
||||
dropped_queue_frames=0,
|
||||
dropped_client_buffer_frames=0,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
asyncio.run(full_call_recording._upload_item(item))
|
||||
|
||||
assert calls == {
|
||||
"namespace_name": "namespace",
|
||||
"bucket_name": "tia-audio",
|
||||
"object_name": "2026-07-08/entire_call/session-1.wav",
|
||||
"body": b"fake-wav",
|
||||
}
|
||||
|
||||
|
||||
def test_upload_item_retries_with_a_fresh_client_after_transient_failure(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
wav_path = tmp_path / "call.wav"
|
||||
wav_path.write_bytes(b"fake-wav")
|
||||
config = OCIUploadConfig(
|
||||
auth_mode="oke_workload_identity",
|
||||
region="sa-saopaulo-1",
|
||||
bucket="tia-audio",
|
||||
namespace="namespace",
|
||||
)
|
||||
attempts = 0
|
||||
resets = 0
|
||||
|
||||
class FakeClient:
|
||||
def put_object(self, **_kwargs):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
raise RuntimeError("transient connection failure")
|
||||
return SimpleNamespace(status=200)
|
||||
|
||||
def fake_reset() -> None:
|
||||
nonlocal resets
|
||||
resets += 1
|
||||
|
||||
monkeypatch.setenv("ENTIRE_CALL_RECORDING_UPLOAD_RETRY_BASE_DELAY_S", "0")
|
||||
monkeypatch.setattr(full_call_recording, "_oci_upload_config_from_env", lambda: config)
|
||||
monkeypatch.setattr(full_call_recording, "_oci_client", lambda _config: FakeClient())
|
||||
monkeypatch.setattr(full_call_recording, "_reset_oci_client", fake_reset)
|
||||
|
||||
item = EntireCallUploadItem(
|
||||
object_name="2026-07-08/entire_call/session-retry.wav",
|
||||
path=wav_path,
|
||||
session_id="session-retry",
|
||||
bytes=8,
|
||||
duration_ms=20,
|
||||
dropped_queue_frames=0,
|
||||
dropped_client_buffer_frames=0,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
asyncio.run(full_call_recording._upload_item(item))
|
||||
|
||||
assert attempts == 2
|
||||
assert resets == 1
|
||||
|
||||
|
||||
def test_upload_worker_keeps_file_after_all_attempts_fail(monkeypatch, tmp_path: Path) -> None:
|
||||
async def _run() -> None:
|
||||
wav_path = tmp_path / "failed.wav"
|
||||
wav_path.write_bytes(b"recoverable-wav")
|
||||
item = EntireCallUploadItem(
|
||||
object_name="2026-07-08/entire_call/session-failed.wav",
|
||||
path=wav_path,
|
||||
session_id="session-failed",
|
||||
bytes=15,
|
||||
duration_ms=20,
|
||||
dropped_queue_frames=0,
|
||||
dropped_client_buffer_frames=0,
|
||||
metadata={},
|
||||
)
|
||||
|
||||
async def fail_upload(_item) -> None:
|
||||
raise RuntimeError("OCI unavailable")
|
||||
|
||||
monkeypatch.setattr(full_call_recording, "_upload_item", fail_upload)
|
||||
worker = full_call_recording._EntireCallUploadWorker(asyncio.get_running_loop())
|
||||
assert worker.enqueue(item)
|
||||
await asyncio.wait_for(worker._queue.join(), timeout=1)
|
||||
|
||||
assert wav_path.read_bytes() == b"recoverable-wav"
|
||||
worker._task.cancel()
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_enqueue_entire_call_upload_is_noop_and_keeps_file_without_oci_config(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
wav_path = tmp_path / "call.wav"
|
||||
wav_path.write_bytes(b"fake-wav")
|
||||
monkeypatch.setattr(full_call_recording, "_oci_upload_config_from_env", lambda: None)
|
||||
|
||||
assert (
|
||||
enqueue_entire_call_upload(
|
||||
path=wav_path,
|
||||
object_name="2026-07-08/entire_call/session-1.wav",
|
||||
session_id="session-1",
|
||||
bytes=8,
|
||||
duration_ms=20,
|
||||
logger_override=logging.getLogger("test.full_call_recording.noop"),
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert wav_path.read_bytes() == b"fake-wav"
|
||||
117
tests/utils/test_logging_async_file.py
Normal file
117
tests/utils/test_logging_async_file.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import io
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import app.utils.logging as app_logging
|
||||
from app.utils.logging import _LogWriter, get_call_logger
|
||||
|
||||
|
||||
class _FailingHandler(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
raise OSError("disk unavailable")
|
||||
|
||||
|
||||
def test_session_console_formatter_appends_context_without_duplicating() -> None:
|
||||
stream = io.StringIO()
|
||||
handler = logging.StreamHandler(stream)
|
||||
handler.setFormatter(app_logging._SessionConsoleFormatter("%(message)s"))
|
||||
logger = logging.getLogger(f"test.session.console.{time.time_ns()}")
|
||||
logger.handlers = [handler]
|
||||
logger.propagate = False
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
try:
|
||||
app_logging.set_log_session_id("session-123")
|
||||
logger.info("FLOW | step=test")
|
||||
logger.info("FLOW | step=test | session_id=session-123")
|
||||
finally:
|
||||
app_logging.set_log_session_id("")
|
||||
|
||||
assert stream.getvalue().splitlines() == [
|
||||
"FLOW | step=test | session_id=session-123",
|
||||
"FLOW | step=test | session_id=session-123",
|
||||
]
|
||||
|
||||
|
||||
def test_session_console_formatter_keeps_structured_json_valid() -> None:
|
||||
formatter = app_logging._SessionConsoleFormatter("%(message)s")
|
||||
try:
|
||||
app_logging.set_log_session_id("session-456")
|
||||
record = logging.LogRecord(
|
||||
"test",
|
||||
logging.INFO,
|
||||
"",
|
||||
0,
|
||||
'{"tipo_evento":"envio msg","session_id":"session-456"}',
|
||||
(),
|
||||
None,
|
||||
)
|
||||
|
||||
assert formatter.format(record) == (
|
||||
'{"tipo_evento":"envio msg","session_id":"session-456"}'
|
||||
)
|
||||
finally:
|
||||
app_logging.set_log_session_id("")
|
||||
|
||||
|
||||
def test_call_file_handler_is_async_and_writes_to_disk() -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
saved = {key: os.environ.get(key) for key in ("LOG_DIR", "LOG_TO_FILE")}
|
||||
os.environ["LOG_DIR"] = tmp
|
||||
os.environ["LOG_TO_FILE"] = "1"
|
||||
try:
|
||||
logger = get_call_logger(
|
||||
phone_number="5500000000123",
|
||||
session_id=f"async{time.time_ns()}",
|
||||
)
|
||||
async_handlers = [
|
||||
handler
|
||||
for handler in logger.handlers
|
||||
if isinstance(handler, app_logging._AsyncFileHandler)
|
||||
]
|
||||
assert len(async_handlers) == 1
|
||||
|
||||
logger.info("LINHA_DE_TESTE_ASYNC_123")
|
||||
assert app_logging._LOG_WRITER.flush(timeout=5.0)
|
||||
|
||||
files = list(Path(tmp).glob("*.txt"))
|
||||
assert len(files) == 1
|
||||
assert "LINHA_DE_TESTE_ASYNC_123" in files[0].read_text(encoding="utf-8")
|
||||
finally:
|
||||
for key, value in saved.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def test_queue_full_is_counted_and_flush_honors_timeout() -> None:
|
||||
writer = _LogWriter(max_queue=1, warning_interval_s=3_600)
|
||||
writer.start = lambda: True # type: ignore[method-assign]
|
||||
writer._last_drop_warning_at = time.monotonic()
|
||||
handler = logging.NullHandler()
|
||||
record = logging.LogRecord("test", logging.INFO, "", 0, "message", (), None)
|
||||
|
||||
assert writer.submit(handler, record) is True
|
||||
assert writer.submit(handler, record) is False
|
||||
|
||||
started = time.monotonic()
|
||||
assert writer.flush(timeout=0.01) is False
|
||||
assert time.monotonic() - started < 0.5
|
||||
assert writer.dropped == 1
|
||||
|
||||
|
||||
def test_handler_error_is_counted_and_shutdown_stays_healthy() -> None:
|
||||
writer = _LogWriter(max_queue=10, warning_interval_s=3_600)
|
||||
writer._last_error_warning_at = time.monotonic()
|
||||
record = logging.LogRecord("test", logging.INFO, "", 0, "message", (), None)
|
||||
|
||||
assert writer.submit(_FailingHandler(), record) is True
|
||||
assert writer.flush(timeout=2.0) is True
|
||||
assert writer.write_errors == 1
|
||||
assert writer.shutdown(timeout=2.0) is True
|
||||
460
tests/utils/test_logging_pubsub.py
Normal file
460
tests/utils/test_logging_pubsub.py
Normal file
@@ -0,0 +1,460 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from app.utils import logging as logging_utils
|
||||
from app.utils import structured_otlp
|
||||
from app.utils import structured_pubsub
|
||||
|
||||
|
||||
class _FakeFuture:
|
||||
def add_done_callback(self, callback):
|
||||
callback(self)
|
||||
|
||||
def result(self):
|
||||
return "message-id"
|
||||
|
||||
|
||||
class _FakePublisher:
|
||||
def __init__(self) -> None:
|
||||
self.calls = []
|
||||
|
||||
def publish(self, *args, **kwargs):
|
||||
self.calls.append((args, kwargs))
|
||||
return _FakeFuture()
|
||||
|
||||
def topic_path(self, project_id: str, topic: str) -> str:
|
||||
return f"projects/{project_id}/topics/{topic}"
|
||||
|
||||
|
||||
def test_log_structured_event_publishes_to_pubsub_when_configured(monkeypatch) -> None:
|
||||
published_events = []
|
||||
|
||||
monkeypatch.setenv("STRUCTURED_EVENT_LOG_ENABLED", "1")
|
||||
monkeypatch.setattr(logging_utils, "publish_structured_event", published_events.append)
|
||||
|
||||
logger = logging.getLogger("test.logging_pubsub")
|
||||
logger.handlers = [logging.NullHandler()]
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
event = logging_utils.log_structured_event(
|
||||
logger,
|
||||
logging_utils.StructuredLogContext(
|
||||
callid="call-1",
|
||||
session_id="session-1",
|
||||
num_telefone="5511999999999",
|
||||
cod_ani="1234",
|
||||
nome_agente="conta",
|
||||
),
|
||||
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
|
||||
message_id="message-1",
|
||||
inicio_ns=1_700_000_000_000_000_000,
|
||||
)
|
||||
|
||||
assert event is not None
|
||||
assert published_events == [event]
|
||||
|
||||
|
||||
def test_log_structured_event_publishes_to_pubsub_and_otlp_when_trace_id_is_valid(monkeypatch) -> None:
|
||||
published_events = []
|
||||
published_spans = []
|
||||
|
||||
monkeypatch.setenv("STRUCTURED_EVENT_LOG_ENABLED", "1")
|
||||
monkeypatch.setattr(logging_utils, "publish_structured_event", published_events.append)
|
||||
monkeypatch.setattr(logging_utils, "publish_structured_span", published_spans.append)
|
||||
|
||||
logger = logging.getLogger("test.logging_pubsub_otlp")
|
||||
logger.handlers = [logging.NullHandler()]
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
event = logging_utils.log_structured_event(
|
||||
logger,
|
||||
logging_utils.StructuredLogContext(
|
||||
callid="call-1",
|
||||
session_id="550e8400-e29b-41d4-a716-446655440101",
|
||||
num_telefone="5511999999999",
|
||||
cod_ani="1234",
|
||||
nome_agente="conta",
|
||||
),
|
||||
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
|
||||
message_id="message-1",
|
||||
inicio_ns=1_700_000_000_000_000_000,
|
||||
)
|
||||
|
||||
assert event is not None
|
||||
assert published_events == [event]
|
||||
assert published_spans == [event]
|
||||
|
||||
|
||||
def test_publish_structured_event_sends_json_to_configured_topic(monkeypatch) -> None:
|
||||
fake_publisher = _FakePublisher()
|
||||
|
||||
monkeypatch.setenv("GCP_PROJECT_ID", "project-1")
|
||||
monkeypatch.setenv("AGENT_PUBSUB_TOPIC", "agent-logs")
|
||||
monkeypatch.setattr(structured_pubsub, "_PUBLISHER", fake_publisher)
|
||||
monkeypatch.setattr(structured_pubsub, "_TOPIC_PATH", "projects/project-1/topics/agent-logs")
|
||||
|
||||
event = {
|
||||
"tipo_evento": "recebimento msg",
|
||||
"dat_hora_inicio": "2026-05-11T22:07:29.965Z",
|
||||
"dat_hora_fim": "2026-05-11T22:07:30.965Z",
|
||||
"callid": "call-1",
|
||||
"session_id": "session-1",
|
||||
"nome_agente": "conta",
|
||||
"cod_ani": "1234",
|
||||
"message_id": "message-1",
|
||||
"original_message_id": "original-message-1",
|
||||
"http_cod_status": 200,
|
||||
"http_cod_desc": "OK",
|
||||
}
|
||||
structured_pubsub.publish_structured_event(event)
|
||||
|
||||
assert len(fake_publisher.calls) == 1
|
||||
|
||||
args, kwargs = fake_publisher.calls[0]
|
||||
assert args[0] == "projects/project-1/topics/agent-logs"
|
||||
payload = json.loads(args[1].decode("utf-8"))
|
||||
assert payload == {
|
||||
"tipo_evento": "recebimento msg",
|
||||
"dat_hora_inicio": "11/05/2026 19:07:29,965000000",
|
||||
"dat_hora_termino": "11/05/2026 19:07:30,965000000",
|
||||
"callid": "call-1",
|
||||
"sessionId": "session-1",
|
||||
"nome_agente": "conta",
|
||||
"cod_ani": "1234",
|
||||
"message_id": "message-1",
|
||||
"http_cod_status": 200,
|
||||
"http_cod_desc": "OK",
|
||||
}
|
||||
assert "session_id" not in payload
|
||||
assert "dat_hora_fim" not in payload
|
||||
assert "original_message_id" not in payload
|
||||
assert kwargs == {
|
||||
"tipo_evento": "recebimento msg",
|
||||
"dat_hora_inicio": "11/05/2026 19:07:29,965000000",
|
||||
"dat_hora_termino": "11/05/2026 19:07:30,965000000",
|
||||
"sessionId": "session-1",
|
||||
"callid": "call-1",
|
||||
"nome_agente": "conta",
|
||||
"cod_ani": "1234",
|
||||
"http_cod_status": "200",
|
||||
"http_cod_desc": "OK",
|
||||
}
|
||||
|
||||
|
||||
def test_log_structured_event_skips_pubsub_without_required_env(monkeypatch) -> None:
|
||||
fake_publisher = _FakePublisher()
|
||||
|
||||
monkeypatch.delenv("GCP_PROJECT_ID", raising=False)
|
||||
monkeypatch.delenv("AGENT_PUBSUB_TOPIC", raising=False)
|
||||
monkeypatch.setattr(structured_pubsub, "_PUBLISHER", fake_publisher)
|
||||
monkeypatch.setattr(structured_pubsub, "_TOPIC_PATH", "projects/project-1/topics/agent-logs")
|
||||
|
||||
structured_pubsub.publish_structured_event({"tipo_evento": "envio msg"})
|
||||
|
||||
assert fake_publisher.calls == []
|
||||
|
||||
|
||||
def test_build_structured_event_includes_session_id_and_lowercase_nome_agente() -> None:
|
||||
event = logging_utils.build_structured_event(
|
||||
logging_utils.StructuredLogContext(
|
||||
callid="call-1",
|
||||
session_id="session-1",
|
||||
num_telefone="5511999999999",
|
||||
cod_ani="1234",
|
||||
nome_agente="conta",
|
||||
),
|
||||
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
|
||||
message_id="message-1",
|
||||
inicio_ns=1_700_000_000_000_000_000,
|
||||
)
|
||||
|
||||
assert event["session_id"] == "session-1"
|
||||
assert event["nome_agente"] == "conta"
|
||||
assert event["dat_hora_fim"] == ""
|
||||
assert event["latencia_total_STT_TTS"] == ""
|
||||
assert event["latencia_TFFB_STT_TTS"] == ""
|
||||
assert event["duracao_audio"] == ""
|
||||
assert event["tts_max_gap_ms"] == ""
|
||||
assert event["tts_max_underrun_0ms"] == ""
|
||||
assert event["tts_underflow_count"] == ""
|
||||
assert event["tts_avg_underflow_ms"] == ""
|
||||
assert "tts_clear_rtt_ms" not in event
|
||||
assert "tts_connection_queue_wait_ms" not in event
|
||||
assert "tts_clear_stale_messages" not in event
|
||||
assert "tts_clear_stale_audio_bytes" not in event
|
||||
assert event["interrupcao"] == ""
|
||||
assert event["erro_msg"] == ""
|
||||
assert event["erro_detalhe"] == ""
|
||||
assert event["http_cod_status"] == ""
|
||||
assert event["http_cod_desc"] == ""
|
||||
assert event["finalizacao"] == ""
|
||||
assert all(value is not None for value in event.values())
|
||||
assert "Nome_agente" not in event
|
||||
|
||||
|
||||
def test_build_structured_event_includes_audio_duration_and_interruption_flag() -> None:
|
||||
event = logging_utils.build_structured_event(
|
||||
logging_utils.StructuredLogContext(
|
||||
callid="call-1",
|
||||
session_id="session-1",
|
||||
num_telefone="5511999999999",
|
||||
cod_ani="1234",
|
||||
nome_agente="conta",
|
||||
),
|
||||
tipo_evento=logging_utils.EVENT_ENVIO_MSG,
|
||||
message_id="message-1",
|
||||
inicio_ns=1_700_000_000_000_000_000,
|
||||
duracao_audio_ms=2400,
|
||||
tts_max_gap_ms=840,
|
||||
tts_max_underrun_0ms=125,
|
||||
interrupcao=True,
|
||||
)
|
||||
|
||||
assert event["duracao_audio"] == 2400
|
||||
assert event["tts_max_gap_ms"] == 840
|
||||
assert event["tts_max_underrun_0ms"] == 125
|
||||
assert event["interrupcao"] == 1
|
||||
|
||||
|
||||
def test_build_structured_event_uses_uuid_when_message_id_is_missing(monkeypatch) -> None:
|
||||
generated = uuid.UUID("12345678-1234-4234-9234-123456789abc")
|
||||
monkeypatch.setattr(logging_utils.uuid, "uuid4", lambda: generated)
|
||||
|
||||
event = logging_utils.build_structured_event(
|
||||
logging_utils.StructuredLogContext(
|
||||
callid="call-1",
|
||||
session_id="session-1",
|
||||
num_telefone="5511999999999",
|
||||
cod_ani="1234",
|
||||
nome_agente="conta",
|
||||
),
|
||||
tipo_evento=logging_utils.EVENT_ENVIO_MSG,
|
||||
inicio_ns=1_700_000_000_000_000_000,
|
||||
)
|
||||
|
||||
assert event["message_id"] == "12345678-1234-4234-9234-123456789abc"
|
||||
|
||||
|
||||
def test_error_message_from_resource_uses_event_specific_contract() -> None:
|
||||
assert (
|
||||
logging_utils.error_message_from_resource(
|
||||
resource="agent_runtime",
|
||||
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
|
||||
)
|
||||
== "Falha TIA"
|
||||
)
|
||||
assert (
|
||||
logging_utils.error_message_from_resource(
|
||||
resource="stt",
|
||||
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
|
||||
)
|
||||
== "Falha STT"
|
||||
)
|
||||
assert (
|
||||
logging_utils.error_message_from_resource(
|
||||
resource="bridge",
|
||||
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
|
||||
)
|
||||
== "Falha comunicacao"
|
||||
)
|
||||
assert (
|
||||
logging_utils.error_message_from_resource(
|
||||
status="stop_silencio_longo",
|
||||
tipo_evento=logging_utils.EVENT_RECEBIMENTO_MSG,
|
||||
)
|
||||
== "Silencio Longo"
|
||||
)
|
||||
assert (
|
||||
logging_utils.error_message_from_resource(
|
||||
resource="agent_backend",
|
||||
tipo_evento=logging_utils.EVENT_ENVIO_MSG,
|
||||
)
|
||||
== "Falha comunicacao"
|
||||
)
|
||||
assert (
|
||||
logging_utils.error_message_from_resource(
|
||||
resource="tts",
|
||||
tipo_evento=logging_utils.EVENT_ENVIO_MSG,
|
||||
)
|
||||
== "Falha TTS"
|
||||
)
|
||||
assert (
|
||||
logging_utils.error_message_from_resource(
|
||||
status="transferred",
|
||||
tipo_evento=logging_utils.EVENT_ENVIO_MSG,
|
||||
)
|
||||
== "Transferido"
|
||||
)
|
||||
|
||||
|
||||
def test_format_event_timestamp_uses_iso_utc_milliseconds() -> None:
|
||||
assert logging_utils.format_event_timestamp(1_700_000_001_234_567_890) == (
|
||||
"2023-11-14T22:13:21.234Z"
|
||||
)
|
||||
|
||||
|
||||
def test_structured_context_from_start_data_keeps_session_id_empty_when_not_provided() -> None:
|
||||
context = logging_utils.structured_context_from_start_data(
|
||||
{
|
||||
"callIdGed": "ged-1",
|
||||
"gsm": "5511999999999",
|
||||
"ani": "1234",
|
||||
"agent": "conta",
|
||||
}
|
||||
)
|
||||
|
||||
assert context.callid == "ged-1"
|
||||
assert context.session_id == ""
|
||||
|
||||
|
||||
def test_topic_path_accepts_short_topic_or_full_resource_name() -> None:
|
||||
fake_publisher = _FakePublisher()
|
||||
|
||||
assert structured_pubsub._topic_path(fake_publisher, "project-1", "agent-logs") == (
|
||||
"projects/project-1/topics/agent-logs"
|
||||
)
|
||||
assert structured_pubsub._topic_path(
|
||||
fake_publisher,
|
||||
"project-1",
|
||||
"projects/another-project/topics/agent-logs",
|
||||
) == "projects/another-project/topics/agent-logs"
|
||||
|
||||
|
||||
def test_trace_id_from_session_id_sanitizes_uuid_and_rejects_invalid_values() -> None:
|
||||
assert structured_otlp.trace_id_from_session_id(
|
||||
"550e8400-e29b-41d4-a716-446655440101"
|
||||
) == "550e8400e29b41d4a716446655440101"
|
||||
assert structured_otlp.trace_id_from_session_id("sess-001") == ""
|
||||
assert structured_otlp.trace_id_from_session_id("00000000-0000-0000-0000-000000000000") == ""
|
||||
|
||||
|
||||
def _install_memory_tracer(monkeypatch):
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
|
||||
endpoint = "http://otel.example/v1/traces"
|
||||
exporter = InMemorySpanExporter()
|
||||
provider = TracerProvider(id_generator=structured_otlp._ID_GENERATOR)
|
||||
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||||
|
||||
monkeypatch.setenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", endpoint)
|
||||
monkeypatch.setattr(structured_otlp, "_TRACER_PROVIDER", provider)
|
||||
monkeypatch.setattr(structured_otlp, "_TRACER", provider.get_tracer("test.structured_otlp"))
|
||||
monkeypatch.setattr(structured_otlp, "_TRACER_ENDPOINT", endpoint)
|
||||
return exporter
|
||||
|
||||
|
||||
def test_publish_structured_span_exports_otlp_span_with_timestamps_and_attributes(monkeypatch) -> None:
|
||||
exporter = _install_memory_tracer(monkeypatch)
|
||||
start_ns = 1_700_000_000_000_000_000
|
||||
end_ns = 1_700_000_001_234_000_000
|
||||
|
||||
event = {
|
||||
"tipo_evento": "envio msg",
|
||||
"message_id": "message-1",
|
||||
"dat_hora_inicio": logging_utils.format_event_timestamp(start_ns),
|
||||
"dat_hora_fim": logging_utils.format_event_timestamp(end_ns),
|
||||
"callid": "call-1",
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440101",
|
||||
"num_telefone": "5511999999999",
|
||||
"cod_ani": "1234",
|
||||
"latencia_total_STT_TTS": 1234,
|
||||
"latencia_TFFB_STT_TTS": 123,
|
||||
"erro_msg": "Falha TTS",
|
||||
"erro_detalhe": "",
|
||||
"http_cod_status": "",
|
||||
"http_cod_desc": "",
|
||||
"finalizacao": "",
|
||||
"nome_agente": "conta",
|
||||
"original_message_id": "original-message-1",
|
||||
}
|
||||
|
||||
structured_otlp.publish_structured_span(event)
|
||||
|
||||
spans = exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
span = spans[0]
|
||||
assert span.name == "structured_log.envio msg"
|
||||
assert span.context.trace_id == int("550e8400e29b41d4a716446655440101", 16)
|
||||
assert span.context.span_id != 0
|
||||
assert span.parent is None
|
||||
assert span.start_time == start_ns
|
||||
assert span.end_time == end_ns
|
||||
assert span.attributes["tipo_evento"] == "envio msg"
|
||||
assert span.attributes["message_id"] == "message-1"
|
||||
assert span.attributes["latencia_total_STT_TTS"] == 1234
|
||||
assert span.attributes["erro_msg"] == "Falha TTS"
|
||||
assert span.attributes["erro_detalhe"] == ""
|
||||
assert span.attributes["http_cod_status"] == ""
|
||||
assert span.attributes["http_cod_desc"] == ""
|
||||
assert span.attributes["finalizacao"] == ""
|
||||
assert "original_message_id" not in span.attributes
|
||||
assert span.status.status_code.name == "ERROR"
|
||||
|
||||
|
||||
def test_publish_structured_span_keeps_same_trace_id_and_lets_sdk_generate_span_id(monkeypatch) -> None:
|
||||
exporter = _install_memory_tracer(monkeypatch)
|
||||
trace_id = int("550e8400e29b41d4a716446655440101", 16)
|
||||
|
||||
base_event = {
|
||||
"dat_hora_inicio": logging_utils.format_event_timestamp(1_700_000_000_000_000_000),
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440101",
|
||||
}
|
||||
structured_otlp.publish_structured_span({**base_event, "tipo_evento": "recebimento msg"})
|
||||
structured_otlp.publish_structured_span({**base_event, "tipo_evento": "envio msg"})
|
||||
|
||||
spans = exporter.get_finished_spans()
|
||||
assert len(spans) == 2
|
||||
assert {span.context.trace_id for span in spans} == {trace_id}
|
||||
assert spans[0].context.span_id != spans[1].context.span_id
|
||||
assert spans[0].parent is None
|
||||
assert spans[1].parent is None
|
||||
|
||||
|
||||
def test_publish_structured_span_omits_empty_tffb_attribute(monkeypatch) -> None:
|
||||
exporter = _install_memory_tracer(monkeypatch)
|
||||
|
||||
structured_otlp.publish_structured_span(
|
||||
{
|
||||
"tipo_evento": "envio msg",
|
||||
"dat_hora_inicio": logging_utils.format_event_timestamp(1_700_000_000_000_000_000),
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440101",
|
||||
"latencia_TFFB_STT_TTS": "",
|
||||
}
|
||||
)
|
||||
|
||||
spans = exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
assert "latencia_TFFB_STT_TTS" not in spans[0].attributes
|
||||
|
||||
|
||||
def test_publish_structured_span_skips_invalid_session_id(monkeypatch) -> None:
|
||||
exporter = _install_memory_tracer(monkeypatch)
|
||||
|
||||
structured_otlp.publish_structured_span(
|
||||
{
|
||||
"tipo_evento": "recebimento msg",
|
||||
"dat_hora_inicio": logging_utils.format_event_timestamp(1_700_000_000_000_000_000),
|
||||
"session_id": "sess-001",
|
||||
}
|
||||
)
|
||||
|
||||
assert exporter.get_finished_spans() == ()
|
||||
|
||||
|
||||
def test_publish_structured_span_skips_without_otlp_endpoint(monkeypatch) -> None:
|
||||
monkeypatch.delenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", raising=False)
|
||||
monkeypatch.setattr(structured_otlp, "_TRACER", None)
|
||||
monkeypatch.setattr(structured_otlp, "_TRACER_ENDPOINT", "")
|
||||
|
||||
structured_otlp.publish_structured_span(
|
||||
{
|
||||
"tipo_evento": "recebimento msg",
|
||||
"dat_hora_inicio": logging_utils.format_event_timestamp(1_700_000_000_000_000_000),
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440101",
|
||||
}
|
||||
)
|
||||
85
tests/utils/test_structured_otlp.py
Normal file
85
tests/utils/test_structured_otlp.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from opentelemetry.sdk.trace.export import SpanExportResult
|
||||
|
||||
import app.utils.structured_otlp as structured_otlp
|
||||
|
||||
|
||||
class _RecordingExporter:
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
self.exported: list[int] = []
|
||||
self.shutdown_calls = 0
|
||||
|
||||
def export(self, spans: object) -> SpanExportResult:
|
||||
self.exported.append(len(list(spans)))
|
||||
return SpanExportResult.SUCCESS
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.shutdown_calls += 1
|
||||
|
||||
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _reset_provider_without_shutdown() -> None:
|
||||
with structured_otlp._TRACER_LOCK:
|
||||
structured_otlp._TRACER = None
|
||||
structured_otlp._TRACER_PROVIDER = None
|
||||
structured_otlp._TRACER_ENDPOINT = ""
|
||||
|
||||
|
||||
def test_span_export_is_batched_not_synchronous(monkeypatch) -> None:
|
||||
old_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
|
||||
os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = "http://localhost:4318/v1/traces"
|
||||
exporter = _RecordingExporter()
|
||||
monkeypatch.setattr(
|
||||
structured_otlp,
|
||||
"OTLPSpanExporter",
|
||||
lambda *args, **kwargs: exporter,
|
||||
)
|
||||
_reset_provider_without_shutdown()
|
||||
try:
|
||||
structured_otlp.publish_structured_span(
|
||||
{
|
||||
"session_id": "0123456789abcdef0123456789abcdef",
|
||||
"tipo_evento": "audio_in",
|
||||
"dat_hora_inicio": "2026-07-24T12:00:00.000000Z",
|
||||
"dat_hora_fim": "2026-07-24T12:00:00.010000Z",
|
||||
}
|
||||
)
|
||||
|
||||
assert exporter.exported == []
|
||||
assert structured_otlp.force_flush_structured_otlp(timeout_millis=5_000)
|
||||
assert sum(exporter.exported) >= 1
|
||||
finally:
|
||||
structured_otlp.shutdown_structured_otlp()
|
||||
if old_endpoint is None:
|
||||
os.environ.pop("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", None)
|
||||
else:
|
||||
os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = old_endpoint
|
||||
|
||||
|
||||
def test_shutdown_flushes_provider_once(monkeypatch) -> None:
|
||||
old_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
|
||||
os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = "http://localhost:4318/v1/traces"
|
||||
exporter = _RecordingExporter()
|
||||
monkeypatch.setattr(
|
||||
structured_otlp,
|
||||
"OTLPSpanExporter",
|
||||
lambda *args, **kwargs: exporter,
|
||||
)
|
||||
_reset_provider_without_shutdown()
|
||||
try:
|
||||
assert structured_otlp._tracer() is not None
|
||||
structured_otlp.shutdown_structured_otlp()
|
||||
structured_otlp.shutdown_structured_otlp()
|
||||
assert exporter.shutdown_calls == 1
|
||||
assert structured_otlp._TRACER_PROVIDER is None
|
||||
finally:
|
||||
structured_otlp.shutdown_structured_otlp()
|
||||
if old_endpoint is None:
|
||||
os.environ.pop("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", None)
|
||||
else:
|
||||
os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"] = old_endpoint
|
||||
221
tests/utils/test_stt_audio_upload.py
Normal file
221
tests/utils/test_stt_audio_upload.py
Normal file
@@ -0,0 +1,221 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime
|
||||
|
||||
from app.utils import stt_audio_upload
|
||||
from app.utils.stt_audio_upload import (
|
||||
build_stt_vad_object_name,
|
||||
enqueue_stt_vad_audio_upload,
|
||||
)
|
||||
|
||||
|
||||
_BUCKET_ENV_NAMES = (
|
||||
"OCI_AUTH_MODE",
|
||||
"BUCKET_USER_ID",
|
||||
"BUCKET_PRIVATE_KEY",
|
||||
"BUCKET_FINGERPRINT",
|
||||
"BUCKET_TENANCY_ID",
|
||||
"BUCKET_REGION",
|
||||
"BUCKET_NAME",
|
||||
"BUCKET_NAMESPACE",
|
||||
)
|
||||
|
||||
|
||||
def _clear_bucket_env(monkeypatch) -> None:
|
||||
for name in _BUCKET_ENV_NAMES:
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
|
||||
def _install_fake_oci_modules(monkeypatch) -> dict[str, object]:
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
class FakeObjectStorageClient:
|
||||
def __init__(self, config, **kwargs) -> None:
|
||||
calls["client_config"] = config
|
||||
calls["client_kwargs"] = kwargs
|
||||
|
||||
def fake_oke_signer() -> object:
|
||||
signer = object()
|
||||
calls["signer"] = signer
|
||||
return signer
|
||||
|
||||
fake_oci = types.ModuleType("oci")
|
||||
fake_auth = types.ModuleType("oci.auth")
|
||||
fake_signers = types.ModuleType("oci.auth.signers")
|
||||
fake_object_storage = types.ModuleType("oci.object_storage")
|
||||
|
||||
fake_object_storage.ObjectStorageClient = FakeObjectStorageClient
|
||||
fake_signers.get_oke_workload_identity_resource_principal_signer = fake_oke_signer
|
||||
fake_auth.signers = fake_signers
|
||||
fake_oci.auth = fake_auth
|
||||
fake_oci.object_storage = fake_object_storage
|
||||
|
||||
monkeypatch.setitem(sys.modules, "oci", fake_oci)
|
||||
monkeypatch.setitem(sys.modules, "oci.auth", fake_auth)
|
||||
monkeypatch.setitem(sys.modules, "oci.auth.signers", fake_signers)
|
||||
monkeypatch.setitem(sys.modules, "oci.object_storage", fake_object_storage)
|
||||
return calls
|
||||
|
||||
|
||||
def _reset_oci_client_cache(monkeypatch) -> None:
|
||||
monkeypatch.setattr(stt_audio_upload, "_OCI_CLIENT", None)
|
||||
monkeypatch.setattr(stt_audio_upload, "_OCI_CLIENT_CONFIG", None)
|
||||
|
||||
|
||||
def test_stt_vad_object_name_uses_requested_layout_and_stays_short() -> None:
|
||||
object_name = build_stt_vad_object_name(
|
||||
session_id="sessao muito longa/" * 30,
|
||||
message_id="message-id-muito-longo/" * 30,
|
||||
req_id="req-id-muito-longo/" * 30,
|
||||
now=datetime(2026, 6, 24, 13, 52, 21),
|
||||
)
|
||||
|
||||
parts = object_name.split("/")
|
||||
assert parts[0] == "2026-06-24"
|
||||
assert object_name.endswith(".wav")
|
||||
assert len(parts) == 3
|
||||
assert len(object_name.encode("utf-8")) <= 512
|
||||
assert all(len(part) <= 68 for part in parts[1:])
|
||||
assert " " not in object_name
|
||||
|
||||
|
||||
def test_stt_vad_object_name_uses_message_id_as_filename() -> None:
|
||||
object_name = build_stt_vad_object_name(
|
||||
session_id="417a5a59-961a-4ba6-9c68-71e92a3da46d",
|
||||
message_id="0bc6875e-ebd1-4dca-a43d-2f7de404895b",
|
||||
req_id="c182775c8c114e3f833bfb7675e0be70",
|
||||
now=datetime(2026, 6, 29, 10, 30, 0),
|
||||
)
|
||||
|
||||
assert (
|
||||
object_name
|
||||
== "2026-06-29/417a5a59-961a-4ba6-9c68-71e92a3da46d/"
|
||||
"0bc6875e-ebd1-4dca-a43d-2f7de404895b.wav"
|
||||
)
|
||||
assert "c182775c8c114e3f833bfb7675e0be70" not in object_name
|
||||
|
||||
|
||||
def test_oci_upload_config_reads_bucket_env_and_private_key(monkeypatch) -> None:
|
||||
_clear_bucket_env(monkeypatch)
|
||||
monkeypatch.setenv("OCI_AUTH_MODE", "local")
|
||||
monkeypatch.setenv("BUCKET_USER_ID", "user-ocid")
|
||||
monkeypatch.setenv("BUCKET_PRIVATE_KEY", "-----BEGIN KEY-----\\nabc\\n-----END KEY-----")
|
||||
monkeypatch.setenv("BUCKET_FINGERPRINT", "fingerprint")
|
||||
monkeypatch.setenv("BUCKET_TENANCY_ID", "tenancy-ocid")
|
||||
monkeypatch.setenv("BUCKET_REGION", "sa-saopaulo-1")
|
||||
monkeypatch.setenv("BUCKET_NAME", "tia-audio")
|
||||
monkeypatch.setenv("BUCKET_NAMESPACE", "namespace")
|
||||
|
||||
config = stt_audio_upload._oci_upload_config_from_env()
|
||||
|
||||
assert config is not None
|
||||
assert config.auth_mode == "local"
|
||||
assert config.bucket == "tia-audio"
|
||||
assert config.namespace == "namespace"
|
||||
assert config.key_content == "-----BEGIN KEY-----\nabc\n-----END KEY-----"
|
||||
|
||||
|
||||
def test_oci_upload_config_uses_oke_when_auth_mode_is_absent(monkeypatch) -> None:
|
||||
_clear_bucket_env(monkeypatch)
|
||||
monkeypatch.setenv("BUCKET_REGION", "sa-saopaulo-1")
|
||||
monkeypatch.setenv("BUCKET_NAME", "tia-audio")
|
||||
monkeypatch.setenv("BUCKET_NAMESPACE", "namespace")
|
||||
|
||||
config = stt_audio_upload._oci_upload_config_from_env()
|
||||
|
||||
assert config is not None
|
||||
assert config.auth_mode == "oke_workload_identity"
|
||||
assert config.bucket == "tia-audio"
|
||||
assert config.namespace == "namespace"
|
||||
assert config.user == ""
|
||||
assert config.key_content == ""
|
||||
|
||||
|
||||
def test_oci_upload_config_uses_oke_when_auth_mode_is_not_local(monkeypatch) -> None:
|
||||
_clear_bucket_env(monkeypatch)
|
||||
monkeypatch.setenv("OCI_AUTH_MODE", "prd")
|
||||
monkeypatch.setenv("BUCKET_REGION", "sa-saopaulo-1")
|
||||
monkeypatch.setenv("BUCKET_NAME", "tia-audio")
|
||||
monkeypatch.setenv("BUCKET_NAMESPACE", "namespace")
|
||||
|
||||
config = stt_audio_upload._oci_upload_config_from_env()
|
||||
|
||||
assert config is not None
|
||||
assert config.auth_mode == "oke_workload_identity"
|
||||
|
||||
|
||||
def test_oci_upload_config_is_missing_when_local_required_env_is_absent(monkeypatch) -> None:
|
||||
_clear_bucket_env(monkeypatch)
|
||||
monkeypatch.setenv("OCI_AUTH_MODE", "local")
|
||||
monkeypatch.setenv("BUCKET_REGION", "sa-saopaulo-1")
|
||||
monkeypatch.setenv("BUCKET_NAME", "tia-audio")
|
||||
monkeypatch.setenv("BUCKET_NAMESPACE", "namespace")
|
||||
|
||||
assert stt_audio_upload._oci_upload_config_from_env() is None
|
||||
|
||||
|
||||
def test_oci_upload_config_is_missing_when_oke_required_env_is_absent(monkeypatch) -> None:
|
||||
_clear_bucket_env(monkeypatch)
|
||||
monkeypatch.setenv("BUCKET_REGION", "sa-saopaulo-1")
|
||||
monkeypatch.setenv("BUCKET_NAME", "tia-audio")
|
||||
|
||||
assert stt_audio_upload._oci_upload_config_from_env() is None
|
||||
|
||||
|
||||
def test_oci_client_uses_api_key_config_for_local_mode(monkeypatch) -> None:
|
||||
_reset_oci_client_cache(monkeypatch)
|
||||
calls = _install_fake_oci_modules(monkeypatch)
|
||||
config = stt_audio_upload.OCIUploadConfig(
|
||||
auth_mode="local",
|
||||
region="sa-saopaulo-1",
|
||||
bucket="tia-audio",
|
||||
namespace="namespace",
|
||||
user="user-ocid",
|
||||
key_content="private-key",
|
||||
fingerprint="fingerprint",
|
||||
tenancy="tenancy-ocid",
|
||||
)
|
||||
|
||||
stt_audio_upload._oci_client(config)
|
||||
|
||||
assert calls["client_config"] == {
|
||||
"user": "user-ocid",
|
||||
"key_content": "private-key",
|
||||
"fingerprint": "fingerprint",
|
||||
"tenancy": "tenancy-ocid",
|
||||
"region": "sa-saopaulo-1",
|
||||
}
|
||||
assert calls["client_kwargs"] == {}
|
||||
assert "signer" not in calls
|
||||
|
||||
|
||||
def test_oci_client_uses_oke_signer_for_workload_identity(monkeypatch) -> None:
|
||||
_reset_oci_client_cache(monkeypatch)
|
||||
calls = _install_fake_oci_modules(monkeypatch)
|
||||
config = stt_audio_upload.OCIUploadConfig(
|
||||
auth_mode="oke_workload_identity",
|
||||
region="sa-saopaulo-1",
|
||||
bucket="tia-audio",
|
||||
namespace="namespace",
|
||||
)
|
||||
|
||||
stt_audio_upload._oci_client(config)
|
||||
|
||||
assert calls["client_config"] == {"region": "sa-saopaulo-1"}
|
||||
assert calls["client_kwargs"] == {"signer": calls["signer"]}
|
||||
|
||||
|
||||
def test_enqueue_stt_vad_audio_upload_is_noop_without_oci_config(monkeypatch) -> None:
|
||||
monkeypatch.setattr(stt_audio_upload, "_oci_upload_config_from_env", lambda: None)
|
||||
|
||||
assert (
|
||||
enqueue_stt_vad_audio_upload(
|
||||
wav_bytes=b"fake-wav",
|
||||
req_id="req-1",
|
||||
message_id="message-1",
|
||||
structured_log_context={"session_id": "session-1"},
|
||||
)
|
||||
is None
|
||||
)
|
||||
56
tests/utils/test_turn_ids.py
Normal file
56
tests/utils/test_turn_ids.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from app.utils.turn_ids import (
|
||||
clear_started_turn_message_id,
|
||||
next_turn_message_id,
|
||||
peek_started_turn_message_id,
|
||||
register_started_turn_message_id,
|
||||
reset_turn_message_sequence,
|
||||
)
|
||||
|
||||
|
||||
def test_next_turn_message_id_uses_uuid4() -> None:
|
||||
generated = uuid.UUID("12345678-1234-4234-9234-123456789abc")
|
||||
|
||||
with mock.patch("app.utils.turn_ids.uuid.uuid4", return_value=generated):
|
||||
message_id = next_turn_message_id(
|
||||
{
|
||||
"callIdGed": "GED-555",
|
||||
"protocol_id": "PRT-555",
|
||||
"session_id": "session-555",
|
||||
}
|
||||
)
|
||||
|
||||
assert message_id == "12345678-1234-4234-9234-123456789abc"
|
||||
|
||||
|
||||
def test_started_turn_message_id_can_be_registered_peeked_and_cleared() -> None:
|
||||
source = {"session_id": "session-started-turn"}
|
||||
reset_turn_message_sequence(source, clear_pending=True)
|
||||
|
||||
assert peek_started_turn_message_id(source) == ""
|
||||
|
||||
register_started_turn_message_id(source, "message-1")
|
||||
assert peek_started_turn_message_id(source) == "message-1"
|
||||
|
||||
register_started_turn_message_id(source, "message-2")
|
||||
assert peek_started_turn_message_id(source) == "message-2"
|
||||
|
||||
clear_started_turn_message_id(source, "message-1")
|
||||
assert peek_started_turn_message_id(source) == "message-2"
|
||||
|
||||
clear_started_turn_message_id(source, "message-2")
|
||||
assert peek_started_turn_message_id(source) == ""
|
||||
|
||||
|
||||
def test_reset_turn_message_sequence_clears_started_turn_message_id() -> None:
|
||||
source = {"session_id": "session-reset-started-turn"}
|
||||
reset_turn_message_sequence(source, clear_pending=True)
|
||||
register_started_turn_message_id(source, "message-to-reset")
|
||||
|
||||
reset_turn_message_sequence(source, clear_pending=True)
|
||||
|
||||
assert peek_started_turn_message_id(source) == ""
|
||||
1
tests/ws_gateway/__init__.py
Normal file
1
tests/ws_gateway/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
9
tests/ws_gateway/test_audio_flow_logging.py
Normal file
9
tests/ws_gateway/test_audio_flow_logging.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from app.ws_gateway import main as main_module
|
||||
|
||||
|
||||
def test_audio_flow_burst_gap_controls_repeated_logs(monkeypatch) -> None:
|
||||
monkeypatch.setattr(main_module, "FLOW_AUDIO_BURST_GAP_S", 0.8)
|
||||
|
||||
assert main_module._is_new_audio_burst(10.0, 0.0) is True
|
||||
assert main_module._is_new_audio_burst(10.5, 10.0) is False
|
||||
assert main_module._is_new_audio_burst(10.8, 10.0) is True
|
||||
305
tests/ws_gateway/test_audio_input_backlog.py
Normal file
305
tests/ws_gateway/test_audio_input_backlog.py
Normal file
@@ -0,0 +1,305 @@
|
||||
import asyncio
|
||||
import struct
|
||||
|
||||
from app.ws_gateway import main as main_module
|
||||
|
||||
|
||||
def _frame(value: int) -> bytes:
|
||||
return bytes([value % 256]) * main_module.BYTES_PER_FRAME
|
||||
|
||||
|
||||
def _silent_frame() -> bytes:
|
||||
return b"\x00" * main_module.BYTES_PER_FRAME
|
||||
|
||||
|
||||
def _voiced_frame(amp: int = 8000) -> bytes:
|
||||
samples = main_module.BYTES_PER_FRAME // 2
|
||||
return struct.pack("<%dh" % samples, *([amp] * samples))
|
||||
|
||||
|
||||
def _config(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
energy_shed_enabled: bool = False,
|
||||
energy_shed_max_excess_ms: int = 1000,
|
||||
silence_dbfs: float = -50.0,
|
||||
) -> main_module.AudioInputBacklogConfig:
|
||||
return main_module.AudioInputBacklogConfig(
|
||||
shed_enabled=enabled,
|
||||
shed_threshold_ms=100,
|
||||
shed_keep_ms=40,
|
||||
latency_metrics_enabled=True,
|
||||
latency_alert_ms=1000,
|
||||
latency_log_interval_s=15.0,
|
||||
livekit_source_queue_size_ms=500,
|
||||
livekit_source_clear_on_shed=True,
|
||||
energy_shed_enabled=energy_shed_enabled,
|
||||
energy_shed_max_excess_ms=energy_shed_max_excess_ms,
|
||||
silence_dbfs=silence_dbfs,
|
||||
)
|
||||
|
||||
|
||||
def test_audio_input_latency_snapshot_separates_raw_and_effective_delay() -> None:
|
||||
tracker = main_module.AudioInputLatencyTracker(_config())
|
||||
tracker.mark_enabled(100.0)
|
||||
|
||||
fields = tracker.snapshot(frame_count=1064, now=110.872, queue_size_frames=0)
|
||||
|
||||
assert fields["received_audio_ms"] == 21280
|
||||
assert fields["elapsed_since_enable_ms"] == 10872
|
||||
assert fields["raw_excess_ms"] == 10408
|
||||
assert fields["raw_lag_ms"] == 10408
|
||||
assert fields["excess_ms"] == 10408
|
||||
assert fields["lag_ms"] == 10408
|
||||
|
||||
tracker.total_dropped_frames = 520
|
||||
fields = tracker.snapshot(frame_count=1064, now=110.872, queue_size_frames=0)
|
||||
|
||||
assert fields["total_dropped_ms"] == 10400
|
||||
assert fields["saved_latency_ms"] == 10400
|
||||
assert fields["raw_excess_ms"] == 10408
|
||||
assert fields["raw_lag_ms"] == 10408
|
||||
assert fields["excess_ms"] == 8
|
||||
assert fields["lag_ms"] == 8
|
||||
|
||||
|
||||
def test_shed_audio_queue_backlog_keeps_recent_frames_and_logs_savings(monkeypatch) -> None:
|
||||
events = []
|
||||
debug_events = []
|
||||
|
||||
def fake_log_flow_event(logger, step, **payload):
|
||||
events.append((step, payload))
|
||||
|
||||
monkeypatch.setattr(main_module, "log_flow_event", fake_log_flow_event)
|
||||
audio_q: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
for value in range(8):
|
||||
audio_q.put_nowait(_frame(value))
|
||||
tracker = main_module.AudioInputLatencyTracker(
|
||||
_config(),
|
||||
debug_event_publisher=lambda event, payload: debug_events.append(
|
||||
(event, payload)
|
||||
),
|
||||
)
|
||||
tracker.mark_enabled(0.0)
|
||||
|
||||
dropped = main_module._shed_audio_queue_backlog(
|
||||
audio_q,
|
||||
config=_config(),
|
||||
tracker=tracker,
|
||||
source="test",
|
||||
frame_count=8,
|
||||
now=1.0,
|
||||
)
|
||||
|
||||
assert dropped.dropped == 6
|
||||
assert audio_q.qsize() == 2
|
||||
assert [audio_q.get_nowait(), audio_q.get_nowait()] == [_frame(6), _frame(7)]
|
||||
assert events[-1][0] == "audio_in_latency_shed"
|
||||
assert events[-1][1]["dropped_ms"] == 120
|
||||
assert events[-1][1]["saved_ms"] == 120
|
||||
assert events[-1][1]["saved_latency_ms"] == 120
|
||||
assert events[-1][1]["queue_before_ms"] == 160
|
||||
assert events[-1][1]["queue_after_ms"] == 40
|
||||
assert events[-1][1]["total_dropped_ms"] == 120
|
||||
assert debug_events[-1][0] == "bridge.audio_in.shed"
|
||||
assert debug_events[-1][1]["dropped_ms"] == 120
|
||||
|
||||
|
||||
def test_shed_audio_queue_backlog_is_disabled_by_flag(monkeypatch) -> None:
|
||||
events = []
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"log_flow_event",
|
||||
lambda logger, step, **payload: events.append((step, payload)),
|
||||
)
|
||||
audio_q: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
for value in range(8):
|
||||
audio_q.put_nowait(_frame(value))
|
||||
|
||||
dropped = main_module._shed_audio_queue_backlog(
|
||||
audio_q,
|
||||
config=_config(enabled=False),
|
||||
tracker=main_module.AudioInputLatencyTracker(_config(enabled=False)),
|
||||
source="test",
|
||||
frame_count=8,
|
||||
now=1.0,
|
||||
)
|
||||
|
||||
assert dropped.dropped == 0
|
||||
assert audio_q.qsize() == 8
|
||||
assert events == []
|
||||
|
||||
|
||||
def test_audio_input_backlog_config_defaults_to_enabled(monkeypatch) -> None:
|
||||
for name in (
|
||||
"AUDIO_IN_BACKLOG_SHED_ENABLED",
|
||||
"AUDIO_IN_BACKLOG_SHED_THRESHOLD_MS",
|
||||
"AUDIO_IN_BACKLOG_SHED_KEEP_MS",
|
||||
"AUDIO_IN_LATENCY_METRICS_ENABLED",
|
||||
"AUDIO_IN_LATENCY_ALERT_MS",
|
||||
"AUDIO_IN_LATENCY_LOG_INTERVAL_S",
|
||||
"LIVEKIT_AUDIO_SOURCE_QUEUE_SIZE_MS",
|
||||
"LIVEKIT_AUDIO_SOURCE_CLEAR_ON_SHED",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
config = main_module.audio_input_backlog_config_from_env()
|
||||
|
||||
assert config.shed_enabled is True
|
||||
assert config.shed_threshold_ms == 500
|
||||
assert config.shed_keep_ms == 300
|
||||
assert config.latency_metrics_enabled is True
|
||||
assert config.livekit_source_queue_size_ms == 500
|
||||
assert config.livekit_source_clear_on_shed is True
|
||||
assert config.config_source == "env"
|
||||
assert config.call_config_overrides == ()
|
||||
|
||||
|
||||
def test_audio_input_backlog_config_preserves_legacy_source_queue_when_disabled(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AUDIO_IN_BACKLOG_SHED_ENABLED", "0")
|
||||
monkeypatch.delenv("LIVEKIT_AUDIO_SOURCE_QUEUE_SIZE_MS", raising=False)
|
||||
monkeypatch.delenv("LIVEKIT_AUDIO_SOURCE_CLEAR_ON_SHED", raising=False)
|
||||
|
||||
config = main_module.audio_input_backlog_config_from_env()
|
||||
|
||||
assert config.shed_enabled is False
|
||||
assert config.livekit_source_queue_size_ms == 5000
|
||||
assert config.livekit_source_clear_on_shed is False
|
||||
assert config.config_source == "env"
|
||||
|
||||
|
||||
def test_audio_input_backlog_config_prefers_call_config_and_falls_back_to_env(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AUDIO_IN_BACKLOG_SHED_ENABLED", "0")
|
||||
monkeypatch.setenv("AUDIO_IN_BACKLOG_SHED_THRESHOLD_MS", "900")
|
||||
monkeypatch.setenv("AUDIO_IN_BACKLOG_SHED_KEEP_MS", "600")
|
||||
monkeypatch.setenv("AUDIO_IN_LATENCY_METRICS_ENABLED", "1")
|
||||
monkeypatch.setenv("AUDIO_IN_LATENCY_ALERT_MS", "1200")
|
||||
monkeypatch.setenv("AUDIO_IN_LATENCY_LOG_INTERVAL_S", "20")
|
||||
monkeypatch.setenv("LIVEKIT_AUDIO_SOURCE_QUEUE_SIZE_MS", "2000")
|
||||
monkeypatch.setenv("LIVEKIT_AUDIO_SOURCE_CLEAR_ON_SHED", "0")
|
||||
|
||||
config = main_module.audio_input_backlog_config_from_env(
|
||||
{
|
||||
"ws": {
|
||||
"audioInputBacklogShedEnabled": True,
|
||||
"audioInputBacklogShedThresholdMs": 500,
|
||||
"audioInputBacklogShedKeepMs": 300,
|
||||
"audioInputLatencyAlertMs": 800,
|
||||
"livekitAudioSourceQueueSizeMs": 400,
|
||||
"livekitAudioSourceClearOnShed": True,
|
||||
"audioInputBacklogEnergyShedEnabled": True,
|
||||
"audioInputBacklogEnergyShedMaxExcessMs": 600,
|
||||
"audioInputBacklogSilenceDbfs": -60,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
assert config.shed_enabled is True
|
||||
assert config.shed_threshold_ms == 500
|
||||
assert config.shed_keep_ms == 300
|
||||
assert config.latency_metrics_enabled is True
|
||||
assert config.latency_alert_ms == 800
|
||||
assert config.latency_log_interval_s == 20.0
|
||||
assert config.livekit_source_queue_size_ms == 400
|
||||
assert config.livekit_source_clear_on_shed is True
|
||||
assert config.config_source == "call_config"
|
||||
assert config.call_config_overrides == (
|
||||
"audio_in_backlog_shed_enabled",
|
||||
"audio_in_backlog_shed_threshold_ms",
|
||||
"audio_in_backlog_shed_keep_ms",
|
||||
"audio_in_latency_alert_ms",
|
||||
"livekit_audio_source_queue_size_ms",
|
||||
"livekit_audio_source_clear_on_shed",
|
||||
"audio_in_backlog_energy_shed_enabled",
|
||||
"audio_in_backlog_energy_shed_max_excess_ms",
|
||||
"audio_in_backlog_silence_dbfs",
|
||||
)
|
||||
|
||||
|
||||
def test_shed_energy_guided_drops_only_silence_under_small_excess(monkeypatch) -> None:
|
||||
events = []
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"log_flow_event",
|
||||
lambda logger, step, **payload: events.append((step, payload)),
|
||||
)
|
||||
config = _config(energy_shed_enabled=True, energy_shed_max_excess_ms=1000)
|
||||
# [V, S, V, S, V, S, V, S] -> 4 de voz, 4 de silencio
|
||||
frames = []
|
||||
for _ in range(4):
|
||||
frames.append(_voiced_frame())
|
||||
frames.append(_silent_frame())
|
||||
audio_q: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
for frame in frames:
|
||||
audio_q.put_nowait(frame)
|
||||
tracker = main_module.AudioInputLatencyTracker(config)
|
||||
tracker.mark_enabled(0.0)
|
||||
|
||||
dropped = main_module._shed_audio_queue_backlog(
|
||||
audio_q,
|
||||
config=config,
|
||||
tracker=tracker,
|
||||
source="test",
|
||||
frame_count=8,
|
||||
now=1.0,
|
||||
)
|
||||
|
||||
# excesso pequeno (120 ms < 1000): descarta so os 4 silencios, preserva a voz.
|
||||
assert dropped.dropped == 4
|
||||
assert audio_q.qsize() == 4
|
||||
remaining = [audio_q.get_nowait() for _ in range(4)]
|
||||
assert all(f == _voiced_frame() for f in remaining)
|
||||
assert events[-1][0] == "audio_in_latency_shed"
|
||||
assert events[-1][1]["mode"] == "energy"
|
||||
assert events[-1][1]["dropped_silent_frames"] == 4
|
||||
assert events[-1][1]["dropped_voiced_frames"] == 0
|
||||
assert events[-1][1]["content_policy"] == "silence_only"
|
||||
assert events[-1][1]["sync_action"] == "compress_silence"
|
||||
|
||||
|
||||
def test_shed_falls_back_to_blind_under_large_excess(monkeypatch) -> None:
|
||||
events = []
|
||||
monkeypatch.setattr(
|
||||
main_module,
|
||||
"log_flow_event",
|
||||
lambda logger, step, **payload: events.append((step, payload)),
|
||||
)
|
||||
# max_excess minusculo: qualquer excesso cai no regime de rajada (cego)
|
||||
config = _config(energy_shed_enabled=True, energy_shed_max_excess_ms=40)
|
||||
audio_q: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
for value in range(8):
|
||||
audio_q.put_nowait(_frame(value))
|
||||
tracker = main_module.AudioInputLatencyTracker(config)
|
||||
tracker.mark_enabled(0.0)
|
||||
|
||||
dropped = main_module._shed_audio_queue_backlog(
|
||||
audio_q,
|
||||
config=config,
|
||||
tracker=tracker,
|
||||
source="test",
|
||||
frame_count=8,
|
||||
now=1.0,
|
||||
)
|
||||
|
||||
assert dropped.dropped == 6
|
||||
assert audio_q.qsize() == 2
|
||||
assert [audio_q.get_nowait(), audio_q.get_nowait()] == [_frame(6), _frame(7)]
|
||||
assert events[-1][1]["mode"] == "blind"
|
||||
assert events[-1][1]["content_policy"] == "oldest_frames"
|
||||
assert events[-1][1]["sync_action"] == "drop_audio_to_catch_up"
|
||||
|
||||
|
||||
def test_audio_input_backlog_config_energy_defaults(monkeypatch) -> None:
|
||||
for name in (
|
||||
"AUDIO_IN_BACKLOG_ENERGY_SHED_ENABLED",
|
||||
"AUDIO_IN_BACKLOG_ENERGY_SHED_MAX_EXCESS_MS",
|
||||
"AUDIO_IN_BACKLOG_SILENCE_DBFS",
|
||||
):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
|
||||
config = main_module.audio_input_backlog_config_from_env()
|
||||
|
||||
assert config.energy_shed_enabled is True
|
||||
assert config.energy_shed_max_excess_ms == 1000
|
||||
assert config.silence_dbfs == -60.0
|
||||
assert config.silence_rms_threshold == 32
|
||||
79
tests/ws_gateway/test_fake_remote_agent.py
Normal file
79
tests/ws_gateway/test_fake_remote_agent.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.ws_gateway.fake_remote_agent import build_fake_remote_agent_response
|
||||
|
||||
|
||||
CONTA_OPENING = (
|
||||
"Olá! Eu sou a Especialista em Contas e vou ajudar você a entender a sua "
|
||||
"fatura. Posso explicar valores, detalhar serviços e itens eventuais, "
|
||||
"identificar cobranças que você não reconhece e, se for o caso, realizar "
|
||||
"ajustes necessários ou solicitações relacionadas à sua conta. Então vamos "
|
||||
"lá, me conte o que você gostaria de entender ou resolver na sua conta."
|
||||
)
|
||||
|
||||
|
||||
class FakeRemoteAgentTests(unittest.TestCase):
|
||||
def test_conta_opening_uses_the_configured_greeting(self) -> None:
|
||||
response = build_fake_remote_agent_response(
|
||||
{
|
||||
"action": "chat",
|
||||
"payload": {"agent": "conta", "stage": "PRESENTATION"},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("ARGUMENTATION", response["stage"])
|
||||
self.assertEqual(CONTA_OPENING, response["result"]["content"])
|
||||
|
||||
def test_conta_contract_returns_result_content(self) -> None:
|
||||
response = build_fake_remote_agent_response(
|
||||
{
|
||||
"action": "chat",
|
||||
"payload": {
|
||||
"agent": "conta",
|
||||
"stage": "PRESENTATION",
|
||||
"message": "quero detalhes da fatura",
|
||||
"msisdn": "5511999990000",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("result", response["type"])
|
||||
self.assertEqual("ARGUMENTATION", response["stage"])
|
||||
self.assertEqual("final", response["result"]["type"])
|
||||
self.assertIn("fatura", response["result"]["content"].lower())
|
||||
self.assertIn("quero detalhes da fatura", response["result"]["content"].lower())
|
||||
|
||||
def test_conta_done_when_user_asks_to_finish(self) -> None:
|
||||
response = build_fake_remote_agent_response(
|
||||
{
|
||||
"action": "chat",
|
||||
"payload": {
|
||||
"agent": "conta",
|
||||
"stage": "FORMALIZATION",
|
||||
"message": "obrigado, pode encerrar",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("DONE", response["stage"])
|
||||
self.assertEqual("result", response["type"])
|
||||
|
||||
def test_generic_contract_returns_text(self) -> None:
|
||||
response = build_fake_remote_agent_response(
|
||||
{
|
||||
"agent": "oferta",
|
||||
"stage": "PRESENTATION",
|
||||
"text": "quero saber da oferta",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual("final", response["type"])
|
||||
self.assertEqual("ARGUMENTATION", response["stage"])
|
||||
self.assertIn("oferta", response["text"].lower())
|
||||
self.assertIn("quero saber da oferta", response["text"].lower())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1056
tests/ws_gateway/test_readiness.py
Normal file
1056
tests/ws_gateway/test_readiness.py
Normal file
File diff suppressed because it is too large
Load Diff
290
tests/ws_gateway/test_session_audio.py
Normal file
290
tests/ws_gateway/test_session_audio.py
Normal file
@@ -0,0 +1,290 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.ws_gateway.session_audio import (
|
||||
enable_client_audio,
|
||||
notify_client_audio_enabled,
|
||||
stream_agent_audio_when_ready,
|
||||
watch_mock_stop_after_first_audio,
|
||||
)
|
||||
from app.ws_gateway.session_lifecycle import RoomLifecycleState
|
||||
|
||||
|
||||
class _FakeTimeline:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[tuple[str, dict]] = []
|
||||
|
||||
def emit(self, event: str, **fields) -> None:
|
||||
self.events.append((event, fields))
|
||||
|
||||
|
||||
class _FakeLocalParticipant:
|
||||
def __init__(self) -> None:
|
||||
self.published: list[dict] = []
|
||||
|
||||
async def publish_data(self, payload: str, **kwargs) -> None:
|
||||
self.published.append({"payload": json.loads(payload), "kwargs": kwargs})
|
||||
|
||||
|
||||
class _FakeRoom:
|
||||
def __init__(self) -> None:
|
||||
self.local_participant = _FakeLocalParticipant()
|
||||
|
||||
|
||||
class _FakeParticipant:
|
||||
def __init__(self, identity: str) -> None:
|
||||
self.identity = identity
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
def __init__(self) -> None:
|
||||
self.text_messages: list[dict] = []
|
||||
self.closed = False
|
||||
|
||||
async def send_text(self, payload: str) -> None:
|
||||
self.text_messages.append(json.loads(payload))
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def test_enable_client_audio_sets_gate_and_emits_timeline() -> None:
|
||||
async def _run() -> None:
|
||||
timeline = _FakeTimeline()
|
||||
logger = logging.getLogger("test.session_audio.enable")
|
||||
client_audio_enabled = asyncio.Event()
|
||||
|
||||
await enable_client_audio(
|
||||
logger=logger,
|
||||
timeline=timeline,
|
||||
client_audio_enabled=client_audio_enabled,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
)
|
||||
|
||||
assert client_audio_enabled.is_set()
|
||||
assert timeline.events == [
|
||||
("client_audio_enabled", {}),
|
||||
]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_enable_client_audio_is_idempotent() -> None:
|
||||
async def _run() -> None:
|
||||
timeline = _FakeTimeline()
|
||||
logger = logging.getLogger("test.session_audio.enable.idempotent")
|
||||
client_audio_enabled = asyncio.Event()
|
||||
|
||||
await enable_client_audio(
|
||||
logger=logger,
|
||||
timeline=timeline,
|
||||
client_audio_enabled=client_audio_enabled,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
)
|
||||
await enable_client_audio(
|
||||
logger=logger,
|
||||
timeline=timeline,
|
||||
client_audio_enabled=client_audio_enabled,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
)
|
||||
|
||||
assert client_audio_enabled.is_set()
|
||||
assert timeline.events == [("client_audio_enabled", {})]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_notify_client_audio_enabled_publishes_bridge_control_message() -> None:
|
||||
async def _run() -> None:
|
||||
timeline = _FakeTimeline()
|
||||
logger = logging.getLogger("test.session_audio.notify")
|
||||
room = _FakeRoom()
|
||||
track_published = asyncio.Event()
|
||||
track_published.set()
|
||||
agent_ready = asyncio.Event()
|
||||
agent_ready.set()
|
||||
lifecycle = RoomLifecycleState(agent_participant=_FakeParticipant("agent-001"))
|
||||
|
||||
await notify_client_audio_enabled(
|
||||
room=room,
|
||||
logger=logger,
|
||||
timeline=timeline,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
track_published=track_published,
|
||||
agent_ready=agent_ready,
|
||||
lifecycle=lifecycle,
|
||||
)
|
||||
|
||||
assert room.local_participant.published == [
|
||||
{
|
||||
"payload": {
|
||||
"type": "client_audio_enabled",
|
||||
"room": "room-dev-123",
|
||||
"protocol": "PRT-1",
|
||||
},
|
||||
"kwargs": {
|
||||
"reliable": True,
|
||||
"destination_identities": ["agent-001"],
|
||||
"topic": "bridge.control",
|
||||
},
|
||||
}
|
||||
]
|
||||
assert timeline.events == [
|
||||
(
|
||||
"bridge_control_sent",
|
||||
{"control_type": "client_audio_enabled", "agent_identity": "agent-001"},
|
||||
)
|
||||
]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_stream_agent_audio_when_ready_uses_lifecycle_participant() -> None:
|
||||
async def _run() -> None:
|
||||
agent_ready = asyncio.Event()
|
||||
agent_ready.set()
|
||||
participant = _FakeParticipant("agent-001")
|
||||
lifecycle = RoomLifecycleState(agent_participant=participant)
|
||||
calls: list[dict] = []
|
||||
|
||||
async def _stream_agent_audio(agent_q, agent_participant, activity, timeline=None) -> None:
|
||||
calls.append(
|
||||
{
|
||||
"agent_q": agent_q,
|
||||
"agent_participant": agent_participant,
|
||||
"activity": activity,
|
||||
"timeline": timeline,
|
||||
}
|
||||
)
|
||||
|
||||
queue: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
activity = SimpleNamespace(last_client_in=0.0, last_agent_out=0.0)
|
||||
timeline = _FakeTimeline()
|
||||
|
||||
await stream_agent_audio_when_ready(
|
||||
agent_ready=agent_ready,
|
||||
lifecycle=lifecycle,
|
||||
agent_q=queue,
|
||||
activity=activity,
|
||||
timeline=timeline,
|
||||
stream_agent_audio=_stream_agent_audio,
|
||||
)
|
||||
|
||||
assert calls == [
|
||||
{
|
||||
"agent_q": queue,
|
||||
"agent_participant": participant,
|
||||
"activity": activity,
|
||||
"timeline": timeline,
|
||||
}
|
||||
]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_stream_agent_audio_when_ready_repeats_for_reconnected_agent() -> None:
|
||||
async def _run() -> None:
|
||||
agent_ready = asyncio.Event()
|
||||
agent_ready.set()
|
||||
first = _FakeParticipant("agent-001")
|
||||
second = _FakeParticipant("agent-002")
|
||||
lifecycle = RoomLifecycleState(agent_participant=first)
|
||||
calls: list[str] = []
|
||||
second_call = asyncio.Event()
|
||||
|
||||
async def _stream_agent_audio(agent_q, agent_participant, activity, timeline=None) -> None:
|
||||
calls.append(agent_participant.identity)
|
||||
if agent_participant is first:
|
||||
lifecycle.agent_participant = None
|
||||
lifecycle.agent_generation += 1
|
||||
lifecycle.agent_connected.clear()
|
||||
|
||||
async def _reconnect() -> None:
|
||||
await asyncio.sleep(0.01)
|
||||
lifecycle.agent_participant = second
|
||||
lifecycle.agent_generation += 1
|
||||
lifecycle.agent_connected.set()
|
||||
|
||||
asyncio.create_task(_reconnect())
|
||||
return
|
||||
|
||||
second_call.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
queue: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
activity = SimpleNamespace(last_client_in=0.0, last_agent_out=0.0)
|
||||
task = asyncio.create_task(
|
||||
stream_agent_audio_when_ready(
|
||||
agent_ready=agent_ready,
|
||||
lifecycle=lifecycle,
|
||||
agent_q=queue,
|
||||
activity=activity,
|
||||
timeline=None,
|
||||
stream_agent_audio=_stream_agent_audio,
|
||||
repeat=True,
|
||||
)
|
||||
)
|
||||
|
||||
await asyncio.wait_for(second_call.wait(), timeout=1.0)
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
assert calls == ["agent-001", "agent-002"]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_watch_mock_stop_after_first_audio_sends_resolved_terminal_stop() -> None:
|
||||
async def _run() -> None:
|
||||
timeline = _FakeTimeline()
|
||||
logger = logging.getLogger("test.session_audio.mock_stop")
|
||||
ws = _FakeWebSocket()
|
||||
first_agent_audio_sent = asyncio.Event()
|
||||
activity = SimpleNamespace(last_agent_out=time.monotonic())
|
||||
agent_q: asyncio.Queue[bytes] = asyncio.Queue()
|
||||
|
||||
task = asyncio.create_task(
|
||||
watch_mock_stop_after_first_audio(
|
||||
ws=ws,
|
||||
logger=logger,
|
||||
timeline=timeline,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
first_agent_audio_sent=first_agent_audio_sent,
|
||||
activity=activity,
|
||||
agent_q=agent_q,
|
||||
silence_s=0.01,
|
||||
reason="stage_done",
|
||||
)
|
||||
)
|
||||
|
||||
first_agent_audio_sent.set()
|
||||
await asyncio.sleep(0.03)
|
||||
await task
|
||||
|
||||
assert ws.text_messages == [
|
||||
{
|
||||
"type": "stop",
|
||||
"data": {
|
||||
"status": "stop_resolvido_e_finalizado",
|
||||
"reason": "stage_done",
|
||||
"phase": "in_session",
|
||||
},
|
||||
}
|
||||
]
|
||||
assert ws.closed is True
|
||||
assert timeline.events == [
|
||||
("mock_stop_after_first_audio_armed", {"silence_ms": 10, "reason": "stage_done"}),
|
||||
("mock_stop_after_first_audio_sent", {"reason": "stage_done"}),
|
||||
]
|
||||
|
||||
asyncio.run(_run())
|
||||
201
tests/ws_gateway/test_session_bootstrap.py
Normal file
201
tests/ws_gateway/test_session_bootstrap.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.ws_gateway.session_bootstrap import build_bridge_session_bootstrap
|
||||
from app.ws_gateway.session_start import StartSessionContext
|
||||
|
||||
|
||||
def _build_start_ctx(**data_overrides: str) -> StartSessionContext:
|
||||
data = {
|
||||
"agent": "oferta",
|
||||
"ani": "5511888888888",
|
||||
"gsm": "5511999999999",
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440100",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-001",
|
||||
"callIdGed": "GED-1234567890",
|
||||
**data_overrides,
|
||||
}
|
||||
payload = {
|
||||
"type": "start",
|
||||
"data": data,
|
||||
"callConfig": {
|
||||
"tts": {"provider": "azure"},
|
||||
},
|
||||
}
|
||||
return StartSessionContext(
|
||||
payload=payload,
|
||||
data=data,
|
||||
agent_data=dict(data.get("agentData") or {}),
|
||||
audio_format={},
|
||||
call_config=payload["callConfig"],
|
||||
session_data={"gsm": data.get("gsm", ""), "msisdn": data.get("gsm", ""), **data},
|
||||
intro="intro",
|
||||
nudge="nudge",
|
||||
)
|
||||
|
||||
|
||||
def test_build_bridge_session_bootstrap_uses_protocol_from_data_and_builds_dispatch_metadata(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
logger = logging.getLogger("test.session_bootstrap")
|
||||
monkeypatch.setenv("CALL_TIMELINE_DIR", "/tmp/timeline-bootstrap")
|
||||
monkeypatch.setenv("CALL_TIMELINE_ENABLED", "0")
|
||||
|
||||
data = {
|
||||
"agent": "conta",
|
||||
"ani": "5511888888888",
|
||||
"gsm": "5511888888888",
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440101",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-321",
|
||||
"callIdGed": "GED-321",
|
||||
"protocolo": "PRT-321",
|
||||
"agentData": {
|
||||
"idFatura": "fat-999",
|
||||
},
|
||||
}
|
||||
start_ctx = StartSessionContext(
|
||||
payload={
|
||||
"type": "start",
|
||||
"data": data,
|
||||
"callConfig": {
|
||||
"tts": {"provider": "azure"},
|
||||
},
|
||||
},
|
||||
data=data,
|
||||
agent_data={"idFatura": "fat-999"},
|
||||
audio_format={},
|
||||
call_config={"tts": {"provider": "azure"}},
|
||||
session_data={"gsm": "5511888888888", "msisdn": "5511888888888", **data},
|
||||
intro="intro",
|
||||
nudge="nudge",
|
||||
)
|
||||
|
||||
uuids = iter(
|
||||
[
|
||||
type("U", (), {"hex": "room123456789"})(),
|
||||
type("U", (), {"hex": "ident12345678"})(),
|
||||
]
|
||||
)
|
||||
token_calls: list[tuple[str, str]] = []
|
||||
|
||||
bootstrap = build_bridge_session_bootstrap(
|
||||
start_ctx=start_ctx,
|
||||
app_env="dev",
|
||||
livekit_room="custom-room",
|
||||
default_protocol="fallback-proto",
|
||||
token_factory=lambda identity, room_name: token_calls.append((identity, room_name)) or "jwt-token",
|
||||
logger=logger,
|
||||
now_fn=lambda: 1234567890,
|
||||
uuid_factory=lambda: next(uuids),
|
||||
)
|
||||
|
||||
assert bootstrap.call_id_ged == "GED-321"
|
||||
assert bootstrap.room_name == "custom-room-room1234"
|
||||
assert bootstrap.identity == "ws-bridge-dev-ident123"
|
||||
assert bootstrap.token == "jwt-token"
|
||||
assert bootstrap.protocol == "PRT-321"
|
||||
assert bootstrap.phone_number == "5511888888888"
|
||||
assert bootstrap.remote_agent_context["agent"] == "conta"
|
||||
assert bootstrap.remote_agent_context["ID_FATURA"] == "fat-999"
|
||||
assert bootstrap.remote_agent_context["callIdGed"] == "GED-321"
|
||||
assert bootstrap.call_config["tts"]["provider"] == "azure"
|
||||
assert bootstrap.dispatch_metadata["bridge_identity"] == "ws-bridge-dev-ident123"
|
||||
assert bootstrap.dispatch_metadata["protocol"] == "PRT-321"
|
||||
assert bootstrap.dispatch_metadata["call_id_ged"] == "GED-321"
|
||||
assert bootstrap.dispatch_metadata["agent_starts_conversation"] is True
|
||||
assert bootstrap.dispatch_metadata["remote_agent"]["ID_FATURA"] == "fat-999"
|
||||
assert bootstrap.dispatch_metadata["timeline_id"] == "custom-room-room1234"
|
||||
assert bootstrap.dispatch_metadata["session_id"] == "550e8400-e29b-41d4-a716-446655440101"
|
||||
assert token_calls == [("ws-bridge-dev-ident123", "custom-room-room1234")]
|
||||
|
||||
|
||||
def test_build_bridge_session_bootstrap_falls_back_to_router_call_key_when_protocol_is_missing(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
logger = logging.getLogger("test.session_bootstrap")
|
||||
monkeypatch.setenv("CALL_TIMELINE_DIR", "/tmp/timeline-bootstrap")
|
||||
monkeypatch.setenv("CALL_TIMELINE_ENABLED", "0")
|
||||
|
||||
start_ctx = _build_start_ctx()
|
||||
|
||||
bootstrap = build_bridge_session_bootstrap(
|
||||
start_ctx=start_ctx,
|
||||
app_env="dev",
|
||||
livekit_room="custom-room",
|
||||
default_protocol="fallback-proto",
|
||||
token_factory=lambda *_args: "jwt-token",
|
||||
logger=logger,
|
||||
now_fn=lambda: 1234567890,
|
||||
uuid_factory=lambda: type("U", (), {"hex": "room123456789"})(),
|
||||
)
|
||||
|
||||
assert bootstrap.protocol == "RCK-001"
|
||||
assert bootstrap.dispatch_metadata["protocol"] == "RCK-001"
|
||||
|
||||
|
||||
def test_build_bridge_session_bootstrap_falls_back_to_generated_protocol_and_phone(monkeypatch) -> None:
|
||||
logger = logging.getLogger("test.session_bootstrap")
|
||||
monkeypatch.setenv("CALL_TIMELINE_DIR", "/tmp/timeline-bootstrap")
|
||||
monkeypatch.setenv("CALL_TIMELINE_ENABLED", "0")
|
||||
|
||||
start_ctx = _build_start_ctx(routerCallKey="", gsm="")
|
||||
start_ctx = StartSessionContext(
|
||||
payload=start_ctx.payload,
|
||||
data=start_ctx.data,
|
||||
agent_data=start_ctx.agent_data,
|
||||
audio_format=start_ctx.audio_format,
|
||||
call_config=start_ctx.call_config,
|
||||
session_data={**start_ctx.session_data, "phone": "5511777777777"},
|
||||
intro=start_ctx.intro,
|
||||
nudge=start_ctx.nudge,
|
||||
)
|
||||
uuids = iter(
|
||||
[
|
||||
type("U", (), {"hex": "roomabcdef1234"})(),
|
||||
type("U", (), {"hex": "identfedcba98"})(),
|
||||
]
|
||||
)
|
||||
|
||||
bootstrap = build_bridge_session_bootstrap(
|
||||
start_ctx=start_ctx,
|
||||
app_env="qa",
|
||||
livekit_room="",
|
||||
default_protocol="",
|
||||
token_factory=lambda *_args: "jwt-token",
|
||||
logger=logger,
|
||||
now_fn=lambda: 1712345678,
|
||||
uuid_factory=lambda: next(uuids),
|
||||
)
|
||||
|
||||
assert bootstrap.room_name == "qa-room-roomabcd"
|
||||
assert bootstrap.identity == "ws-bridge-qa-identfed"
|
||||
assert bootstrap.protocol == "WS-GED-1234567890-1712345678"
|
||||
assert bootstrap.phone_number == "5511777777777"
|
||||
assert bootstrap.dispatch_metadata["session_id"] == "550e8400-e29b-41d4-a716-446655440100"
|
||||
assert bootstrap.dispatch_metadata["agent_starts_conversation"] is True
|
||||
assert bootstrap.dispatch_metadata["session_data"]["phone"] == "5511777777777"
|
||||
|
||||
|
||||
def test_build_bridge_session_bootstrap_uses_explicit_session_id_without_protocol_fallback(monkeypatch) -> None:
|
||||
logger = logging.getLogger("test.session_bootstrap.session_id")
|
||||
monkeypatch.setenv("CALL_TIMELINE_DIR", "/tmp/timeline-bootstrap")
|
||||
monkeypatch.setenv("CALL_TIMELINE_ENABLED", "0")
|
||||
|
||||
start_ctx = _build_start_ctx(protocolo="PRT-001", session_id="sess-001")
|
||||
|
||||
bootstrap = build_bridge_session_bootstrap(
|
||||
start_ctx=start_ctx,
|
||||
app_env="dev",
|
||||
livekit_room="custom-room",
|
||||
default_protocol="fallback-proto",
|
||||
token_factory=lambda *_args: "jwt-token",
|
||||
logger=logger,
|
||||
now_fn=lambda: 1234567890,
|
||||
uuid_factory=lambda: type("U", (), {"hex": "room123456789"})(),
|
||||
)
|
||||
|
||||
assert bootstrap.protocol == "PRT-001"
|
||||
assert bootstrap.dispatch_metadata["session_id"] == "sess-001"
|
||||
365
tests/ws_gateway/test_session_lifecycle.py
Normal file
365
tests/ws_gateway/test_session_lifecycle.py
Normal file
@@ -0,0 +1,365 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.ws_gateway.session_lifecycle import (
|
||||
RoomLifecycleState,
|
||||
register_room_lifecycle_handlers,
|
||||
watch_call_done,
|
||||
)
|
||||
|
||||
|
||||
class _FakeRoom:
|
||||
def __init__(self) -> None:
|
||||
self.handlers = {}
|
||||
|
||||
def on(self, event_name: str):
|
||||
def _decorator(fn):
|
||||
self.handlers[event_name] = fn
|
||||
return fn
|
||||
|
||||
return _decorator
|
||||
|
||||
|
||||
class _FakeTimeline:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[tuple[str, dict]] = []
|
||||
|
||||
def emit(self, event: str, **fields) -> None:
|
||||
self.events.append((event, fields))
|
||||
|
||||
|
||||
class _FakeParticipant:
|
||||
def __init__(self, identity: str) -> None:
|
||||
self.identity = identity
|
||||
|
||||
|
||||
class _FakePacket:
|
||||
def __init__(self, topic: str, data) -> None:
|
||||
self.topic = topic
|
||||
self.data = data
|
||||
|
||||
|
||||
class _FakeWebSocket:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[dict] = []
|
||||
self.closed = 0
|
||||
self.close_calls: list[dict] = []
|
||||
|
||||
async def send_text(self, payload: str) -> None:
|
||||
self.sent.append(json.loads(payload))
|
||||
|
||||
async def close(self, code: int = 1000, reason: str = "") -> None:
|
||||
self.closed += 1
|
||||
self.close_calls.append({"code": code, "reason": reason})
|
||||
|
||||
|
||||
def test_register_room_lifecycle_handlers_updates_state_and_emits_timeline() -> None:
|
||||
room = _FakeRoom()
|
||||
timeline = _FakeTimeline()
|
||||
logger = logging.getLogger("test.session_lifecycle.register")
|
||||
call_done = asyncio.Event()
|
||||
agent_ready = asyncio.Event()
|
||||
state = RoomLifecycleState()
|
||||
|
||||
register_room_lifecycle_handlers(
|
||||
room=room,
|
||||
logger=logger,
|
||||
timeline=timeline,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
dispatch_started_at=100.0,
|
||||
agent_ready=agent_ready,
|
||||
call_done=call_done,
|
||||
state=state,
|
||||
is_agent=lambda participant: str(getattr(participant, "identity", "")).startswith("agent"),
|
||||
monotonic_fn=lambda: 100.123,
|
||||
)
|
||||
|
||||
participant = _FakeParticipant("agent-001")
|
||||
room.handlers["participant_connected"](participant)
|
||||
room.handlers["data_received"](
|
||||
_FakePacket("agent.stage", b'{"stage":"DONE","reason":"completed"}')
|
||||
)
|
||||
room.handlers["participant_disconnected"](participant)
|
||||
|
||||
assert agent_ready.is_set()
|
||||
assert call_done.is_set()
|
||||
assert state.agent_participant is None
|
||||
assert state.done_payload == {"stage": "DONE", "reason": "completed"}
|
||||
assert timeline.events == [
|
||||
("agent_join", {"agent_identity": "agent-001", "dispatch_dt_ms": 123}),
|
||||
(
|
||||
"done_packet_received",
|
||||
{"reason": "completed", "payload": {"stage": "DONE", "reason": "completed"}},
|
||||
),
|
||||
("agent_leave", {"agent_identity": "agent-001"}),
|
||||
]
|
||||
|
||||
|
||||
def test_agent_disconnect_without_done_notifies_recovery_handler() -> None:
|
||||
async def _run() -> None:
|
||||
room = _FakeRoom()
|
||||
timeline = _FakeTimeline()
|
||||
logger = logging.getLogger("test.session_lifecycle.agent_disconnect")
|
||||
call_done = asyncio.Event()
|
||||
agent_ready = asyncio.Event()
|
||||
state = RoomLifecycleState()
|
||||
calls: list[dict] = []
|
||||
|
||||
async def _on_agent_disconnect(agent_identity: str, generation: int) -> None:
|
||||
calls.append({"agent_identity": agent_identity, "generation": generation})
|
||||
|
||||
register_room_lifecycle_handlers(
|
||||
room=room,
|
||||
logger=logger,
|
||||
timeline=timeline,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
dispatch_started_at=100.0,
|
||||
agent_ready=agent_ready,
|
||||
call_done=call_done,
|
||||
state=state,
|
||||
is_agent=lambda participant: str(getattr(participant, "identity", "")).startswith("agent"),
|
||||
on_agent_disconnect=_on_agent_disconnect,
|
||||
monotonic_fn=lambda: 100.123,
|
||||
)
|
||||
|
||||
participant = _FakeParticipant("agent-001")
|
||||
room.handlers["participant_connected"](participant)
|
||||
connected_generation = state.agent_generation
|
||||
room.handlers["participant_disconnected"](participant)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert state.agent_participant is None
|
||||
assert not state.agent_connected.is_set()
|
||||
assert state.agent_generation == connected_generation + 1
|
||||
assert calls == [
|
||||
{"agent_identity": "agent-001", "generation": connected_generation + 1}
|
||||
]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_watch_call_done_sends_stop_and_closes_socket() -> None:
|
||||
async def _run() -> None:
|
||||
timeline = _FakeTimeline()
|
||||
ws = _FakeWebSocket()
|
||||
logger = logging.getLogger("test.session_lifecycle.done")
|
||||
call_done = asyncio.Event()
|
||||
state = RoomLifecycleState(done_payload={"reason": "finished"})
|
||||
call_done.set()
|
||||
|
||||
await watch_call_done(
|
||||
call_done=call_done,
|
||||
state=state,
|
||||
ws=ws,
|
||||
logger=logger,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
timeline=timeline,
|
||||
)
|
||||
|
||||
assert ws.sent == [
|
||||
{
|
||||
"type": "stop",
|
||||
"data": {
|
||||
"status": "stop_resolvido_e_finalizado",
|
||||
"reason": "finished",
|
||||
"phase": "in_session",
|
||||
},
|
||||
}
|
||||
]
|
||||
assert ws.closed == 1
|
||||
assert ws.close_calls == [{"code": 1000, "reason": "call_done:finished"}]
|
||||
assert timeline.events == [
|
||||
("call_done", {"reason": "finished"}),
|
||||
("stop_sent", {"reason": "finished"}),
|
||||
]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_agent_debug_packet_is_forwarded_when_enabled() -> None:
|
||||
async def _run() -> None:
|
||||
room = _FakeRoom()
|
||||
ws = _FakeWebSocket()
|
||||
register_room_lifecycle_handlers(
|
||||
room=room,
|
||||
logger=logging.getLogger("test.session_lifecycle.debug"),
|
||||
timeline=_FakeTimeline(),
|
||||
room_name="room-load-1",
|
||||
protocol="LOAD-1",
|
||||
dispatch_started_at=1.0,
|
||||
agent_ready=asyncio.Event(),
|
||||
call_done=asyncio.Event(),
|
||||
state=RoomLifecycleState(),
|
||||
is_agent=lambda _participant: False,
|
||||
ws=ws,
|
||||
debug_events_enabled=True,
|
||||
)
|
||||
event = {
|
||||
"type": "debug_event",
|
||||
"version": 1,
|
||||
"source": "agent",
|
||||
"event": "agent.speech.finished",
|
||||
"data": {"text": "Resposta", "stage": "ARGUMENTATION"},
|
||||
}
|
||||
room.handlers["data_received"](
|
||||
_FakePacket("agent.debug", json.dumps(event).encode("utf-8"))
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert ws.sent == [{**event, "stress_test": True}]
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_agent_debug_packet_is_not_forwarded_when_disabled() -> None:
|
||||
async def _run() -> None:
|
||||
room = _FakeRoom()
|
||||
ws = _FakeWebSocket()
|
||||
register_room_lifecycle_handlers(
|
||||
room=room,
|
||||
logger=logging.getLogger("test.session_lifecycle.debug.disabled"),
|
||||
timeline=_FakeTimeline(),
|
||||
room_name="room-regular-1",
|
||||
protocol="REGULAR-1",
|
||||
dispatch_started_at=1.0,
|
||||
agent_ready=asyncio.Event(),
|
||||
call_done=asyncio.Event(),
|
||||
state=RoomLifecycleState(),
|
||||
is_agent=lambda _participant: False,
|
||||
ws=ws,
|
||||
debug_events_enabled=False,
|
||||
)
|
||||
event = {
|
||||
"type": "debug_event",
|
||||
"version": 1,
|
||||
"source": "agent",
|
||||
"event": "stt.completed",
|
||||
"data": {"duration_ms": 100},
|
||||
}
|
||||
room.handlers["data_received"](
|
||||
_FakePacket("agent.debug", json.dumps(event).encode("utf-8"))
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert ws.sent == []
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_watch_call_done_maps_no_user_response_to_long_silence_status() -> None:
|
||||
async def _run() -> None:
|
||||
timeline = _FakeTimeline()
|
||||
ws = _FakeWebSocket()
|
||||
logger = logging.getLogger("test.session_lifecycle.done.long_silence")
|
||||
call_done = asyncio.Event()
|
||||
state = RoomLifecycleState(done_payload={"reason": "no_user_response"})
|
||||
call_done.set()
|
||||
|
||||
await watch_call_done(
|
||||
call_done=call_done,
|
||||
state=state,
|
||||
ws=ws,
|
||||
logger=logger,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
timeline=timeline,
|
||||
)
|
||||
|
||||
assert ws.sent == [
|
||||
{
|
||||
"type": "stop",
|
||||
"data": {
|
||||
"status": "stop_silencio_longo",
|
||||
"reason": "no_user_response",
|
||||
"phase": "in_session",
|
||||
},
|
||||
}
|
||||
]
|
||||
assert ws.closed == 1
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_watch_call_done_uses_default_status_when_reason_is_not_exact_match() -> None:
|
||||
async def _run() -> None:
|
||||
timeline = _FakeTimeline()
|
||||
ws = _FakeWebSocket()
|
||||
logger = logging.getLogger("test.session_lifecycle.done.default")
|
||||
call_done = asyncio.Event()
|
||||
state = RoomLifecycleState(done_payload={"reason": "finished"})
|
||||
call_done.set()
|
||||
|
||||
await watch_call_done(
|
||||
call_done=call_done,
|
||||
state=state,
|
||||
ws=ws,
|
||||
logger=logger,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
timeline=timeline,
|
||||
)
|
||||
|
||||
assert ws.sent == [
|
||||
{
|
||||
"type": "stop",
|
||||
"data": {
|
||||
"status": "stop_resolvido_e_finalizado",
|
||||
"reason": "finished",
|
||||
"phase": "in_session",
|
||||
},
|
||||
}
|
||||
]
|
||||
assert ws.closed == 1
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_watch_call_done_preserves_terminal_status_from_agent_payload() -> None:
|
||||
async def _run() -> None:
|
||||
timeline = _FakeTimeline()
|
||||
ws = _FakeWebSocket()
|
||||
logger = logging.getLogger("test.session_lifecycle.done.custom")
|
||||
call_done = asyncio.Event()
|
||||
state = RoomLifecycleState(
|
||||
done_payload={
|
||||
"stage": "DONE",
|
||||
"status": "stop_agent_backend_unavailable",
|
||||
"reason": "resource_unhealthy",
|
||||
"resource": "agent_backend",
|
||||
"failed_resources": ["agent_backend"],
|
||||
"phase": "in_session",
|
||||
}
|
||||
)
|
||||
call_done.set()
|
||||
|
||||
await watch_call_done(
|
||||
call_done=call_done,
|
||||
state=state,
|
||||
ws=ws,
|
||||
logger=logger,
|
||||
room_name="room-dev-123",
|
||||
protocol="PRT-1",
|
||||
timeline=timeline,
|
||||
)
|
||||
|
||||
assert ws.sent == [
|
||||
{
|
||||
"type": "stop",
|
||||
"data": {
|
||||
"status": "stop_agent_backend_unavailable",
|
||||
"reason": "resource_unhealthy",
|
||||
"resource": "agent_backend",
|
||||
"failed_resources": ["agent_backend"],
|
||||
"phase": "in_session",
|
||||
},
|
||||
}
|
||||
]
|
||||
assert ws.closed == 1
|
||||
|
||||
asyncio.run(_run())
|
||||
385
tests/ws_gateway/test_session_start.py
Normal file
385
tests/ws_gateway/test_session_start.py
Normal file
@@ -0,0 +1,385 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from app.ws_gateway.session_start import (
|
||||
build_remote_agent_context,
|
||||
parse_start_payload,
|
||||
parse_transferencia_session_id_payload,
|
||||
recv_start_message,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_start_payload_builds_context_from_data() -> None:
|
||||
ctx = parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "oferta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-001",
|
||||
"callIdGed": "GED-001",
|
||||
"protocolo": "PRT-001",
|
||||
},
|
||||
"audioFormat": {"encoding": "linear16"},
|
||||
"callConfig": {"agentBackend": "remote_ws"},
|
||||
}
|
||||
)
|
||||
|
||||
assert ctx.data["agent"] == "oferta"
|
||||
assert ctx.data["session_id"] == "550e8400-e29b-41d4-a716-446655440000"
|
||||
assert ctx.data["protocolo"] == "PRT-001"
|
||||
assert ctx.agent_data == {}
|
||||
assert ctx.audio_format == {"encoding": "linear16"}
|
||||
assert ctx.call_config == {"agentBackend": "remote_ws"}
|
||||
assert ctx.session_data["msisdn"] == "5511999999999"
|
||||
assert ctx.session_data["audioFormat"] == {"encoding": "linear16"}
|
||||
assert ctx.agent_starts_conversation is True
|
||||
assert ctx.intro == ""
|
||||
assert ctx.nudge == "Alô, você ainda está aí?"
|
||||
|
||||
|
||||
def test_parse_start_payload_lets_conta_agent_own_first_message() -> None:
|
||||
ctx = parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "conta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440001",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-001",
|
||||
"callIdGed": "GED-001",
|
||||
"agentData": {
|
||||
"idFatura": "fat-123",
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert ctx.agent_starts_conversation is True
|
||||
assert ctx.intro == ""
|
||||
assert ctx.nudge == "Alô, você ainda está aí?"
|
||||
|
||||
|
||||
def test_parse_start_payload_rejects_non_start_message() -> None:
|
||||
with pytest.raises(RuntimeError):
|
||||
parse_start_payload({"type": "ping"})
|
||||
|
||||
|
||||
def test_parse_start_payload_rejects_invalid_fake_agent_responses() -> None:
|
||||
with pytest.raises(RuntimeError, match="callConfig invalido"):
|
||||
parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "conta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"routerCallKeyDay": "20260819",
|
||||
"routerCallKey": "RCK-1",
|
||||
"callIdGed": "GED-1",
|
||||
"session_id": "session-1",
|
||||
"agentData": {"idFatura": "FAT-1"},
|
||||
},
|
||||
"callConfig": {
|
||||
"agentBackend": "remote_ws_fake",
|
||||
"agentFake": {"responses": "resposta curta;outra curta"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_parse_start_payload_requires_id_fatura_for_conta() -> None:
|
||||
with pytest.raises(RuntimeError, match="idFatura"):
|
||||
parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "conta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440002",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-001",
|
||||
"callIdGed": "GED-001",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_parse_start_payload_requires_protocolo_for_oferta() -> None:
|
||||
with pytest.raises(RuntimeError, match="protocolo"):
|
||||
parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "oferta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"session_id": "550e8400-e29b-41d4-a716-446655440003",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-001",
|
||||
"callIdGed": "GED-001",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_parse_start_payload_requires_session_id() -> None:
|
||||
with pytest.raises(RuntimeError, match="session_id"):
|
||||
parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "oferta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-001",
|
||||
"callIdGed": "GED-001",
|
||||
"protocolo": "PRT-001",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_parse_start_payload_accepts_camel_session_id_and_normalizes() -> None:
|
||||
ctx = parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "oferta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"sessionId": "550e8400-e29b-41d4-a716-446655440004",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-001",
|
||||
"callIdGed": "GED-001",
|
||||
"protocolo": "PRT-001",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert ctx.data["session_id"] == "550e8400-e29b-41d4-a716-446655440004"
|
||||
|
||||
|
||||
def test_parse_transferencia_session_id_payload_extracts_session_id() -> None:
|
||||
session_id = parse_transferencia_session_id_payload(
|
||||
{
|
||||
"type": "transferencia_session_id",
|
||||
"data": {"session_id": "550e8400-e29b-41d4-a716-446655440005"},
|
||||
}
|
||||
)
|
||||
|
||||
assert session_id == "550e8400-e29b-41d4-a716-446655440005"
|
||||
|
||||
|
||||
def test_recv_start_message_uses_transferencia_session_id_before_start() -> None:
|
||||
class FakeWebSocket:
|
||||
def __init__(self) -> None:
|
||||
self.messages = [
|
||||
{
|
||||
"type": "transferencia_session_id",
|
||||
"data": {"session_id": "550e8400-e29b-41d4-a716-446655440006"},
|
||||
},
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "oferta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"routerCallKeyDay": "20260409",
|
||||
"routerCallKey": "RCK-001",
|
||||
"callIdGed": "GED-001",
|
||||
"protocolo": "PRT-001",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async def receive_text(self) -> str:
|
||||
return json.dumps(self.messages.pop(0))
|
||||
|
||||
ctx = asyncio.run(recv_start_message(FakeWebSocket()))
|
||||
|
||||
assert ctx.data["session_id"] == "550e8400-e29b-41d4-a716-446655440006"
|
||||
|
||||
|
||||
def test_build_remote_agent_context_uses_camel_case_payload() -> None:
|
||||
context = build_remote_agent_context(
|
||||
data={
|
||||
"agent": "contas",
|
||||
"ani": "5511888888888",
|
||||
"gsm": "5511777777777",
|
||||
"routerCallKeyDay": "20260406",
|
||||
"routerCallKey": "RCK-123",
|
||||
"callIdGed": "GED-777",
|
||||
},
|
||||
agent_data={"idFatura": "fat-123"},
|
||||
)
|
||||
|
||||
assert context == {
|
||||
"agent": "conta",
|
||||
"RouterCallKeyDay": "20260406",
|
||||
"RouterCallKey": "RCK-123",
|
||||
"ANI": "5511888888888",
|
||||
"GSM": "5511777777777",
|
||||
"msisdn": "5511777777777",
|
||||
"callIdGed": "GED-777",
|
||||
"ID_FATURA": "fat-123",
|
||||
"current_invoice_number": "fat-123",
|
||||
}
|
||||
|
||||
|
||||
def test_build_remote_agent_context_for_conta_forwards_protocol_id() -> None:
|
||||
context = build_remote_agent_context(
|
||||
data={
|
||||
"agent": "conta",
|
||||
"ani": "5511888888888",
|
||||
"gsm": "5511777777777",
|
||||
"routerCallKeyDay": "20260406",
|
||||
"routerCallKey": "RCK-123",
|
||||
"callIdGed": "GED-777",
|
||||
"protocol_id": "PRT-777",
|
||||
},
|
||||
agent_data={"idFatura": "fat-123"},
|
||||
)
|
||||
|
||||
assert context["protocol_id"] == "PRT-777"
|
||||
assert context["protocolo"] == "PRT-777"
|
||||
assert context["protocolNumber"] == "PRT-777"
|
||||
|
||||
|
||||
def test_build_remote_agent_context_for_oferta_uses_protocolo_and_never_fatura() -> None:
|
||||
context = build_remote_agent_context(
|
||||
data={
|
||||
"agent": "ofertas",
|
||||
"ani": "5511888888888",
|
||||
"gsm": "5511777777777",
|
||||
"routerCallKeyDay": "20260406",
|
||||
"routerCallKey": "RCK-123",
|
||||
"callIdGed": "GED-777",
|
||||
"protocolo": "PRT-777",
|
||||
"assetId": "asset-777",
|
||||
"channelId": "ura",
|
||||
},
|
||||
agent_data={"idFatura": "fat-should-not-leak"},
|
||||
)
|
||||
|
||||
assert context == {
|
||||
"agent": "oferta",
|
||||
"RouterCallKeyDay": "20260406",
|
||||
"RouterCallKey": "RCK-123",
|
||||
"ANI": "5511888888888",
|
||||
"GSM": "5511777777777",
|
||||
"msisdn": "5511777777777",
|
||||
"callIdGed": "GED-777",
|
||||
"protocolo": "PRT-777",
|
||||
"protocolNumber": "PRT-777",
|
||||
"channelId": "ura",
|
||||
"assetId": "asset-777",
|
||||
}
|
||||
|
||||
|
||||
def test_build_remote_agent_context_only_forwards_explicit_session_id() -> None:
|
||||
without_session = build_remote_agent_context(
|
||||
data={
|
||||
"agent": "oferta",
|
||||
"ani": "5511888888888",
|
||||
"gsm": "5511777777777",
|
||||
"routerCallKeyDay": "20260406",
|
||||
"routerCallKey": "RCK-123",
|
||||
"callIdGed": "GED-777",
|
||||
"protocolo": "PRT-777",
|
||||
},
|
||||
agent_data={},
|
||||
)
|
||||
with_session = build_remote_agent_context(
|
||||
data={
|
||||
**without_session,
|
||||
"agent": "oferta",
|
||||
"routerCallKeyDay": "20260406",
|
||||
"routerCallKey": "RCK-123",
|
||||
"ani": "5511888888888",
|
||||
"gsm": "5511777777777",
|
||||
"sessionId": "sess-777",
|
||||
},
|
||||
agent_data={},
|
||||
)
|
||||
|
||||
assert "sessionId" not in without_session
|
||||
assert "session_id" not in without_session
|
||||
assert with_session["sessionId"] == "sess-777"
|
||||
assert with_session["session_id"] == "sess-777"
|
||||
|
||||
|
||||
def test_build_remote_agent_context_forwards_explicit_message_id() -> None:
|
||||
context = build_remote_agent_context(
|
||||
data={
|
||||
"agent": "conta",
|
||||
"ani": "5511888888888",
|
||||
"gsm": "5511777777777",
|
||||
"routerCallKeyDay": "20260406",
|
||||
"routerCallKey": "RCK-123",
|
||||
"callIdGed": "GED-777",
|
||||
"messageId": "msg-777",
|
||||
},
|
||||
agent_data={},
|
||||
)
|
||||
|
||||
assert context["message_id"] == "msg-777"
|
||||
|
||||
|
||||
def test_parse_start_payload_does_not_enable_debug_events_from_client_flag_alone() -> None:
|
||||
ctx = parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"debug": {"events": True},
|
||||
"data": {
|
||||
"agent": "conta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"session_id": "load-debug-1",
|
||||
"routerCallKeyDay": "20260817",
|
||||
"routerCallKey": "LOAD-1",
|
||||
"callIdGed": "GED-LOAD-1",
|
||||
"agentData": {"idFatura": "fat-load-1"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert ctx.stress_test is False
|
||||
assert ctx.debug_events_enabled is False
|
||||
|
||||
|
||||
def test_parse_start_payload_enables_debug_events_for_scripted_fake_agent() -> None:
|
||||
response = "Esta resposta simulada possui tamanho suficiente para validar o contrato."
|
||||
ctx = parse_start_payload(
|
||||
{
|
||||
"type": "start",
|
||||
"data": {
|
||||
"agent": "conta",
|
||||
"ani": "5511999999999",
|
||||
"gsm": "5511999999999",
|
||||
"session_id": "stress-debug-1",
|
||||
"routerCallKeyDay": "20260817",
|
||||
"routerCallKey": "STRESS-1",
|
||||
"callIdGed": "GED-STRESS-1",
|
||||
"agentData": {"idFatura": "fat-stress-1"},
|
||||
},
|
||||
"callConfig": {
|
||||
"agentBackend": "remote_ws_fake",
|
||||
"agentFake": {"responses": f"{response};{response}"},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert ctx.stress_test is True
|
||||
assert ctx.debug_events_enabled is True
|
||||
56
tests/ws_gateway/test_voice_client.py
Normal file
56
tests/ws_gateway/test_voice_client.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
VOICE_CLIENT_HTML = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src"
|
||||
/ "app"
|
||||
/ "ws_gateway"
|
||||
/ "voice_client.html"
|
||||
)
|
||||
|
||||
|
||||
def test_voice_client_exposes_required_protocolo_for_oferta() -> None:
|
||||
html = VOICE_CLIENT_HTML.read_text(encoding="utf-8")
|
||||
|
||||
assert "oferta: [" in html
|
||||
assert 'key: "protocolo"' in html
|
||||
assert 'inputId: "agentFieldProtocolo"' in html
|
||||
assert 'label: "PROTOCOLO"' in html
|
||||
assert 'required: true' in html
|
||||
assert 'target: "data"' in html
|
||||
|
||||
|
||||
def test_voice_client_keeps_id_fatura_inside_agent_data() -> None:
|
||||
html = VOICE_CLIENT_HTML.read_text(encoding="utf-8")
|
||||
|
||||
assert "const agentData = {};" in html
|
||||
assert 'key: "idFatura"' in html
|
||||
assert 'data.agentData = agentData;' in html
|
||||
|
||||
|
||||
def test_voice_client_exposes_required_protocol_id_for_conta() -> None:
|
||||
html = VOICE_CLIENT_HTML.read_text(encoding="utf-8")
|
||||
|
||||
assert "conta: [" in html
|
||||
assert 'key: "protocol_id"' in html
|
||||
assert 'inputId: "agentFieldProtocolId"' in html
|
||||
assert 'label: "PROTOCOL_ID"' in html
|
||||
assert 'target: "data"' in html
|
||||
|
||||
|
||||
def test_voice_client_sends_session_id_in_start_data() -> None:
|
||||
html = VOICE_CLIENT_HTML.read_text(encoding="utf-8")
|
||||
|
||||
assert 'id="sessionId"' in html
|
||||
assert "function defaultSessionId()" in html
|
||||
assert "session_id: sessionId" in html
|
||||
|
||||
|
||||
def test_voice_client_guards_stale_microphone_streams() -> None:
|
||||
html = VOICE_CLIENT_HTML.read_text(encoding="utf-8")
|
||||
|
||||
assert "function stopMicStreaming()" in html
|
||||
assert "const wsAtStart = state.ws;" in html
|
||||
assert "state.ws !== wsAtStart" in html
|
||||
assert "mediaStream.getTracks().forEach((track) => track.stop());" in html
|
||||
Reference in New Issue
Block a user