first commit

This commit is contained in:
2026-08-21 08:37:51 -03:00
commit 881a99b0a8
214 changed files with 61407 additions and 0 deletions

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@
from __future__ import annotations

View 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

View 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()

View 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()

View 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
View File

@@ -0,0 +1 @@

View 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()

View 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
View 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))

View File

@@ -0,0 +1 @@

View 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

View 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())

View 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

View 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()

View 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)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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]

View 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

View File

@@ -0,0 +1 @@
from __future__ import annotations

View 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()

View 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()

View 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()

View 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
View File

@@ -0,0 +1 @@
"""Tests for operational tooling."""

View 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 "![Fluxo STT](diagrams/stt_flow.svg)" 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

View 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
View File

@@ -0,0 +1 @@

View 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"

View 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"]

View 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())

View 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()

View 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

View 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"

View 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

View 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",
}
)

View 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

View 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
)

View 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) == ""

View File

@@ -0,0 +1 @@

View 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

View 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

View 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()

File diff suppressed because it is too large Load Diff

View 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())

View 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"

View 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())

View 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

View 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