first commit
This commit is contained in:
1
tests/livekit/__init__.py
Normal file
1
tests/livekit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
21
tests/livekit/test_agent_finalization.py
Normal file
21
tests/livekit/test_agent_finalization.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.livekit.policies.agent_finalization import (
|
||||
final_stop_from_agent_result,
|
||||
stop_status_for_agent_result_type,
|
||||
)
|
||||
|
||||
|
||||
def test_stop_status_for_agent_result_type_uses_conta_contract() -> None:
|
||||
assert stop_status_for_agent_result_type("resolvido") == "stop_resolvido_e_finalizado"
|
||||
assert stop_status_for_agent_result_type("nao_resolvido") == "stop_nao_resolvido"
|
||||
assert stop_status_for_agent_result_type("resolvido_outros_assuntos") == "stop_outro_assunto"
|
||||
assert stop_status_for_agent_result_type("outros_assuntos") == "stop_outro_assunto"
|
||||
assert stop_status_for_agent_result_type("erro_falha_sistema") == "stop_falha_sistema"
|
||||
assert stop_status_for_agent_result_type("erro_no_match") == "stop_no_match"
|
||||
|
||||
|
||||
def test_final_stop_from_agent_result_ignores_non_terminal_result_types() -> None:
|
||||
assert final_stop_from_agent_result({"type": "final", "content": "texto"}) is None
|
||||
assert final_stop_from_agent_result({"content": "texto"}) is None
|
||||
assert final_stop_from_agent_result(None) is None
|
||||
68
tests/livekit/test_bridge_gateway.py
Normal file
68
tests/livekit/test_bridge_gateway.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from app.livekit.adapters.bridge_gateway import BridgeGateway
|
||||
|
||||
|
||||
class _Participant:
|
||||
def __init__(self) -> None:
|
||||
self.calls = []
|
||||
|
||||
async def publish_data(self, payload, **kwargs) -> None:
|
||||
self.calls.append((json.loads(payload), kwargs))
|
||||
|
||||
|
||||
class _Room:
|
||||
name = "room-load-1"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.local_participant = _Participant()
|
||||
|
||||
|
||||
def test_publish_debug_event_targets_originating_bridge() -> None:
|
||||
async def _run() -> None:
|
||||
room = _Room()
|
||||
gateway = BridgeGateway(
|
||||
room=room,
|
||||
bridge_identity="bridge-load-1",
|
||||
protocol="LOAD-1",
|
||||
stress_test=True,
|
||||
)
|
||||
|
||||
await gateway.publish_debug_event(
|
||||
"stt.completed", duration_ms=123, text="texto reconhecido"
|
||||
)
|
||||
|
||||
payload, kwargs = room.local_participant.calls[0]
|
||||
assert payload["type"] == "debug_event"
|
||||
assert payload["event"] == "stt.completed"
|
||||
assert payload["stress_test"] is True
|
||||
assert payload["data"] == {
|
||||
"duration_ms": 123,
|
||||
"text": "texto reconhecido",
|
||||
}
|
||||
assert kwargs == {
|
||||
"reliable": True,
|
||||
"destination_identities": ["bridge-load-1"],
|
||||
"topic": "agent.debug",
|
||||
}
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def test_publish_debug_event_is_suppressed_outside_stress_test() -> None:
|
||||
async def _run() -> None:
|
||||
room = _Room()
|
||||
gateway = BridgeGateway(
|
||||
room=room,
|
||||
bridge_identity="bridge-regular-1",
|
||||
protocol="REGULAR-1",
|
||||
)
|
||||
|
||||
await gateway.publish_debug_event("stt.completed", duration_ms=123)
|
||||
|
||||
assert room.local_participant.calls == []
|
||||
|
||||
asyncio.run(_run())
|
||||
73
tests/livekit/test_compat.py
Normal file
73
tests/livekit/test_compat.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from app.livekit.compat import patch_inference_executor_is_alive
|
||||
|
||||
|
||||
def test_patch_inference_executor_is_alive_handles_closed_process(monkeypatch) -> None:
|
||||
fake_module = types.SimpleNamespace()
|
||||
|
||||
class FakeInferenceProcExecutor:
|
||||
def is_alive(self) -> bool:
|
||||
raise ValueError("process object is closed")
|
||||
|
||||
fake_module.InferenceProcExecutor = FakeInferenceProcExecutor
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.livekit.compat.import_module",
|
||||
lambda name: fake_module,
|
||||
)
|
||||
|
||||
patched = patch_inference_executor_is_alive()
|
||||
|
||||
assert patched is True
|
||||
assert FakeInferenceProcExecutor().is_alive() is False
|
||||
|
||||
|
||||
def test_patch_inference_executor_is_alive_preserves_other_value_errors(monkeypatch) -> None:
|
||||
fake_module = types.SimpleNamespace()
|
||||
|
||||
class FakeInferenceProcExecutor:
|
||||
def is_alive(self) -> bool:
|
||||
raise ValueError("unexpected failure")
|
||||
|
||||
fake_module.InferenceProcExecutor = FakeInferenceProcExecutor
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.livekit.compat.import_module",
|
||||
lambda name: fake_module,
|
||||
)
|
||||
|
||||
patch_inference_executor_is_alive()
|
||||
|
||||
with pytest.raises(ValueError, match="unexpected failure"):
|
||||
FakeInferenceProcExecutor().is_alive()
|
||||
|
||||
|
||||
def test_patch_inference_executor_is_alive_is_idempotent(monkeypatch) -> None:
|
||||
fake_module = types.SimpleNamespace()
|
||||
|
||||
class FakeInferenceProcExecutor:
|
||||
calls = 0
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
type(self).calls += 1
|
||||
raise ValueError("process object is closed")
|
||||
|
||||
fake_module.InferenceProcExecutor = FakeInferenceProcExecutor
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.livekit.compat.import_module",
|
||||
lambda name: fake_module,
|
||||
)
|
||||
|
||||
first = patch_inference_executor_is_alive()
|
||||
second = patch_inference_executor_is_alive()
|
||||
|
||||
assert first is True
|
||||
assert second is False
|
||||
assert FakeInferenceProcExecutor().is_alive() is False
|
||||
assert FakeInferenceProcExecutor.calls == 1
|
||||
96
tests/livekit/test_fake_remote_ws_adapter.py
Normal file
96
tests/livekit/test_fake_remote_ws_adapter.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from app.livekit.adapters.agent_backend import BackendReply
|
||||
from app.livekit.adapters.fake_remote_ws_adapter import FakeRemoteWSAdapter
|
||||
|
||||
|
||||
class FakeRemoteWSAdapterTests(unittest.IsolatedAsyncioTestCase):
|
||||
RESPONSES = (
|
||||
"Primeira resposta deterministica com comprimento intermediario",
|
||||
"Segunda resposta deterministica preparando a formalizacao",
|
||||
"Terceira resposta deterministica encerrando o atendimento",
|
||||
)
|
||||
|
||||
async def test_run_returns_mocked_reply_with_transcribed_text(self) -> None:
|
||||
adapter = FakeRemoteWSAdapter(
|
||||
intro="oi",
|
||||
request_context={
|
||||
"agent": "oferta",
|
||||
"RouterCallKeyDay": "20260329",
|
||||
"RouterCallKey": "0001",
|
||||
"ANI": "3133334444",
|
||||
"GSM": "31999999999",
|
||||
"callIdGed": "GED-123",
|
||||
},
|
||||
)
|
||||
await adapter.prepare(True, "PRT-123")
|
||||
|
||||
reply = await adapter.run({"text": "quero detalhes da oferta"})
|
||||
|
||||
self.assertEqual(reply.stage, "ARGUMENTATION")
|
||||
self.assertFalse(reply.done)
|
||||
self.assertIn("quero detalhes da oferta", reply.text.lower())
|
||||
|
||||
async def test_end_service_once_returns_done_without_endpoint_dependency(self) -> None:
|
||||
adapter = FakeRemoteWSAdapter(
|
||||
intro="oi",
|
||||
request_context={"agent": "conta", "GSM": "5511999999999"},
|
||||
)
|
||||
await adapter.prepare(True, "PRT-999")
|
||||
|
||||
reply = await adapter.end_service_once()
|
||||
|
||||
self.assertEqual(
|
||||
reply,
|
||||
BackendReply(
|
||||
stage="DONE",
|
||||
text="Atendimento simulado de conta encerrado. Obrigado.",
|
||||
done=True,
|
||||
export_payload={
|
||||
"type": "final",
|
||||
"content": "Atendimento simulado de conta encerrado. Obrigado.",
|
||||
"tool_calls": [],
|
||||
"result": [{"status": "ok", "reason": "fake_done"}],
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
async def test_scripted_responses_are_sequential_and_end_idempotently(self) -> None:
|
||||
adapter = FakeRemoteWSAdapter(
|
||||
intro="saudacao normal",
|
||||
request_context={"agent": "oferta"},
|
||||
delay_ms=0,
|
||||
responses=self.RESPONSES,
|
||||
)
|
||||
await adapter.prepare(True, "PRT-SEQUENTIAL")
|
||||
|
||||
first = await adapter.run({"text": "texto do STT que nao influencia a resposta"})
|
||||
second = await adapter.run({"text": "outro texto arbitrario reconhecido"})
|
||||
third = await adapter.run({"text": "ultimo texto arbitrario reconhecido"})
|
||||
repeated = await adapter.run({"text": "texto posterior ao encerramento"})
|
||||
|
||||
self.assertEqual(
|
||||
[first.stage, second.stage, third.stage],
|
||||
["ARGUMENTATION", "FORMALIZATION", "DONE"],
|
||||
)
|
||||
self.assertEqual([first.text, second.text, third.text], list(self.RESPONSES))
|
||||
self.assertTrue(third.done)
|
||||
self.assertIs(repeated, third)
|
||||
|
||||
async def test_scripted_sequence_is_isolated_per_adapter_session(self) -> None:
|
||||
first_call = FakeRemoteWSAdapter(intro="oi", delay_ms=0, responses=self.RESPONSES)
|
||||
second_call = FakeRemoteWSAdapter(intro="oi", delay_ms=0, responses=self.RESPONSES)
|
||||
await first_call.prepare(True, "PRT-1")
|
||||
await second_call.prepare(True, "PRT-2")
|
||||
|
||||
await first_call.run("fala um")
|
||||
first_reply_second_call = await second_call.run("fala independente")
|
||||
|
||||
self.assertEqual(first_reply_second_call.text, self.RESPONSES[0])
|
||||
self.assertEqual(first_reply_second_call.stage, "ARGUMENTATION")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
64
tests/livekit/test_initial_greeting_audio_cache.py
Normal file
64
tests/livekit/test_initial_greeting_audio_cache.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from app.livekit.runtime.initial_greeting_audio_cache import InitialGreetingAudioCache
|
||||
|
||||
|
||||
class InitialGreetingAudioCacheTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_cache_defaults_are_enabled_and_bounded(self) -> None:
|
||||
with mock.patch.dict("os.environ", {}, clear=True):
|
||||
cache = InitialGreetingAudioCache()
|
||||
|
||||
self.assertTrue(cache.enabled)
|
||||
self.assertEqual(cache._max_chars, 1_000)
|
||||
self.assertEqual(cache._ttl_s, 3_600)
|
||||
self.assertEqual(cache._max_entries, 32)
|
||||
self.assertEqual(cache._max_bytes, 16 * 1024 * 1024)
|
||||
self.assertEqual(cache._max_agent_variants, 1)
|
||||
self.assertEqual(cache._disable_ttl_s, 300)
|
||||
key = cache.key_for(
|
||||
agent="conta",
|
||||
text="Olá, como posso ajudar?",
|
||||
provider="xAI",
|
||||
voice="ara",
|
||||
language="pt-BR",
|
||||
sample_rate=24000,
|
||||
)
|
||||
self.assertIsNotNone(key)
|
||||
assert key is not None
|
||||
self.assertEqual(key.agent, "conta")
|
||||
|
||||
async def test_store_writes_atomic_pcm_wav_for_matching_key(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with mock.patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"INITIAL_GREETING_AUDIO_CACHE_ENABLED": "1",
|
||||
"INITIAL_GREETING_AUDIO_CACHE_DIR": directory,
|
||||
},
|
||||
clear=True,
|
||||
):
|
||||
cache = InitialGreetingAudioCache()
|
||||
key = cache.key_for(
|
||||
agent="conta",
|
||||
text="Olá, como posso ajudar?",
|
||||
provider="xAI",
|
||||
voice="ara",
|
||||
language="pt-BR",
|
||||
sample_rate=24000,
|
||||
)
|
||||
assert key is not None
|
||||
await cache.store_pcm(key, b"\x00\x00" * 240)
|
||||
|
||||
path = Path(directory) / f"{key.digest}.wav"
|
||||
self.assertTrue(path.is_file())
|
||||
with wave.open(str(path), "rb") as rendered:
|
||||
self.assertEqual(rendered.getnchannels(), 1)
|
||||
self.assertEqual(rendered.getsampwidth(), 2)
|
||||
self.assertEqual(rendered.getframerate(), 24000)
|
||||
self.assertEqual(rendered.getnframes(), 240)
|
||||
1129
tests/livekit/test_remote_agent_sse_adapter.py
Normal file
1129
tests/livekit/test_remote_agent_sse_adapter.py
Normal file
File diff suppressed because it is too large
Load Diff
1078
tests/livekit/test_remote_agent_ws_adapter.py
Normal file
1078
tests/livekit/test_remote_agent_ws_adapter.py
Normal file
File diff suppressed because it is too large
Load Diff
4802
tests/livekit/test_runtime.py
Normal file
4802
tests/livekit/test_runtime.py
Normal file
File diff suppressed because it is too large
Load Diff
125
tests/livekit/test_vad_flow_logging.py
Normal file
125
tests/livekit/test_vad_flow_logging.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
|
||||
from livekit.agents import vad as agents_vad
|
||||
|
||||
from app.livekit.main import FlowLoggingVADStream
|
||||
|
||||
|
||||
def _event(
|
||||
event_type: agents_vad.VADEventType,
|
||||
*,
|
||||
speech_s: float,
|
||||
raw_speech_s: float | None = None,
|
||||
silence_s: float = 0.0,
|
||||
speaking: bool = False,
|
||||
) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
type=event_type,
|
||||
speech_duration=speech_s,
|
||||
raw_accumulated_speech=speech_s if raw_speech_s is None else raw_speech_s,
|
||||
silence_duration=silence_s,
|
||||
raw_accumulated_silence=silence_s,
|
||||
probability=0.0,
|
||||
speaking=speaking,
|
||||
)
|
||||
|
||||
|
||||
def _stream(on_speech_end) -> FlowLoggingVADStream:
|
||||
return FlowLoggingVADStream(
|
||||
SimpleNamespace(),
|
||||
stream_id=0,
|
||||
should_log=True,
|
||||
release_logging_stream=lambda _stream_id: None,
|
||||
call_logger=logging.getLogger(__name__),
|
||||
min_interrupt_s=0.5,
|
||||
vad_config={
|
||||
"min_speech_duration": 0.15,
|
||||
"min_silence_duration": 1.0,
|
||||
"activation_threshold": 0.3,
|
||||
"deactivation_threshold": 0.15,
|
||||
},
|
||||
log_decisions=False,
|
||||
log_activity=False,
|
||||
activity_min_prob=0.0,
|
||||
on_speech_end=on_speech_end,
|
||||
)
|
||||
|
||||
|
||||
def test_speech_end_uses_final_duration_without_terminal_vad_silence() -> None:
|
||||
durations_ms: list[int] = []
|
||||
stream = _stream(durations_ms.append)
|
||||
|
||||
stream._log_vad_decision(
|
||||
_event(agents_vad.VADEventType.START_OF_SPEECH, speech_s=0.15)
|
||||
)
|
||||
# While Silero waits for the endpoint, INFERENCE_DONE keeps increasing
|
||||
# speech_duration with the terminal silence.
|
||||
stream._log_vad_decision(
|
||||
_event(
|
||||
agents_vad.VADEventType.INFERENCE_DONE,
|
||||
speech_s=1.2,
|
||||
raw_speech_s=0.2,
|
||||
silence_s=1.0,
|
||||
)
|
||||
)
|
||||
# END_OF_SPEECH reports the corrected duration after removing that silence.
|
||||
stream._log_vad_decision(
|
||||
_event(
|
||||
agents_vad.VADEventType.END_OF_SPEECH,
|
||||
speech_s=0.2,
|
||||
raw_speech_s=0.2,
|
||||
silence_s=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert durations_ms == [200]
|
||||
|
||||
|
||||
def test_short_audio_stays_short_after_a_previous_long_round() -> None:
|
||||
durations_ms: list[int] = []
|
||||
stream = _stream(durations_ms.append)
|
||||
|
||||
stream._log_vad_decision(
|
||||
_event(agents_vad.VADEventType.START_OF_SPEECH, speech_s=0.15)
|
||||
)
|
||||
stream._log_vad_decision(
|
||||
_event(
|
||||
agents_vad.VADEventType.INFERENCE_DONE,
|
||||
speech_s=2.2,
|
||||
raw_speech_s=1.2,
|
||||
silence_s=1.0,
|
||||
)
|
||||
)
|
||||
stream._log_vad_decision(
|
||||
_event(
|
||||
agents_vad.VADEventType.END_OF_SPEECH,
|
||||
speech_s=1.2,
|
||||
raw_speech_s=1.2,
|
||||
silence_s=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
stream._log_vad_decision(
|
||||
_event(agents_vad.VADEventType.START_OF_SPEECH, speech_s=0.15)
|
||||
)
|
||||
stream._log_vad_decision(
|
||||
_event(
|
||||
agents_vad.VADEventType.INFERENCE_DONE,
|
||||
speech_s=1.2,
|
||||
raw_speech_s=0.2,
|
||||
silence_s=1.0,
|
||||
)
|
||||
)
|
||||
stream._log_vad_decision(
|
||||
_event(
|
||||
agents_vad.VADEventType.END_OF_SPEECH,
|
||||
speech_s=0.2,
|
||||
raw_speech_s=0.2,
|
||||
silence_s=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert durations_ms == [1200, 200]
|
||||
64
tests/livekit/test_wav_audio.py
Normal file
64
tests/livekit/test_wav_audio.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import types
|
||||
import wave
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from app.livekit.runtime.wav_audio import wav_audio_frames, wav_duration_ms
|
||||
|
||||
|
||||
class _AudioFrame:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
data: bytes,
|
||||
sample_rate: int,
|
||||
num_channels: int,
|
||||
samples_per_channel: int,
|
||||
) -> None:
|
||||
self.data = data
|
||||
self.sample_rate = sample_rate
|
||||
self.num_channels = num_channels
|
||||
self.samples_per_channel = samples_per_channel
|
||||
|
||||
|
||||
def _write_wav(path: Path, *, samples: int, sample_rate: int = 1000) -> None:
|
||||
with wave.open(str(path), "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(sample_rate)
|
||||
wav.writeframes(b"\x01\x02" * samples)
|
||||
|
||||
|
||||
def test_wav_audio_frames_pads_last_frame_and_adds_tail_silence(tmp_path: Path) -> None:
|
||||
wav_path = tmp_path / "audio.wav"
|
||||
_write_wav(wav_path, samples=25)
|
||||
|
||||
livekit_module = types.ModuleType("livekit")
|
||||
rtc_module = types.ModuleType("livekit.rtc")
|
||||
rtc_module.AudioFrame = _AudioFrame
|
||||
livekit_module.rtc = rtc_module
|
||||
|
||||
async def _collect():
|
||||
with mock.patch.dict(sys.modules, {"livekit": livekit_module, "livekit.rtc": rtc_module}):
|
||||
return [
|
||||
frame
|
||||
async for frame in wav_audio_frames(
|
||||
str(wav_path),
|
||||
frame_duration_ms=20,
|
||||
tail_silence_ms=40,
|
||||
)
|
||||
]
|
||||
|
||||
frames = asyncio.run(_collect())
|
||||
|
||||
assert len(frames) == 4
|
||||
assert [frame.samples_per_channel for frame in frames] == [20, 20, 20, 20]
|
||||
assert frames[1].data[:10] == b"\x01\x02" * 5
|
||||
assert frames[1].data[10:] == b"\x00" * 30
|
||||
assert frames[2].data == b"\x00" * 40
|
||||
assert frames[3].data == b"\x00" * 40
|
||||
assert wav_duration_ms(str(wav_path)) == 25
|
||||
Reference in New Issue
Block a user