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

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