From 881a99b0a8eb4f4be924e8d6a77d4df0e12b83c2 Mon Sep 17 00:00:00 2001 From: "cristiano.hoshikawa" Date: Fri, 21 Aug 2026 08:37:51 -0300 Subject: [PATCH] first commit --- .dockerignore | 47 + .env.kube.dev | 182 + .env.kube.fqa | 179 + .env.kube.prd | 175 + .gitignore | 54 + Dockerfile | 26 + README.md | 253 + azure-pipelines/dev.yml | 136 + azure-pipelines/fqa.yml | 142 + azure-pipelines/prod.yml | 156 + ddl/README.md | 3 + docker-compose.yml | 150 + docs/README.md | 17 + docs/api-overview.md | 728 ++ docs/refactor-log.md | 612 ++ docs/refactor-plan.md | 116 + docs/regional/ARQUITETURA_TIA_XAI_REGIONAL.md | 416 ++ docs/regional/CHANGELOG_IMPLEMENTACAO.md | 32 + docs/regional/DEPLOYMENT_TIA_XAI_REGIONAL.md | 385 ++ docs/regional/TESTES_TIA_XAI_REGIONAL.md | 151 + docs/vad-pause-logs.md | 193 + k8s/helm_deploy.yaml | 4 + k8s/livekit/Dockerfile | 7 + k8s/livekit/configmap.yaml | 19 + k8s/livekit/deployment.yaml | 100 + k8s/regional/deployment-region.yaml | 211 + k8s/regional/hpa-region.yaml | 32 + k8s/regional/pdb.yaml | 10 + k8s/regional/regions.env.example | 51 + k8s/regional/service.yaml | 17 + k8s/regional/xai-secret.example.yaml | 19 + k8s/secrets.yaml | 11 + k8s/tia/Dockerfile | 31 + k8s/tia/deployment.yaml | 264 + livekit.yaml | 8 + makefile | 495 ++ pytest.ini | 16 + requirements.txt | 34 + scripts/deploy-regional-k8s.sh | 15 + scripts/render-regional-k8s.sh | 24 + scripts/validate-regional-k8s.sh | 11 + sh/run_export.sh | 13 + src/agent/base/base_classifier.py | 66 + src/agent/base/base_stage.py | 205 + src/agent/classifier/check_guardrail.py | 40 + .../classifier/conversation_classifier.py | 80 + src/agent/classifier/conversation_summary.py | 13 + .../classifier/interruption_classifier.py | 23 + src/agent/pipeline/__main__.py | 164 + .../pipeline/customer_pipeline_langgraph.py | 942 +++ .../pipeline/customer_pipeline_unified.py | 663 ++ src/agent/pipeline/pipeline_streaming_text.py | 31 + src/agent/stage/argumentation.py | 132 + src/agent/stage/data_confirmation.py | 153 + src/agent/stage/formalization.py | 67 + src/agent/stage/presentation.py | 91 + src/agent/stage/prompt_manager.py | 466 ++ src/agent/stage/teste.py | 48 + src/agent/stage/unified_agent.py | 313 + src/agent/stage/unified_state.py | 107 + src/agent/utils/utils.py | 254 + src/app/agent_entry.py | 9 + src/app/bridge_entry.py | 32 + src/app/common/call_config.py | 265 + src/app/common/stage.py | 18 + src/app/common/timed.py | 81 + src/app/livekit/adapters/agent_backend.py | 43 + src/app/livekit/adapters/audio_gain.py | 104 + src/app/livekit/adapters/azure_rest_tts.py | 242 + src/app/livekit/adapters/backend_factory.py | 135 + src/app/livekit/adapters/bridge_gateway.py | 112 + src/app/livekit/adapters/export_service.py | 11 + .../adapters/fake_remote_ws_adapter.py | 428 ++ src/app/livekit/adapters/fake_tts.py | 65 + src/app/livekit/adapters/pipeline_adapter.py | 202 + .../adapters/remote_agent_sse_adapter.py | 1817 +++++ .../adapters/remote_agent_ws_adapter.py | 1577 +++++ src/app/livekit/adapters/speech_service.py | 111 + src/app/livekit/adapters/xai_pool_proxy.py | 579 ++ src/app/livekit/adapters/xai_tts.py | 1519 +++++ .../comfort/fails/tts_fail_recovery.wav | Bin 0 -> 122444 bytes .../assets/comfort/interruption/01.txt | 1 + .../assets/comfort/interruption/01.wav | Bin 0 -> 47084 bytes src/app/livekit/assets/comfort/long/01.txt | 1 + src/app/livekit/assets/comfort/long/01.wav | Bin 0 -> 133144 bytes src/app/livekit/assets/comfort/long/02.txt | 1 + src/app/livekit/assets/comfort/long/02.wav | Bin 0 -> 129228 bytes src/app/livekit/assets/comfort/long/03.txt | 1 + src/app/livekit/assets/comfort/long/03.wav | Bin 0 -> 117484 bytes src/app/livekit/assets/comfort/short/01.txt | 1 + src/app/livekit/assets/comfort/short/01.wav | Bin 0 -> 34298 bytes src/app/livekit/assets/comfort/short/02.txt | 1 + src/app/livekit/assets/comfort/short/02.wav | Bin 0 -> 34298 bytes src/app/livekit/assets/comfort/short/03.txt | 1 + src/app/livekit/assets/comfort/short/03.wav | Bin 0 -> 55828 bytes src/app/livekit/assets/comfort/short/04.txt | 1 + src/app/livekit/assets/comfort/short/04.wav | Bin 0 -> 49956 bytes src/app/livekit/azure_speech.py | 104 + src/app/livekit/call_config.py | 10 + src/app/livekit/compat.py | 42 + src/app/livekit/main.py | 1507 +++++ .../livekit/policies/agent_finalization.py | 46 + .../livekit/policies/finalization_policy.py | 15 + src/app/livekit/policies/idle_policy.py | 107 + src/app/livekit/policies/interrupt_policy.py | 57 + src/app/livekit/runtime/call_runtime.py | 6016 +++++++++++++++++ src/app/livekit/runtime/command_executor.py | 131 + src/app/livekit/runtime/commands.py | 91 + .../runtime/initial_greeting_audio_cache.py | 186 + src/app/livekit/runtime/scheduler.py | 48 + src/app/livekit/runtime/state.py | 99 + src/app/livekit/runtime/wav_audio.py | 73 + src/app/livekit/vad_dynamic_threshold.py | 134 + src/app/models/schemas.py | 46 + src/app/models/stages.py | 18 + src/app/providers/stt_config.py | 551 ++ src/app/providers/stt_fake.py | 179 + src/app/providers/stt_internal_livekit.py | 1180 ++++ src/app/providers/stt_vosk.py | 286 + src/app/providers/stt_word_dictionary.py | 17 + src/app/providers/tts.py | 324 + src/app/services/session_context.py | 58 + src/app/services/text_pipeline.py | 162 + src/app/services/text_pipeline_stream.py | 195 + src/app/tools/__init__.py | 1 + src/app/tools/local_stresstest/__init__.py | 10 + src/app/tools/local_stresstest/__main__.py | 13 + src/app/tools/local_stresstest/audio.py | 364 + src/app/tools/local_stresstest/report.py | 436 ++ src/app/tools/local_stresstest/runner.py | 755 +++ src/app/tools/local_stresstest/scenarios.py | 47 + src/app/tools/local_stresstest/text.py | 106 + src/app/tools/local_stresstest/timeline.py | 73 + src/app/tools/oci_audio_download.py | 229 + src/app/tools/oci_logs_cli_search.py | 678 ++ src/app/tools/oci_logs_viewer.html | 1282 ++++ src/app/tools/oci_logs_viewer.py | 508 ++ src/app/tools/sofya_transcribe.py | 1617 +++++ src/app/tools/xai_tts_load.py | 697 ++ src/app/tools/xai_tts_ws.py | 676 ++ src/app/tools/xai_tts_ws_capture.py | 949 +++ src/app/utils/audio_backlog.py | 111 + src/app/utils/background.py | 524 ++ src/app/utils/call_timeline.py | 356 + src/app/utils/dump.py | 76 + src/app/utils/export.py | 191 + src/app/utils/full_call_recording.py | 903 +++ src/app/utils/interruption_tracker.py | 418 ++ src/app/utils/logging.py | 958 +++ src/app/utils/pcm.py | 42 + src/app/utils/structured_otlp.py | 240 + src/app/utils/structured_pubsub.py | 128 + src/app/utils/stt_audio_upload.py | 408 ++ src/app/utils/turn_ids.py | 162 + src/app/ws_gateway/call_config.py | 9 + src/app/ws_gateway/fake_remote_agent.py | 149 + src/app/ws_gateway/main.py | 2100 ++++++ src/app/ws_gateway/readiness.py | 944 +++ src/app/ws_gateway/session_audio.py | 195 + src/app/ws_gateway/session_bootstrap.py | 140 + src/app/ws_gateway/session_lifecycle.py | 210 + src/app/ws_gateway/session_start.py | 326 + src/app/ws_gateway/voice_client.html | 1750 +++++ tests/__init__.py | 1 + tests/adapters/__init__.py | 1 + tests/adapters/test_audio_gain.py | 134 + tests/adapters/test_azure_rest_tts.py | 47 + tests/adapters/test_fake_tts.py | 117 + tests/adapters/test_xai_tts.py | 407 ++ tests/config/__init__.py | 1 + tests/config/test_azure_speech.py | 116 + tests/config/test_call_config.py | 254 + tests/conftest.py | 9 + tests/livekit/__init__.py | 1 + tests/livekit/test_agent_finalization.py | 21 + tests/livekit/test_bridge_gateway.py | 68 + tests/livekit/test_compat.py | 73 + tests/livekit/test_fake_remote_ws_adapter.py | 96 + .../test_initial_greeting_audio_cache.py | 64 + .../livekit/test_remote_agent_sse_adapter.py | 1129 ++++ tests/livekit/test_remote_agent_ws_adapter.py | 1078 +++ tests/livekit/test_runtime.py | 4802 +++++++++++++ tests/livekit/test_vad_flow_logging.py | 125 + tests/livekit/test_wav_audio.py | 64 + tests/providers/__init__.py | 1 + tests/providers/test_fake_stt.py | 57 + tests/providers/test_internal_http_stt.py | 465 ++ tests/providers/test_tts.py | 51 + tests/services/test_session_context.py | 52 + tests/tools/__init__.py | 1 + tests/tools/test_local_stresstest.py | 258 + tests/tools/test_oci_audio_download.py | 119 + tests/utils/__init__.py | 1 + tests/utils/test_audio_backlog.py | 109 + tests/utils/test_audio_output_tracker.py | 178 + tests/utils/test_background.py | 152 + tests/utils/test_call_timeline.py | 106 + .../utils/test_call_timeline_async_writer.py | 47 + tests/utils/test_full_call_recording.py | 292 + tests/utils/test_logging_async_file.py | 117 + tests/utils/test_logging_pubsub.py | 460 ++ tests/utils/test_structured_otlp.py | 85 + tests/utils/test_stt_audio_upload.py | 221 + tests/utils/test_turn_ids.py | 56 + tests/ws_gateway/__init__.py | 1 + tests/ws_gateway/test_audio_flow_logging.py | 9 + tests/ws_gateway/test_audio_input_backlog.py | 305 + tests/ws_gateway/test_fake_remote_agent.py | 79 + tests/ws_gateway/test_readiness.py | 1056 +++ tests/ws_gateway/test_session_audio.py | 290 + tests/ws_gateway/test_session_bootstrap.py | 201 + tests/ws_gateway/test_session_lifecycle.py | 365 + tests/ws_gateway/test_session_start.py | 385 ++ tests/ws_gateway/test_voice_client.py | 56 + 214 files changed, 61407 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.kube.dev create mode 100644 .env.kube.fqa create mode 100644 .env.kube.prd create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 azure-pipelines/dev.yml create mode 100644 azure-pipelines/fqa.yml create mode 100644 azure-pipelines/prod.yml create mode 100644 ddl/README.md create mode 100644 docker-compose.yml create mode 100644 docs/README.md create mode 100644 docs/api-overview.md create mode 100644 docs/refactor-log.md create mode 100644 docs/refactor-plan.md create mode 100644 docs/regional/ARQUITETURA_TIA_XAI_REGIONAL.md create mode 100644 docs/regional/CHANGELOG_IMPLEMENTACAO.md create mode 100644 docs/regional/DEPLOYMENT_TIA_XAI_REGIONAL.md create mode 100644 docs/regional/TESTES_TIA_XAI_REGIONAL.md create mode 100644 docs/vad-pause-logs.md create mode 100644 k8s/helm_deploy.yaml create mode 100644 k8s/livekit/Dockerfile create mode 100644 k8s/livekit/configmap.yaml create mode 100644 k8s/livekit/deployment.yaml create mode 100644 k8s/regional/deployment-region.yaml create mode 100644 k8s/regional/hpa-region.yaml create mode 100644 k8s/regional/pdb.yaml create mode 100644 k8s/regional/regions.env.example create mode 100644 k8s/regional/service.yaml create mode 100644 k8s/regional/xai-secret.example.yaml create mode 100644 k8s/secrets.yaml create mode 100644 k8s/tia/Dockerfile create mode 100644 k8s/tia/deployment.yaml create mode 100644 livekit.yaml create mode 100644 makefile create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 scripts/deploy-regional-k8s.sh create mode 100644 scripts/render-regional-k8s.sh create mode 100644 scripts/validate-regional-k8s.sh create mode 100644 sh/run_export.sh create mode 100644 src/agent/base/base_classifier.py create mode 100644 src/agent/base/base_stage.py create mode 100644 src/agent/classifier/check_guardrail.py create mode 100644 src/agent/classifier/conversation_classifier.py create mode 100644 src/agent/classifier/conversation_summary.py create mode 100644 src/agent/classifier/interruption_classifier.py create mode 100644 src/agent/pipeline/__main__.py create mode 100644 src/agent/pipeline/customer_pipeline_langgraph.py create mode 100644 src/agent/pipeline/customer_pipeline_unified.py create mode 100644 src/agent/pipeline/pipeline_streaming_text.py create mode 100644 src/agent/stage/argumentation.py create mode 100644 src/agent/stage/data_confirmation.py create mode 100644 src/agent/stage/formalization.py create mode 100644 src/agent/stage/presentation.py create mode 100644 src/agent/stage/prompt_manager.py create mode 100644 src/agent/stage/teste.py create mode 100644 src/agent/stage/unified_agent.py create mode 100644 src/agent/stage/unified_state.py create mode 100644 src/agent/utils/utils.py create mode 100644 src/app/agent_entry.py create mode 100644 src/app/bridge_entry.py create mode 100644 src/app/common/call_config.py create mode 100644 src/app/common/stage.py create mode 100644 src/app/common/timed.py create mode 100644 src/app/livekit/adapters/agent_backend.py create mode 100644 src/app/livekit/adapters/audio_gain.py create mode 100644 src/app/livekit/adapters/azure_rest_tts.py create mode 100644 src/app/livekit/adapters/backend_factory.py create mode 100644 src/app/livekit/adapters/bridge_gateway.py create mode 100644 src/app/livekit/adapters/export_service.py create mode 100644 src/app/livekit/adapters/fake_remote_ws_adapter.py create mode 100644 src/app/livekit/adapters/fake_tts.py create mode 100644 src/app/livekit/adapters/pipeline_adapter.py create mode 100644 src/app/livekit/adapters/remote_agent_sse_adapter.py create mode 100644 src/app/livekit/adapters/remote_agent_ws_adapter.py create mode 100644 src/app/livekit/adapters/speech_service.py create mode 100644 src/app/livekit/adapters/xai_pool_proxy.py create mode 100644 src/app/livekit/adapters/xai_tts.py create mode 100644 src/app/livekit/assets/comfort/fails/tts_fail_recovery.wav create mode 100644 src/app/livekit/assets/comfort/interruption/01.txt create mode 100644 src/app/livekit/assets/comfort/interruption/01.wav create mode 100644 src/app/livekit/assets/comfort/long/01.txt create mode 100644 src/app/livekit/assets/comfort/long/01.wav create mode 100644 src/app/livekit/assets/comfort/long/02.txt create mode 100644 src/app/livekit/assets/comfort/long/02.wav create mode 100644 src/app/livekit/assets/comfort/long/03.txt create mode 100644 src/app/livekit/assets/comfort/long/03.wav create mode 100644 src/app/livekit/assets/comfort/short/01.txt create mode 100644 src/app/livekit/assets/comfort/short/01.wav create mode 100644 src/app/livekit/assets/comfort/short/02.txt create mode 100644 src/app/livekit/assets/comfort/short/02.wav create mode 100644 src/app/livekit/assets/comfort/short/03.txt create mode 100644 src/app/livekit/assets/comfort/short/03.wav create mode 100644 src/app/livekit/assets/comfort/short/04.txt create mode 100644 src/app/livekit/assets/comfort/short/04.wav create mode 100644 src/app/livekit/azure_speech.py create mode 100644 src/app/livekit/call_config.py create mode 100644 src/app/livekit/compat.py create mode 100644 src/app/livekit/main.py create mode 100644 src/app/livekit/policies/agent_finalization.py create mode 100644 src/app/livekit/policies/finalization_policy.py create mode 100644 src/app/livekit/policies/idle_policy.py create mode 100644 src/app/livekit/policies/interrupt_policy.py create mode 100644 src/app/livekit/runtime/call_runtime.py create mode 100644 src/app/livekit/runtime/command_executor.py create mode 100644 src/app/livekit/runtime/commands.py create mode 100644 src/app/livekit/runtime/initial_greeting_audio_cache.py create mode 100644 src/app/livekit/runtime/scheduler.py create mode 100644 src/app/livekit/runtime/state.py create mode 100644 src/app/livekit/runtime/wav_audio.py create mode 100644 src/app/livekit/vad_dynamic_threshold.py create mode 100644 src/app/models/schemas.py create mode 100644 src/app/models/stages.py create mode 100644 src/app/providers/stt_config.py create mode 100644 src/app/providers/stt_fake.py create mode 100644 src/app/providers/stt_internal_livekit.py create mode 100644 src/app/providers/stt_vosk.py create mode 100644 src/app/providers/stt_word_dictionary.py create mode 100644 src/app/providers/tts.py create mode 100644 src/app/services/session_context.py create mode 100644 src/app/services/text_pipeline.py create mode 100644 src/app/services/text_pipeline_stream.py create mode 100644 src/app/tools/__init__.py create mode 100644 src/app/tools/local_stresstest/__init__.py create mode 100644 src/app/tools/local_stresstest/__main__.py create mode 100644 src/app/tools/local_stresstest/audio.py create mode 100644 src/app/tools/local_stresstest/report.py create mode 100644 src/app/tools/local_stresstest/runner.py create mode 100644 src/app/tools/local_stresstest/scenarios.py create mode 100644 src/app/tools/local_stresstest/text.py create mode 100644 src/app/tools/local_stresstest/timeline.py create mode 100644 src/app/tools/oci_audio_download.py create mode 100644 src/app/tools/oci_logs_cli_search.py create mode 100644 src/app/tools/oci_logs_viewer.html create mode 100644 src/app/tools/oci_logs_viewer.py create mode 100644 src/app/tools/sofya_transcribe.py create mode 100644 src/app/tools/xai_tts_load.py create mode 100644 src/app/tools/xai_tts_ws.py create mode 100644 src/app/tools/xai_tts_ws_capture.py create mode 100644 src/app/utils/audio_backlog.py create mode 100644 src/app/utils/background.py create mode 100644 src/app/utils/call_timeline.py create mode 100644 src/app/utils/dump.py create mode 100644 src/app/utils/export.py create mode 100644 src/app/utils/full_call_recording.py create mode 100644 src/app/utils/interruption_tracker.py create mode 100644 src/app/utils/logging.py create mode 100644 src/app/utils/pcm.py create mode 100644 src/app/utils/structured_otlp.py create mode 100644 src/app/utils/structured_pubsub.py create mode 100644 src/app/utils/stt_audio_upload.py create mode 100644 src/app/utils/turn_ids.py create mode 100644 src/app/ws_gateway/call_config.py create mode 100644 src/app/ws_gateway/fake_remote_agent.py create mode 100644 src/app/ws_gateway/main.py create mode 100644 src/app/ws_gateway/readiness.py create mode 100644 src/app/ws_gateway/session_audio.py create mode 100644 src/app/ws_gateway/session_bootstrap.py create mode 100644 src/app/ws_gateway/session_lifecycle.py create mode 100644 src/app/ws_gateway/session_start.py create mode 100644 src/app/ws_gateway/voice_client.html create mode 100644 tests/__init__.py create mode 100644 tests/adapters/__init__.py create mode 100644 tests/adapters/test_audio_gain.py create mode 100644 tests/adapters/test_azure_rest_tts.py create mode 100644 tests/adapters/test_fake_tts.py create mode 100644 tests/adapters/test_xai_tts.py create mode 100644 tests/config/__init__.py create mode 100644 tests/config/test_azure_speech.py create mode 100644 tests/config/test_call_config.py create mode 100644 tests/conftest.py create mode 100644 tests/livekit/__init__.py create mode 100644 tests/livekit/test_agent_finalization.py create mode 100644 tests/livekit/test_bridge_gateway.py create mode 100644 tests/livekit/test_compat.py create mode 100644 tests/livekit/test_fake_remote_ws_adapter.py create mode 100644 tests/livekit/test_initial_greeting_audio_cache.py create mode 100644 tests/livekit/test_remote_agent_sse_adapter.py create mode 100644 tests/livekit/test_remote_agent_ws_adapter.py create mode 100644 tests/livekit/test_runtime.py create mode 100644 tests/livekit/test_vad_flow_logging.py create mode 100644 tests/livekit/test_wav_audio.py create mode 100644 tests/providers/__init__.py create mode 100644 tests/providers/test_fake_stt.py create mode 100644 tests/providers/test_internal_http_stt.py create mode 100644 tests/providers/test_tts.py create mode 100644 tests/services/test_session_context.py create mode 100644 tests/tools/__init__.py create mode 100644 tests/tools/test_local_stresstest.py create mode 100644 tests/tools/test_oci_audio_download.py create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/test_audio_backlog.py create mode 100644 tests/utils/test_audio_output_tracker.py create mode 100644 tests/utils/test_background.py create mode 100644 tests/utils/test_call_timeline.py create mode 100644 tests/utils/test_call_timeline_async_writer.py create mode 100644 tests/utils/test_full_call_recording.py create mode 100644 tests/utils/test_logging_async_file.py create mode 100644 tests/utils/test_logging_pubsub.py create mode 100644 tests/utils/test_structured_otlp.py create mode 100644 tests/utils/test_stt_audio_upload.py create mode 100644 tests/utils/test_turn_ids.py create mode 100644 tests/ws_gateway/__init__.py create mode 100644 tests/ws_gateway/test_audio_flow_logging.py create mode 100644 tests/ws_gateway/test_audio_input_backlog.py create mode 100644 tests/ws_gateway/test_fake_remote_agent.py create mode 100644 tests/ws_gateway/test_readiness.py create mode 100644 tests/ws_gateway/test_session_audio.py create mode 100644 tests/ws_gateway/test_session_bootstrap.py create mode 100644 tests/ws_gateway/test_session_lifecycle.py create mode 100644 tests/ws_gateway/test_session_start.py create mode 100644 tests/ws_gateway/test_voice_client.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..85e57b8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +.git/ +.gitignore +.gitattributes + +__pycache__/ +*.py[cod] +*$py.class +.venv/ +venv/ +env/ +ENV/ +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.hypothesis/ + +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +tests/ +docs/ +azure-pipelines/ +k8s/ +ddl/ +sh/ +docker-compose.yml + +.env +.env.* +!.env.example + +logs/ +timeline/ +log_agent/ +recordings/ +backup_logs_*/ +src/logs/ +src/timeline/ + +tmp/ +temp/ +*.tmp diff --git a/.env.kube.dev b/.env.kube.dev new file mode 100644 index 0000000..7fcfcf1 --- /dev/null +++ b/.env.kube.dev @@ -0,0 +1,182 @@ +# Copie para .env.dev ou .env.prod antes de executar. + +APP_ENV=dev +LOG_LEVEL=DEBUG +LOG_DIR=./logs +LOG_TO_FILE=1 +CALL_TIMELINE_DIR=./timeline +CALL_TIMELINE_ENABLED=1 +CALL_TIMELINE_CONSOLE=1 +FLOW_LOG_ENABLED=1 +FLOW_AUDIO_BURST_GAP_S=0.8 +FLOW_LOG_AUDIO_BURST_END=1 +FLOW_LOG_PREVIEW_CHARS=500 +FLOW_LOG_STT_SKIPS=1 +FLOW_LOG_VAD_DECISIONS=1 +FLOW_LOG_VAD_ACTIVITY=1 +FLOW_LOG_VAD_ACTIVITY_MIN_PROB=0.03 +STRUCTURED_EVENT_LOG_ENABLED=1 +EXPORT_DIR=./recordings +ENTIRE_CALL_RECORDING_ENABLED=1 +ENTIRE_CALL_RECORDING_TMP_DIR=./recordings/entire_call_tmp + +AGENT_BASE_NAME=ws-voice-agent +AGENT_BACKEND=remote_sse +AGENT_RECONNECT_ENABLED=1 +AGENT_RECONNECT_MAX_ATTEMPTS=1 +AGENT_RECONNECT_TIMEOUT_S=10 + +# WebSocket keepalive do Uvicorn para sessoes longas de audio. +UVICORN_WS_PING_INTERVAL_S=30 +UVICORN_WS_PING_TIMEOUT_S=120 + +LIVEKIT_URL=ws://tim-ai-atend-agnt-integ-tia-livekit.agnt-ai-atendimento-tia.svc.cluster.local:7880 +LIVEKIT_API_KEY=tia_livek_tia_api_key +LIVEKIT_API_SECRET=TiaLivekitSecret2026KeyBridgeSync01 + +# Providers locais para smoke test +STT_PROVIDER=internal_http +TTS_PROVIDER=xai +TTS_EMPTY_FRAME_RETRY_TIMEOUT_S=3 +TTS_FIRST_FRAME_TIMEOUT_S=3 +TTS_UNDERFLOW_ERROR_MS=1000 +TTS_TOTAL_TIMEOUT_S=60 +TTS_PLAYOUT_START_TIMEOUT_S=6 + +# Tuning de fala/interrupcao: permite detectar palavras curtas como "nao". +MIN_INTERRUPT_S=0.5 +DISCARD_AUDIO_IF_UNINTERRUPTIBLE=0 +SILENCE_CHECK_EVERY=1 +# Volume da fala do agente: a correcao de nivel agora e feita na saida do TTS +# (worker), com limiter soft-knee p/ nao clipar. O ganho do bridge fica neutro. +WS_OUTPUT_GAIN=1.0 +TTS_OUTPUT_GAIN=2.0 +VAD_MIN_SPEECH_DURATION=0.15 +VAD_ACTIVATION_THRESHOLD=0.30 +VAD_DEACTIVATION_THRESHOLD=0.15 +VAD_MIN_SILENCE_DURATION=1.0 +VAD_PREFIX_PADDING_DURATION=1.0 +VAD_PREFIX_PADDING_MIN_DURATION=1.0 +AGENT_WAIT_TIMEOUT_RETRY_VAD_THRESHOLD_ENABLED=1 +AGENT_WAIT_TIMEOUT_RETRY_VAD_ACTIVATION_THRESHOLD=0.20 + +# STT fake: usa uma frase por turno. Quando acabar, repete a ultima. +FAKE_STT_TRANSCRIPTS="alô" +FAKE_STT_MODE=repeat_last +FAKE_STT_MIN_AUDIO_MS=150 + +# TTS fake: gera um tom PCM local para validar o pipeline de audio. +FAKE_TTS_TONE_HZ=440 +FAKE_TTS_AMPLITUDE=0.12 +FAKE_TTS_MIN_DURATION_MS=320 +FAKE_TTS_MAX_DURATION_MS=2200 +FAKE_TTS_CHAR_DURATION_MS=35 + +# Azure Speech TTS opcional +AZURE_TTS_IMPLEMENTATION=plugin +AZURE_SPEECH_REGION=brazilsouth +AZURE_SPEECH_ENDPOINT=https://speech-agent-ai-atendi-fqa-01.cognitiveservices.azure.com/ +AZURE_SPEECH_VOICE=pt-BR-FranciscaNeural +AZURE_SPEECH_LANGUAGE=pt-BR +AZURE_SPEECH_DEPLOYMENT_ID= + +# STT opcional +STT_URL=http://10.152.95.27:8100/api/transcriber +STT_KEY= +STT_LANG=portuguese +STT_MIN_PROB_SINGLE_WORD=0.03 +STT_MIN_AUDIO_MS=120 +STT_MIN_DBFS=-55 +VOSK_MODEL_PATH= + +# Backend remoto opcional +REMOTE_AGENT_WS_URL=ws://tim-ai-contas-agnt-service.agnt-ai-atendimento-contas.svc.cluster.local:80/agent/ws +# Endpoint fake mantido apenas para testes manuais do contrato websocket. +REMOTE_AGENT_WS_FAKE_URL=ws://127.0.0.1:8000/fake-agent/ws +REMOTE_AGENT_WS_OPEN_TIMEOUT_S=10 +REMOTE_AGENT_WS_READ_TIMEOUT_S=900 +REMOTE_AGENT_WS_WRITE_TIMEOUT_S=10 +REMOTE_AGENT_WS_CLOSE_TIMEOUT_S=10 +REMOTE_AGENT_SSE_URL_CONTA=http://tim-ai-contas-agnt-service.agnt-ai-atendimento-contas.svc.cluster.local:80/agent/sse +REMOTE_AGENT_HEALTH_URL=http://tim-ai-contas-agnt-service.agnt-ai-atendimento-contas.svc.cluster.local:80/health +REMOTE_AGENT_HEALTH_URL_CONTA=http://tim-ai-contas-agnt-service.agnt-ai-atendimento-contas.svc.cluster.local:80/health +REMOTE_AGENT_SSE_DEFAULT_STAGE=PRESENTATION +REMOTE_AGENT_SSE_CONNECT_TIMEOUT_S=10 +REMOTE_AGENT_SSE_READ_TIMEOUT_S=900 +REMOTE_AGENT_SSE_WRITE_TIMEOUT_S=10 +REMOTE_AGENT_INFLIGHT_WAIT_INTERVAL_S=12 +REMOTE_AGENT_INFLIGHT_WAIT_TIMEOUT_S=180 +REMOTE_AGENT_INFLIGHT_WAIT_MAX_NOTICES=0 +REMOTE_AGENT_INFLIGHT_WAIT_TEXT=Um momento, ainda estou consultando para te ajudar. +PRE_BACKEND_WAIT_NOTICE_FAST_ON_VAD_PAUSE=0 +REMOTE_AGENT_INFLIGHT_WAIT_SHORT_AUDIO_DIR=src/app/livekit/assets/comfort/short +REMOTE_AGENT_INFLIGHT_WAIT_LONG_AUDIO_DIR=src/app/livekit/assets/comfort/long + +# Mock temporario para encerrar a chamada apos a primeira resposta de audio do agente. +MOCK_STOP_AFTER_FIRST_AUDIO_ENABLED=0 +MOCK_STOP_AFTER_FIRST_AUDIO_SILENCE_S=2 +MOCK_STOP_AFTER_FIRST_AUDIO_REASON=nao_resolvido + +# Bridge audio tuning +HOLD_SILENCE_DBFS=-45 +LK_CATCHUP_KEEP_MS=600 +AUDIO_IN_BACKLOG_SHED_ENABLED=1 +AUDIO_IN_BACKLOG_SHED_THRESHOLD_MS=500 +AUDIO_IN_BACKLOG_SHED_KEEP_MS=300 +AUDIO_IN_LATENCY_METRICS_ENABLED=1 +AUDIO_IN_LATENCY_ALERT_MS=1000 +AUDIO_IN_LATENCY_LOG_INTERVAL_S=15 +LIVEKIT_AUDIO_SOURCE_QUEUE_SIZE_MS=500 +LIVEKIT_AUDIO_SOURCE_CLEAR_ON_SHED=1 + +# Status terminais de finalizacao normal, resolvidos por reason no bridge. +FINAL_STOP_STATUS_RESOLVED=stop_resolvido_e_finalizado +FINAL_STOP_STATUS_UNRESOLVED=stop_nao_resolvido +FINAL_STOP_STATUS_OTHER_SUBJECT=stop_outro_assunto +FINAL_STOP_STATUS_LONG_SILENCE=stop_silencio_longo +FINAL_STOP_DEFAULT_KIND=resolved +FINAL_STOP_REASON_RESOLVED=stage_done +FINAL_STOP_REASON_UNRESOLVED=nao_resolvido +FINAL_STOP_REASON_OTHER_SUBJECT=outro_assunto +FINAL_STOP_REASON_LONG_SILENCE=no_user_response + +# Readiness / capacidade do TIA +TIA_WS_MAX_CONNECTIONS=0 +TIA_RESOURCE_HEALTH_TTL_S=5 +TIA_RESOURCE_HEALTH_TIMEOUT_S=3 +TIA_SKIP_STT_READINESS=1 +STT_HEALTH_URL=http://10.152.95.27:8100/health + +# Idle nudge +IDLE_NUDGE_ENABLED=0 +IDLE_NUDGE_DELAY_S=60 +IDLE_NUDGE_JOIN_DELAY_S=60 + +# Interrupcao durante processamento +DEFERRED_INTERRUPTION_ENABLED=1 +DEFERRED_INTERRUPTION_MIN_AUDIO_MS=1000 +DEFERRED_INTERRUPTION_STT_SETTLE_TIMEOUT_S=3.0 +DEFERRED_INTERRUPTION_USER_TURN_TIMEOUT_S=10.0 + +# XAI +XAI_TTS_VOICE=c8x2ieiocufs +XAI_TTS_LANGUAGE=pt-BR +XAI_TTS_READINESS_MODE=connect +XAI_WEBSOCKET_URL=wss://peordagnt002prd.pe.inference.generativeai.us-chicago-1.oci.oraclecloud.com/xai/v1/tts + +# PUBSUB Metrics +GCP_PROJECT_ID=tim-bigdata-dev-ca1f +AGENT_PUBSUB_TOPIC=pbs-ingest-agnt-ai-tia-events + +REMOTE_AGENT_SSE_URL_OFERTA=https://agt-ai-atendimento-ofertas-dev.internal.timbrasil.com.br/agent/execute +REMOTE_AGENT_SSE_TLS_VERIFY_OFERTA=0 + +# OTEL +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://10.153.35.23/v1/traces +OTEL_SERVICE_NAME=ai-agent-tia-orch +OTEL_EXPORTER_OTLP_HEADERS=Host=tim-ai-atend-agnt-opentelemetry + +#BUCKET OBJECT STORAGE +BUCKET_REGION=sa-saopaulo-1 +BUCKET_NAME=osb-gru-agnt-ai-atendimento-dev-004 +BUCKET_NAMESPACE=grfrp6rtsznm diff --git a/.env.kube.fqa b/.env.kube.fqa new file mode 100644 index 0000000..6b28fdc --- /dev/null +++ b/.env.kube.fqa @@ -0,0 +1,179 @@ +# Copie para .env.dev ou .env.prod antes de executar. + +APP_ENV=fqa +LOG_LEVEL=DEBUG +LOG_DIR=./logs +LOG_TO_FILE=1 +CALL_TIMELINE_DIR=./timeline +CALL_TIMELINE_ENABLED=1 +CALL_TIMELINE_CONSOLE=1 +FLOW_LOG_ENABLED=1 +FLOW_AUDIO_BURST_GAP_S=0.8 +FLOW_LOG_AUDIO_BURST_END=1 +FLOW_LOG_PREVIEW_CHARS=500 +FLOW_LOG_STT_SKIPS=1 +FLOW_LOG_VAD_DECISIONS=1 +FLOW_LOG_VAD_ACTIVITY=1 +FLOW_LOG_VAD_ACTIVITY_MIN_PROB=0.03 +STRUCTURED_EVENT_LOG_ENABLED=1 +EXPORT_DIR=./recordings +ENTIRE_CALL_RECORDING_ENABLED=1 +ENTIRE_CALL_RECORDING_TMP_DIR=./recordings/entire_call_tmp + +AGENT_BASE_NAME=ws-voice-agent +AGENT_BACKEND=remote_sse +AGENT_RECONNECT_ENABLED=1 +AGENT_RECONNECT_MAX_ATTEMPTS=1 +AGENT_RECONNECT_TIMEOUT_S=10 + +# WebSocket keepalive do Uvicorn para sessoes longas de audio. +UVICORN_WS_PING_INTERVAL_S=30 +UVICORN_WS_PING_TIMEOUT_S=120 + +LIVEKIT_URL=ws://tim-ai-atend-agnt-integ-tia-livekit.agnt-ai-atendimento-tia.svc.cluster.local:7880 +LIVEKIT_API_KEY=tia_livek_tia_api_key +LIVEKIT_API_SECRET=TiaLivekitSecret2026KeyBridgeSync01 + +# Providers locais para smoke test +STT_PROVIDER=internal_http +TTS_PROVIDER=xai +TTS_EMPTY_FRAME_RETRY_TIMEOUT_S=3 +TTS_FIRST_FRAME_TIMEOUT_S=3 +TTS_UNDERFLOW_ERROR_MS=1000 +TTS_TOTAL_TIMEOUT_S=60 +TTS_PLAYOUT_START_TIMEOUT_S=6 + +# Tuning de fala/interrupcao: permite detectar palavras curtas como "nao". +MIN_INTERRUPT_S=0.5 +DISCARD_AUDIO_IF_UNINTERRUPTIBLE=0 +SILENCE_CHECK_EVERY=1 +# Volume da fala do agente: a correcao de nivel agora e feita na saida do TTS +# (worker), com limiter soft-knee p/ nao clipar. O ganho do bridge fica neutro. +WS_OUTPUT_GAIN=1.0 +TTS_OUTPUT_GAIN=2.0 +VAD_MIN_SPEECH_DURATION=0.15 +VAD_ACTIVATION_THRESHOLD=0.30 +VAD_MIN_SILENCE_DURATION=1.0 +VAD_DEACTIVATION_THRESHOLD=0.15 +VAD_PREFIX_PADDING_DURATION=1.0 +VAD_PREFIX_PADDING_MIN_DURATION=1.0 +AGENT_WAIT_TIMEOUT_RETRY_VAD_THRESHOLD_ENABLED=1 +AGENT_WAIT_TIMEOUT_RETRY_VAD_ACTIVATION_THRESHOLD=0.20 + +# STT fake: usa uma frase por turno. Quando acabar, repete a ultima. +FAKE_STT_TRANSCRIPTS="alô" +FAKE_STT_MODE=repeat_last +FAKE_STT_MIN_AUDIO_MS=150 + +# TTS fake: gera um tom PCM local para validar o pipeline de audio. +FAKE_TTS_TONE_HZ=440 +FAKE_TTS_AMPLITUDE=0.12 +FAKE_TTS_MIN_DURATION_MS=320 +FAKE_TTS_MAX_DURATION_MS=2200 +FAKE_TTS_CHAR_DURATION_MS=35 + + +# Azure Speech TTS opcional +AZURE_TTS_IMPLEMENTATION=plugin +AZURE_SPEECH_REGION=brazilsouth +AZURE_SPEECH_ENDPOINT=https://speech-agent-ai-atendi-fqa-01.cognitiveservices.azure.com/ +AZURE_SPEECH_VOICE=pt-BR-FranciscaNeural +AZURE_SPEECH_LANGUAGE=pt-BR +AZURE_SPEECH_DEPLOYMENT_ID= + +# STT opcional +STT_URL=http://10.152.95.27:8100/api/transcriber +STT_KEY= +STT_LANG=portuguese +STT_MIN_PROB_SINGLE_WORD=0.03 +STT_MIN_AUDIO_MS=120 +STT_MIN_DBFS=-55 +VOSK_MODEL_PATH= + +# Backend remoto opcional +REMOTE_AGENT_WS_URL=ws://tim-ai-contas-agnt-service.agnt-ai-atendimento-contas.svc.cluster.local:80/agent/ws +# Endpoint fake mantido apenas para testes manuais do contrato websocket. +REMOTE_AGENT_WS_FAKE_URL=ws://127.0.0.1:8000/fake-agent/ws +REMOTE_AGENT_WS_OPEN_TIMEOUT_S=10 +REMOTE_AGENT_WS_READ_TIMEOUT_S=900 +REMOTE_AGENT_WS_WRITE_TIMEOUT_S=10 +REMOTE_AGENT_WS_CLOSE_TIMEOUT_S=10 +REMOTE_AGENT_SSE_URL_CONTA=http://tim-ai-contas-agnt-service.agnt-ai-atendimento-contas.svc.cluster.local:80/agent/sse +REMOTE_AGENT_HEALTH_URL=http://tim-ai-contas-agnt-service.agnt-ai-atendimento-contas.svc.cluster.local:80/health +REMOTE_AGENT_HEALTH_URL_CONTA=http://tim-ai-contas-agnt-service.agnt-ai-atendimento-contas.svc.cluster.local:80/health +REMOTE_AGENT_SSE_DEFAULT_STAGE=PRESENTATION +REMOTE_AGENT_SSE_CONNECT_TIMEOUT_S=10 +REMOTE_AGENT_SSE_READ_TIMEOUT_S=900 +REMOTE_AGENT_SSE_WRITE_TIMEOUT_S=10 +REMOTE_AGENT_INFLIGHT_WAIT_INTERVAL_S=12 +REMOTE_AGENT_INFLIGHT_WAIT_SHORT_AUDIO_DIR=src/app/livekit/assets/comfort/short +REMOTE_AGENT_INFLIGHT_WAIT_LONG_AUDIO_DIR=src/app/livekit/assets/comfort/long +REMOTE_AGENT_INFLIGHT_WAIT_TIMEOUT_S=180 +REMOTE_AGENT_INFLIGHT_WAIT_MAX_NOTICES=0 +REMOTE_AGENT_INFLIGHT_WAIT_TEXT=Um momento, ainda estou consultando para te ajudar. +PRE_BACKEND_WAIT_NOTICE_FAST_ON_VAD_PAUSE=0 + +# Mock temporario para encerrar a chamada apos a primeira resposta de audio do agente. +MOCK_STOP_AFTER_FIRST_AUDIO_ENABLED=0 +MOCK_STOP_AFTER_FIRST_AUDIO_SILENCE_S=2 +MOCK_STOP_AFTER_FIRST_AUDIO_REASON=nao_resolvido + +# Bridge audio tuning +HOLD_SILENCE_DBFS=-45 +LK_CATCHUP_KEEP_MS=600 +AUDIO_IN_BACKLOG_SHED_ENABLED=1 +AUDIO_IN_BACKLOG_SHED_THRESHOLD_MS=500 +AUDIO_IN_BACKLOG_SHED_KEEP_MS=300 +AUDIO_IN_LATENCY_METRICS_ENABLED=1 +AUDIO_IN_LATENCY_ALERT_MS=1000 +AUDIO_IN_LATENCY_LOG_INTERVAL_S=15 +LIVEKIT_AUDIO_SOURCE_QUEUE_SIZE_MS=500 +LIVEKIT_AUDIO_SOURCE_CLEAR_ON_SHED=1 + +# Status terminais de finalizacao normal, resolvidos por reason no bridge. +FINAL_STOP_STATUS_RESOLVED=stop_resolvido_e_finalizado +FINAL_STOP_STATUS_UNRESOLVED=stop_nao_resolvido +FINAL_STOP_STATUS_OTHER_SUBJECT=stop_outro_assunto +FINAL_STOP_STATUS_LONG_SILENCE=stop_silencio_longo +FINAL_STOP_DEFAULT_KIND=resolved +FINAL_STOP_REASON_RESOLVED=stage_done +FINAL_STOP_REASON_UNRESOLVED=nao_resolvido +FINAL_STOP_REASON_OTHER_SUBJECT=outro_assunto +FINAL_STOP_REASON_LONG_SILENCE=no_user_response + +# Readiness / capacidade do TIA +TIA_WS_MAX_CONNECTIONS=0 +TIA_RESOURCE_HEALTH_TTL_S=5 +TIA_RESOURCE_HEALTH_TIMEOUT_S=3 +TIA_SKIP_STT_READINESS=1 +STT_HEALTH_URL=http://10.152.95.27:8100/health + +# Idle nudge +IDLE_NUDGE_ENABLED=0 +IDLE_NUDGE_DELAY_S=60 +IDLE_NUDGE_JOIN_DELAY_S=60 + +# Interrupcao durante processamento +DEFERRED_INTERRUPTION_ENABLED=1 +DEFERRED_INTERRUPTION_MIN_AUDIO_MS=1000 +DEFERRED_INTERRUPTION_STT_SETTLE_TIMEOUT_S=3.0 +DEFERRED_INTERRUPTION_USER_TURN_TIMEOUT_S=10.0 + +# XAI +XAI_TTS_VOICE=c8x2ieiocufs +XAI_TTS_LANGUAGE=pt-BR +XAI_TTS_READINESS_MODE=connect +XAI_WEBSOCKET_URL=wss://peordagnt002prd.pe.inference.generativeai.us-chicago-1.oci.oraclecloud.com/xai/v1/tts + +GCP_PROJECT_ID=tim-bigdata-fqa-60ce +AGENT_PUBSUB_TOPIC=pbs-ingest-agnt-ai-tia-events + +# OTEL +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://10.153.35.71/v1/traces +OTEL_SERVICE_NAME=ai-agent-tia-orch +OTEL_EXPORTER_OTLP_HEADERS=Host=tim-ai-atend-agnt-opentelemetry + +#BUCKET OBJECT STORAGE +BUCKET_REGION=sa-saopaulo-1 +BUCKET_NAME=osb-gru-agnt-ai-atendimento-fqa-004 +BUCKET_NAMESPACE=grfrp6rtsznm diff --git a/.env.kube.prd b/.env.kube.prd new file mode 100644 index 0000000..e4edd4d --- /dev/null +++ b/.env.kube.prd @@ -0,0 +1,175 @@ +# Copie para .env.dev ou .env.prod antes de executar. + +APP_ENV=prd +LOG_LEVEL=INFO +LOG_DIR=./logs +CALL_TIMELINE_DIR=./timeline +CALL_TIMELINE_CONSOLE=0 +FLOW_LOG_ENABLED=1 +FLOW_AUDIO_BURST_GAP_S=0.8 +FLOW_LOG_AUDIO_BURST_END=1 +FLOW_LOG_PREVIEW_CHARS=500 +FLOW_LOG_STT_SKIPS=1 +FLOW_LOG_VAD_DECISIONS=1 +FLOW_LOG_VAD_ACTIVITY=1 +FLOW_LOG_VAD_ACTIVITY_MIN_PROB=0.03 +EXPORT_DIR=./recordings +ENTIRE_CALL_RECORDING_ENABLED=1 +ENTIRE_CALL_RECORDING_TMP_DIR=./recordings/entire_call_tmp + +AGENT_BASE_NAME=ws-voice-agent +AGENT_BACKEND=remote_sse +AGENT_RECONNECT_ENABLED=1 +AGENT_RECONNECT_MAX_ATTEMPTS=1 +AGENT_RECONNECT_TIMEOUT_S=10 + +# WebSocket keepalive do Uvicorn para sessoes longas de audio. +UVICORN_WS_PING_INTERVAL_S=30 +UVICORN_WS_PING_TIMEOUT_S=120 + +LIVEKIT_URL=ws://tim-ai-atend-agnt-integ-tia-livekit.agnt-ai-atendimento-tia.svc.cluster.local:7880 +LIVEKIT_API_KEY=tia_livek_tia_api_key +LIVEKIT_API_SECRET=TiaLivekitSecret2026KeyBridgeSync01 + +# Providers locais para smoke test +STT_PROVIDER=internal_http +TTS_PROVIDER=xai +TTS_EMPTY_FRAME_RETRY_TIMEOUT_S=3 +TTS_FIRST_FRAME_TIMEOUT_S=3 +TTS_UNDERFLOW_ERROR_MS=1000 +TTS_TOTAL_TIMEOUT_S=60 + +# Tuning de fala/interrupcao: permite detectar palavras curtas como "nao". +MIN_INTERRUPT_S=0.5 +DISCARD_AUDIO_IF_UNINTERRUPTIBLE=0 +SILENCE_CHECK_EVERY=1 +# Volume da fala do agente: a correcao de nivel agora e feita na saida do TTS +# (worker), com limiter soft-knee p/ nao clipar. O ganho do bridge fica neutro. +WS_OUTPUT_GAIN=1.0 +TTS_OUTPUT_GAIN=2.0 +VAD_MIN_SPEECH_DURATION=0.15 +VAD_ACTIVATION_THRESHOLD=0.30 +VAD_MIN_SILENCE_DURATION=1.0 +VAD_DEACTIVATION_THRESHOLD=0.15 +VAD_PREFIX_PADDING_DURATION=1.0 +VAD_PREFIX_PADDING_MIN_DURATION=1.0 +AGENT_WAIT_TIMEOUT_RETRY_VAD_THRESHOLD_ENABLED=1 +AGENT_WAIT_TIMEOUT_RETRY_VAD_ACTIVATION_THRESHOLD=0.20 + +# STT fake: usa uma frase por turno. Quando acabar, repete a ultima. +FAKE_STT_TRANSCRIPTS="alô" +FAKE_STT_MODE=repeat_last +FAKE_STT_MIN_AUDIO_MS=150 + +# TTS fake: gera um tom PCM local para validar o pipeline de audio. +FAKE_TTS_TONE_HZ=440 +FAKE_TTS_AMPLITUDE=0.12 +FAKE_TTS_MIN_DURATION_MS=320 +FAKE_TTS_MAX_DURATION_MS=2200 +FAKE_TTS_CHAR_DURATION_MS=35 + + +# Azure Speech TTS opcional +AZURE_TTS_IMPLEMENTATION=plugin +AZURE_SPEECH_REGION=brazilsouth +AZURE_SPEECH_ENDPOINT= +AZURE_SPEECH_VOICE=pt-BR-FranciscaNeural +AZURE_SPEECH_LANGUAGE=pt-BR +AZURE_SPEECH_DEPLOYMENT_ID= + +# STT opcional +STT_URL=http://10.152.89.211:8100/api/transcriber +STT_KEY= +STT_LANG=portuguese +STT_MIN_PROB_SINGLE_WORD=0.03 +STT_MIN_AUDIO_MS=120 +STT_MIN_DBFS=-55 +VOSK_MODEL_PATH= + +# Backend remoto opcional +REMOTE_AGENT_WS_URL=ws://tim-ai-contas-agnt-service.agnt-ai-atendimento-contas.svc.cluster.local:80/agent/ws +# Endpoint fake mantido apenas para testes manuais do contrato websocket. +REMOTE_AGENT_WS_FAKE_URL=ws://127.0.0.1:8000/fake-agent/ws +REMOTE_AGENT_WS_OPEN_TIMEOUT_S=10 +REMOTE_AGENT_WS_READ_TIMEOUT_S=900 +REMOTE_AGENT_WS_WRITE_TIMEOUT_S=10 +REMOTE_AGENT_WS_CLOSE_TIMEOUT_S=10 +REMOTE_AGENT_SSE_URL_CONTA=http://tim-ai-contas-agnt-service.agnt-ai-atendimento-contas.svc.cluster.local:80/agent/sse +REMOTE_AGENT_HEALTH_URL=http://tim-ai-contas-agnt-service.agnt-ai-atendimento-contas.svc.cluster.local:80/health +REMOTE_AGENT_HEALTH_URL_CONTA=http://tim-ai-contas-agnt-service.agnt-ai-atendimento-contas.svc.cluster.local:80/health +REMOTE_AGENT_SSE_DEFAULT_STAGE=PRESENTATION +REMOTE_AGENT_SSE_CONNECT_TIMEOUT_S=10 +REMOTE_AGENT_SSE_READ_TIMEOUT_S=900 +REMOTE_AGENT_SSE_WRITE_TIMEOUT_S=10 +REMOTE_AGENT_INFLIGHT_WAIT_INTERVAL_S=12 +REMOTE_AGENT_INFLIGHT_WAIT_TIMEOUT_S=180 +REMOTE_AGENT_INFLIGHT_WAIT_MAX_NOTICES=0 +REMOTE_AGENT_INFLIGHT_WAIT_TEXT=Um momento, ainda estou consultando para te ajudar. +PRE_BACKEND_WAIT_NOTICE_FAST_ON_VAD_PAUSE=0 +REMOTE_AGENT_INFLIGHT_WAIT_SHORT_AUDIO_DIR=src/app/livekit/assets/comfort/short +REMOTE_AGENT_INFLIGHT_WAIT_LONG_AUDIO_DIR=src/app/livekit/assets/comfort/long + +# Mock temporario para encerrar a chamada apos a primeira resposta de audio do agente. +MOCK_STOP_AFTER_FIRST_AUDIO_ENABLED=0 +MOCK_STOP_AFTER_FIRST_AUDIO_SILENCE_S=2 +MOCK_STOP_AFTER_FIRST_AUDIO_REASON=nao_resolvido + +# Bridge audio tuning +HOLD_SILENCE_DBFS=-45 +LK_CATCHUP_KEEP_MS=600 +AUDIO_IN_BACKLOG_SHED_ENABLED=1 +AUDIO_IN_BACKLOG_SHED_THRESHOLD_MS=500 +AUDIO_IN_BACKLOG_SHED_KEEP_MS=300 +AUDIO_IN_LATENCY_METRICS_ENABLED=1 +AUDIO_IN_LATENCY_ALERT_MS=1000 +AUDIO_IN_LATENCY_LOG_INTERVAL_S=15 +LIVEKIT_AUDIO_SOURCE_QUEUE_SIZE_MS=500 +LIVEKIT_AUDIO_SOURCE_CLEAR_ON_SHED=1 + +# Status terminais de finalizacao normal, resolvidos por reason no bridge. +FINAL_STOP_STATUS_RESOLVED=stop_resolvido_e_finalizado +FINAL_STOP_STATUS_UNRESOLVED=stop_nao_resolvido +FINAL_STOP_STATUS_OTHER_SUBJECT=stop_outro_assunto +FINAL_STOP_STATUS_LONG_SILENCE=stop_silencio_longo +FINAL_STOP_DEFAULT_KIND=resolved +FINAL_STOP_REASON_RESOLVED=stage_done +FINAL_STOP_REASON_UNRESOLVED=nao_resolvido +FINAL_STOP_REASON_OTHER_SUBJECT=outro_assunto +FINAL_STOP_REASON_LONG_SILENCE=no_user_response + +# Readiness / capacidade do TIA +TIA_WS_MAX_CONNECTIONS=0 +TIA_RESOURCE_HEALTH_TTL_S=5 +TIA_RESOURCE_HEALTH_TIMEOUT_S=3 +TIA_SKIP_STT_READINESS=1 +STT_HEALTH_URL=http://10.152.89.211:8100/healthz + +# Idle nudge +IDLE_NUDGE_ENABLED=0 +IDLE_NUDGE_DELAY_S=60 +IDLE_NUDGE_JOIN_DELAY_S=60 + +# Interrupcao durante processamento +DEFERRED_INTERRUPTION_ENABLED=1 +DEFERRED_INTERRUPTION_MIN_AUDIO_MS=1000 +DEFERRED_INTERRUPTION_STT_SETTLE_TIMEOUT_S=3.0 +DEFERRED_INTERRUPTION_USER_TURN_TIMEOUT_S=10.0 + +# XAI +XAI_TTS_VOICE=c8x2ieiocufs +XAI_TTS_LANGUAGE=pt-BR +XAI_TTS_READINESS_MODE=connect +XAI_WEBSOCKET_URL=wss://peordagnt002prd.pe.inference.generativeai.us-chicago-1.oci.oraclecloud.com/xai/v1/tts + +GCP_PROJECT_ID=tim-bigdata-prod-e305 +AGENT_PUBSUB_TOPIC=pbs-ingest-agnt-ai-tia-events + +# OTEL +OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://10.152.6.254/v1/traces +OTEL_SERVICE_NAME=ai-agent-tia-orch +OTEL_EXPORTER_OTLP_HEADERS=Host=tim-ai-atend-agnt-opentelemetry + +#BUCKET OBJECT STORAGE +BUCKET_REGION=sa-saopaulo-1 +BUCKET_NAME=osb-gru-agnt-ai-atendimento-prd-004 +BUCKET_NAMESPACE=grfrp6rtsznm diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68cfe57 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# OS / editor +.DS_Store +.idea/ +.vscode/ +*.iml +*.swp +*.swo +*~ + +# Python +__pycache__/ +*.py[cod] +*$py.class +.python-version +.mypy_cache/ +.pytest_cache/ +.hypothesis/ +.tox/ +.coverage +.coverage.* +htmlcov/ +build/ +dist/ +*.egg-info/ + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# Environment files +.env +.env.dev +!.env.example + +# Runtime artifacts +logs/ +timeline/ +log_agent/ +recordings/ +backup_logs_*/ +src/logs/ +src/timeline/ +.run/ + +# Documentation / temporary +docs/_build/ +tmp/ +temp/ +*.tmp + +# Cache +cache/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fd1301d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PYTHONPATH=/app/src + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + libsndfile1 \ +&& rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install uv && \ + uv pip install --no-cache-dir --system -r requirements.txt + +COPY src/ ./src/ + +RUN useradd -m -u 1000 agent && chown -R agent:agent /app +USER agent + +EXPOSE 8000 18081 + +ENTRYPOINT ["python", "-m"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e1c84a --- /dev/null +++ b/README.md @@ -0,0 +1,253 @@ +# TIA Voice API + +Este repositorio contem a stack de voz-to-voz do projeto TIA. + +## Estrutura + +- `src/app`: gateway WebSocket, runtime LiveKit, providers e utilitarios +- `src/agent`: pipeline e estagios do agente +- `tests`: testes automatizados +- `docs`: documentacao viva do projeto +- `k8s/livekit`: imagem e manifest do pod dedicado do LiveKit +- `k8s/tia`: imagem e manifest do pod da aplicacao com `bridge` e `agent` +- `requirements.txt`: dependencias Python da aplicacao +- `livekit.yaml`: configuracao local do servidor LiveKit + +## Setup local + +```bash +make setup +``` + +O alvo `make setup` faz o bootstrap do ambiente local: + +- cria `.venv` priorizando `python3.13`, `python3.12`, `python3.11`, `python3.10`, `python3.9`, `python3` e `python` +- instala as dependencias de `requirements.txt` +- cria `.env.dev` a partir de `.env.example` se o arquivo ainda nao existir + +O projeto hoje exige Python `3.9` ate `3.13`. Python `3.14` nao e aceito pelas dependencias atuais de `livekit-agents==1.3.10`. + +Se voce tiver mais de um Python instalado e quiser forcar uma versao especifica no bootstrap: + +```bash +make BOOTSTRAP_PY=python3.12 setup +``` + +Se voce preferir fazer manualmente ou quiser depurar o bootstrap, use: + +```bash +python3.12 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install -r requirements.txt +cp .env.example .env.dev +``` + +Se `make test`, `make agent` ou `make bridge` falharem com erro de virtualenv ausente, rode `make setup` primeiro. + +## Teste local + +```bash +make test +``` + +O `makefile` executa os testes com `.venv/bin/python` e injeta `PYTHONPATH=src`, entao o comando deve ser chamado a partir da raiz do repositorio. + +## Execucao local + +O `.env.dev` de desenvolvimento pode ser configurado em dois modos: + +- smoke test local: `AGENT_BACKEND=remote_ws_fake`, `STT_PROVIDER=fake` e `TTS_PROVIDER=fake` +- integracao real: ajuste `AGENT_BACKEND`, `STT_PROVIDER`, `TTS_PROVIDER` e as credenciais externas necessarias + +No modo fake, o agent nao depende de STT HTTP, ElevenLabs ou backend LLM externo. O `STT fake` consome uma sequencia configurada em `FAKE_STT_TRANSCRIPTS` e o `TTS fake` gera um tom PCM local para validar o pipeline de audio. + +Importante: `LIVEKIT_API_KEY` e `LIVEKIT_API_SECRET` do `.env.dev` precisam ser identicos ao bloco `keys` de `livekit.yaml`. Se esses valores divergirem, o bridge falha ao conectar/publicar no LiveKit e o dispatch do agent retorna erro de autenticacao. + +Para usar o STT HTTP real, configure no `.env.dev`: + +- `STT_PROVIDER=internal_http` +- `STT_URL=http://10.152.95.27:8100` +- `STT_KEY=...` se o servico exigir o header `x-api-key` +- `STT_LANG=portuguese` + +Observacao: o provider faz `POST` exatamente na URL configurada em `STT_URL`. Se o seu servico expuser uma rota especifica, use a URL completa, por exemplo `http://10.152.95.27:8100/transcribe`. + +Para usar Azure Speech TTS com o plugin oficial do LiveKit, configure no `.env.dev`: + +- `TTS_PROVIDER=azure` +- `AZURE_SPEECH_KEY=...` +- `AZURE_SPEECH_REGION=...` ou `AZURE_SPEECH_ENDPOINT=https://...cognitiveservices.azure.com/` +- `AZURE_SPEECH_VOICE=pt-BR-FranciscaNeural` +- `AZURE_SPEECH_LANGUAGE=pt-BR` +- `AZURE_SPEECH_DEPLOYMENT_ID=...` apenas para Custom Voice + +Para usar xAI TTS, configure no `.env.dev`: + +- `TTS_PROVIDER=xai` +- `XAI_API_KEY=...` +- `XAI_WEBSOCKET_URL=https://cloud9.api.x.ai/v1/tts` +- `XAI_TTS_READINESS_MODE=connect` opcional para validar apenas o handshake do WebSocket; default `synthesize` +- `XAI_TTS_VOICE=ara` opcional +- `XAI_TTS_LANGUAGE=pt-BR` opcional +- `TTS_FRAME_GAP_TIMEOUT_S=2` limite entre deltas de audio do xAI; quando estoura, o runtime registra `Falha TTS`, toca o audio de conforto e reenvia o texto + +Para enviar logs estruturados ao Google Pub/Sub, configure: + +- `GCP_PROJECT_ID=...` +- `AGENT_PUBSUB_TOPIC=agent-logs` ou `projects/.../topics/agent-logs` + +Se uma dessas variaveis nao existir, os eventos estruturados continuam indo apenas para o log local. O ambiente tambem precisa ter credenciais Google disponiveis via Application Default Credentials ou service account com permissao de publicacao no topico. + +Para subir o fluxo local de voz: + +```bash +make local-up +``` + +Depois abra `http://127.0.0.1:8000/voice-client`. O cliente web agora vem com `remote_ws_fake`, `fake` STT e `fake` TTS por padrao para smoke test local. + +Para rodar o teste operacional com STT Sofya, TTS xAI, Bridge e LiveKit reais: + +```bash +make local-stresstest +``` + +O alvo sobe o ambiente local se necessario, executa `python -m app.tools.local_stresstest` e gera os artefatos em `.run/local-stresstest/`: + +- `report.md`: resumo em Markdown com tabelas e diagramas Mermaid +- `summary.json`: resultado estruturado completo +- `stt_results.csv`, `tts_results.csv`, `e2e_results.csv`: planilhas dos cenarios +- `timeline_excerpt.jsonl`: eventos relevantes da timeline da chamada E2E +- `diagrams/*.svg`: imagens estaticas dos fluxos, boas para preview no VSCode +- `diagrams/*.mmd`: fontes Mermaid dos fluxos +- `stt_dumps/*.wav` e `stt_dumps/*.pcm`: audio efetivamente enviado ao STT +- WAVs gerados para baseline, variacoes, TTS e audio recebido do Bridge + +Por padrao, o alvo habilita dumps de audio do STT e logs de VAD do agent +(`FLOW_LOG_VAD_DECISIONS=1`, `FLOW_LOG_VAD_ACTIVITY=1`). O agent local e reiniciado +antes do teste para garantir que esses flags entrem no processo. + +Se `STRESS_AUDIO` nao for informado, o runner gera um baseline sintetico com xAI TTS usando `STRESS_SYNTH_TEXT`, ou `STRESS_EXPECTED_TEXT` quando `STRESS_SYNTH_TEXT` nao for definido. Quando houver uma gravacao humana, rode: + +```bash +STRESS_AUDIO=/caminho/audio_16k_mono.wav make local-stresstest +``` + +Variaveis uteis: + +- `STRESS_EXPECTED_TEXT`: frase esperada para calculo de WER +- `STRESS_SYNTH_TEXT`: frase usada na sintese do baseline xAI TTS, default igual a `STRESS_EXPECTED_TEXT` +- `STRESS_CRITICAL_TERMS`: termos obrigatorios separados por virgula +- `STRESS_WER_THRESHOLD`: limite de WER, default `0.20` +- `STRESS_REPEAT`: repeticoes dos cenarios STT, default `1`; com os 25 cenarios atuais, gera 25 chamadas ao STT. Use `STRESS_REPEAT=2` para 50 chamadas +- `STRESS_CONCURRENCY`: concorrencia dos cenarios STT, default `2` +- `STRESS_PREFIX_TEXT`: prefixo que precisa aparecer no inicio da transcricao, default primeiras palavras de `STRESS_EXPECTED_TEXT` +- `STRESS_PREFIX_WORDS`: quantidade de palavras usadas no prefixo automatico, default `2` +- `STRESS_VAD_PROXY_DBFS`: limiar RMS usado na analise local de VAD proxy, default `-45` +- `STRESS_VAD_PREFIX_PADDING_MS`: padding usado na analise local de VAD proxy, default vem de `VAD_PREFIX_PADDING_DURATION` +- `STRESS_VAD_MIN_SPEECH_MS`: fala minima usada na analise local de VAD proxy, default `100` +- `VAD_PREFIX_PADDING_DURATION`: pre-roll do LiveKit VAD, default `2.0` segundos +- `VAD_PREFIX_PADDING_MIN_DURATION`: piso aplicado tambem sobre override de `call_config`, default `2.0` segundos +- `STT_INPUT_PREFIX_PADDING_MS`: silencio curto adicionado antes do WAV enviado ao STT HTTP, default `250` +- `STT_DUMP_DIR`: diretorio dos WAV/PCM enviados ao STT, default `.run/local-stresstest/stt_dumps` +- `FLOW_LOG_VAD_DECISIONS`: habilita logs `vad_speech_start`/`vad_speech_end`, default `1` no alvo +- `FLOW_LOG_VAD_ACTIVITY`: habilita logs `vad_activity`, default `1` no alvo +- `STRESS_RESTART_AGENT_FOR_DIAGNOSTICS`: reinicia o agent antes do teste, default `1` +- `STRESS_STARTUP_WAIT_S`: tempo maximo para aguardar Bridge e agent runtime, default `180` +- `STRESS_STARTUP_POLL_S`: intervalo entre probes de prontidao, default `3` +- `STRESS_SKIP_LOCAL_WAIT`: use `1` para pular a espera inicial +- `STRESS_BRIDGE_HEALTH_URL`: URL de health do Bridge, derivada de `STRESS_BRIDGE_URL` por padrao +- `STRESS_AGENT_HEALTH_URL`: URL de health do agent runtime, default `http://127.0.0.1:18081/` +- `STRESS_REPORT_DIR`: diretorio de saida, default `.run/local-stresstest` + +Comandos auxiliares do modo local: + +```bash +make local-status +make local-logs +make bridge-logs +make agent-logs +make local-down +``` + +Se o `agent` falhar com `worker process is not responding.. worker crashed?` e o log mostrar erro de bind na porta `18081`, existe um worker antigo preso nessa porta. Nesse caso, rode `make agent-down` e depois `make agent-up` ou `make local-up` novamente. + +## Kubernetes + +A pasta `k8s` agora esta organizada em dois blocos: + +- `k8s/livekit/Dockerfile`: usa a imagem oficial `livekit/livekit-server` como base para publicacao no registro privado da empresa +- `k8s/livekit/deployment.yaml`: deployment e service do pod exclusivo do LiveKit +- `k8s/tia/Dockerfile`: mesma imagem da aplicacao Python da raiz do repo, mantida ao lado do manifest para facilitar pipeline de build/publicacao +- `k8s/tia/deployment.yaml`: deployment e service do pod `tia-app`, com dois containers na mesma imagem: `tia-bridge` e `tia-agent` + +Os dois `Dockerfile`s devem ser buildados a partir da raiz do repositorio, porque dependem do contexto raiz para copiar `requirements.txt`, `src/` e `livekit.yaml`. + +Exemplos: + +```bash +docker build -f k8s/livekit/Dockerfile -t registry.example.com/tia/livekit:TAG . +docker build -f k8s/tia/Dockerfile -t registry.example.com/tia/app:TAG . +``` + +Depois do push para o registry privado, ajuste as imagens em `k8s/livekit/deployment.yaml` e `k8s/tia/deployment.yaml` ou substitua os placeholders via pipeline: + +- `LIVEKIT_IMAGE_REPOSITORY:LIVEKIT_IMAGE_TAG` +- `TIA_IMAGE_REPOSITORY:TIA_IMAGE_TAG` + +Aplicacao dos manifests: + +```bash +kubectl apply -f k8s/livekit/deployment.yaml +kubectl apply -f k8s/tia/deployment.yaml +``` + +Observacoes: + +- `k8s/livekit/Dockerfile` embute o arquivo `livekit.yaml` dentro da imagem; se a configuracao do LiveKit mudar, a imagem precisa ser rebuildada +- `k8s/tia/deployment.yaml` ainda usa envs inline de placeholder para `LIVEKIT_API_KEY` e `LIVEKIT_API_SECRET`; o passo seguinte natural e migrar isso para `Secret` e demais configs para `ConfigMap` +- o `bridge` atende na porta `8000` e o `agent` expõe a porta interna `18081` + +## Comandos uteis + +```bash +make test +make agent +make bridge +make livekit +``` + +Todos os comandos assumem o codigo em `src`, portanto a raiz do projeto deve ser usada como diretorio de execucao. + +## Documentacao + +- `docs/README.md`: indice das docs do projeto +- `docs/refactor-plan.md`: plano do refactor incremental +- `docs/refactor-log.md`: registro das mudancas por etapa +- `docs/api-overview.md`: documentacao viva da API e do comportamento atual + +--- + +## Regional xAI TTS pool (Kubernetes) + +Esta versão inclui uma arquitetura opcional para alta disponibilidade do TTS xAI com Pods TIA regionais, pool WebSocket pré-aquecido por Pod, readiness orientada a capacidade e failover via Service/Load Balancer. + +Documentação: + +- `docs/regional/ARQUITETURA_TIA_XAI_REGIONAL.md` +- `docs/regional/DEPLOYMENT_TIA_XAI_REGIONAL.md` +- `docs/regional/TESTES_TIA_XAI_REGIONAL.md` + +Código principal: + +- `src/app/livekit/adapters/xai_pool_proxy.py` + +Manifests e scripts: + +- `k8s/regional/` +- `scripts/render-regional-k8s.sh` +- `scripts/validate-regional-k8s.sh` +- `scripts/deploy-regional-k8s.sh` + +O modo antigo de conexão direta com xAI foi preservado. O deployment regional é opt-in. diff --git a/azure-pipelines/dev.yml b/azure-pipelines/dev.yml new file mode 100644 index 0000000..ffdd0d2 --- /dev/null +++ b/azure-pipelines/dev.yml @@ -0,0 +1,136 @@ +trigger: + branches: + include: + - develop + # paths: + # exclude: + # - azure-pipelines/* + +pool: + name: Azure Pipelines + vmImage: 'ubuntu-latest' + +resources: + repositories: + - repository: templates + type: git + name: Data_Science_AI_ML/template-yaml-devops-mlops + ref: master + +variables: +- template: oke_agents_atendimento/variables.yaml@templates +- group: sa-timsa-it-agnt-ai-atendimento-cicd-dev-auth-token +- group: tim-ai-atend-agnt-integ-tia-dev-fqa +- name: imageRepository + value: '$(ociRegistry)/$(OCI_NAMESPACE)/agnt-ai-atendimento/dev/agnt-ai-atendimento-tia' +- name: imageLivekit + value: '$(ociRegistry)/$(OCI_NAMESPACE)/agnt-ai-atendimento/dev/agnt-ai-atendimento-tia-livekit' +- name: DNS + value: agt-ai-atendimento-tia-dev.internal.timbrasil.com.br +- name: REDIS_IP + value: 10.152.96.51 +- name: REDIS_HOST + value: aaaaehl73aayzoctg2kvzc5xphvuiryrtwelktfaxcc3ill4cs7ml5a-p.redis.sa-saopaulo-1.oci.oraclecloud.com +# Livekit Config +- name: LIVEKIT_REPLICAS + value: 2 +- name: CPU_LIVEKIT_REQ + value: 1 +- name: CPU_LIVEKIT_LIM + value: 2 +- name: MEM_LIVEKIT_REQ + value: 2Gi +- name: MEM_LIVEKIT_LIM + value: 4Gi +# TIA Config +- name: TIA_REPLICAS + value: 2 +- name: CPU_TIA_REQ + value: 1 +- name: CPU_TIA_LIM + value: 2 +- name: MEM_TIA_REQ + value: 4Gi +- name: MEM_TIA_LIM + value: 6Gi +# TIA Bridge Config +- name: CPU_TIA_BRIDGE_REQ + value: 1 +- name: CPU_TIA_BRIDGE_LIM + value: 2 +- name: MEM_TIA_BRIDGE_REQ + value: 2Gi +- name: MEM_TIA_BRIDGE_LIM + value: 4Gi + +name: 0.0.$(Rev:r) + +stages: +- stage: Build + jobs: + - job: Build + steps: + - script: | + echo "$(auth-token)" | docker login $(ociRegistry) -u "$(OCI_NAMESPACE)/$(SA_NAME)" --password-stdin + displayName: 'Login to OCI Registry' + + - script: | + set -eo pipefail + + docker build -f $(Build.SourcesDirectory)/k8s/tia/Dockerfile -t $(imageRepository):$(Build.BuildNumber) $(Build.SourcesDirectory) + + docker build -f $(Build.SourcesDirectory)/k8s/livekit/Dockerfile -t $(imageLivekit):$(Build.BuildNumber) $(Build.SourcesDirectory) + displayName: 'Build the image' + + - script: | + docker push $(imageRepository):$(Build.BuildNumber) + docker push $(imageLivekit):$(Build.BuildNumber) + displayName: 'Push image to OCI Registry' + +- stage: Deploy + dependsOn: Build + condition: succeeded() + jobs: + - job: deployb8s + pool: + name: oci-kubernetes + variables: + K8S_NAMESPACE: 'agnt-ai-atendimento-tia' + IMAGE_REPOSITORY: '$(imageRepository)' + IMAGE_REPOSITORY_LIVEKIT: '$(imageLivekit)' + IMAGE_TAG: '$(Build.BuildNumber)' + APP_NAME: '$(Build.Repository.Name)' + OCI_CLI_CONFIG_FILE: '$(Pipeline.Workspace)/oci_config' + + steps: + + - template: oke_agents_atendimento/autenticacao_kubernetes_v2.yaml@templates + + - template: oke_agents_atendimento/apply_env_as_configmap.yaml@templates + + - template: oke_agents_atendimento/sa_gcp_secret.yaml@templates + + - script: | + set -eo pipefail + envsubst < k8s/secrets.yaml | kubectl apply -n $(K8S_NAMESPACE) -f - + displayName: "aplicando secrets" + env: + AZURE_SPEECH_KEY: $(AZURE_SPEECH_KEY) + XAI_API_KEY: $(XAI_API_KEY_DEV) + + - script: | + set -eo pipefail + + # cada pipeline que vamos querer deployar + mkdir -p helm_build/templates + envsubst < k8s/livekit/configmap.yaml > helm_build/templates/configmap-livekit.yaml + envsubst < k8s/livekit/deployment.yaml > helm_build/templates/deployment-livekit.yaml + envsubst < k8s/tia/deployment.yaml > helm_build/templates/deployment-tia.yaml + + envsubst < k8s/helm_deploy.yaml > helm_build/Chart.yaml + helm upgrade --install $(APP_NAME) ./helm_build \ + --namespace $(K8S_NAMESPACE) \ + --wait \ + --timeout 270s \ + --atomic + displayName: 'Apply kube custom helm' diff --git a/azure-pipelines/fqa.yml b/azure-pipelines/fqa.yml new file mode 100644 index 0000000..f481d3d --- /dev/null +++ b/azure-pipelines/fqa.yml @@ -0,0 +1,142 @@ +trigger: + branches: + include: + - fqa + +pool: + name: Azure Pipelines + vmImage: 'ubuntu-latest' + +resources: + repositories: + - repository: templates + type: git + name: Data_Science_AI_ML/template-yaml-devops-mlops + ref: master + +variables: +- template: oke_agents_atendimento/variables.yaml@templates +- group: sa-timsa-it-agnt-ai-atendimento-cicd-fqa-auth-token +- group: tim-ai-atend-agnt-integ-tia-dev-fqa +- name: imageRepository + value: '$(ociRegistry)/$(OCI_NAMESPACE)/agnt-ai-atendimento/fqa/agnt-ai-atendimento-tia' +- name: imageLivekit + value: '$(ociRegistry)/$(OCI_NAMESPACE)/agnt-ai-atendimento/fqa/agnt-ai-atendimento-tia-livekit' +- name: DNS + value: agt-ai-atendimento-tia-fqa.internal.timbrasil.com.br +- name: REDIS_IP + value: 10.152.96.51 +- name: REDIS_HOST + value: aaaaehl73aayzoctg2kvzc5xphvuiryrtwelktfaxcc3ill4cs7ml5a-p.redis.sa-saopaulo-1.oci.oraclecloud.com + +# Livekit Config +- name: LIVEKIT_REPLICAS + value: 2 +- name: CPU_LIVEKIT_REQ + value: 1 +- name: CPU_LIVEKIT_LIM + value: 2 +- name: MEM_LIVEKIT_REQ + value: 2Gi +- name: MEM_LIVEKIT_LIM + value: 4Gi + +# TIA Config +- name: TIA_REPLICAS + value: 2 +# esse valor de réplica vale para ambos os abaixo +- name: CPU_TIA_REQ + value: 1 +- name: CPU_TIA_LIM + value: 2 +- name: MEM_TIA_REQ + value: 4Gi +- name: MEM_TIA_LIM + value: 6Gi + +# TIA Bridge Config +- name: CPU_TIA_BRIDGE_REQ + value: 1 +- name: CPU_TIA_BRIDGE_LIM + value: 2 +- name: MEM_TIA_BRIDGE_REQ + value: 2Gi +- name: MEM_TIA_BRIDGE_LIM + value: 4Gi + +name: 0.1.$(Rev:r) + +stages: +- stage: Build + jobs: + - job: Build + steps: + - script: | + echo "$(auth-token)" | docker login $(ociRegistry) -u "$(OCI_NAMESPACE)/$(SA_NAME)" --password-stdin + displayName: 'Login to OCI Registry' + + - script: | + set -eo pipefail + + docker build -f $(Build.SourcesDirectory)/k8s/tia/Dockerfile -t $(imageRepository):$(Build.BuildNumber) $(Build.SourcesDirectory) + + docker build -f $(Build.SourcesDirectory)/k8s/livekit/Dockerfile -t $(imageLivekit):$(Build.BuildNumber) $(Build.SourcesDirectory) + displayName: 'Build the image' + + - script: | + docker push $(imageRepository):$(Build.BuildNumber) + docker push $(imageLivekit):$(Build.BuildNumber) + displayName: 'Push image to OCI Registry' + +- stage: Deploy + dependsOn: Build + condition: succeeded() + jobs: + - job: deployb8s + pool: + name: oci-kubernetes + variables: + K8S_NAMESPACE: 'agnt-ai-atendimento-tia' + IMAGE_REPOSITORY: '$(imageRepository)' + IMAGE_REPOSITORY_LIVEKIT: '$(imageLivekit)' + IMAGE_TAG: '$(Build.BuildNumber)' + APP_NAME: '$(Build.Repository.Name)' + OCI_CLI_CONFIG_FILE: '$(Pipeline.Workspace)/oci_config' + + steps: + + - template: oke_agents_atendimento/autenticacao_kubernetes_v2.yaml@templates + + - template: oke_agents_atendimento/apply_env_as_configmap.yaml@templates + parameters: + envFile: '.env.kube.fqa' + + - template: oke_agents_atendimento/sa_gcp_secret.yaml@templates + + - script: | + set -eo pipefail + envsubst < k8s/secrets.yaml | kubectl apply -n $(K8S_NAMESPACE) -f - + displayName: "aplicando secrets" + env: + AZURE_SPEECH_KEY: $(AZURE_SPEECH_KEY) + XAI_API_KEY: $(fXAI_API_KEY_FQA) + LIVEKIT_REDIS_USERNAME: $(REDIS_FQA_USERNAME) + LIVEKIT_REDIS_PASSWORD: $(REDIS_FQA_PASSWORD) + + - script: | + set -eo pipefail + + # cada pipeline que vamos querer deployar + mkdir -p helm_build/templates + + envsubst < k8s/livekit/configmap.yaml > helm_build/templates/configmap-livekit.yaml + envsubst < k8s/livekit/deployment.yaml > helm_build/templates/deployment-livekit.yaml + envsubst < k8s/tia/deployment.yaml > helm_build/templates/deployment-tia.yaml + + envsubst < k8s/helm_deploy.yaml > helm_build/Chart.yaml + helm upgrade --install $(APP_NAME) ./helm_build \ + --namespace $(K8S_NAMESPACE) \ + --wait \ + --timeout 180s \ + --atomic + displayName: 'Apply kube custom helm' \ No newline at end of file diff --git a/azure-pipelines/prod.yml b/azure-pipelines/prod.yml new file mode 100644 index 0000000..d3ec239 --- /dev/null +++ b/azure-pipelines/prod.yml @@ -0,0 +1,156 @@ +trigger: + branches: + include: + - master + +pool: + name: Azure Pipelines + vmImage: 'ubuntu-latest' + +resources: + repositories: + - repository: templates + type: git + name: Data_Science_AI_ML/template-yaml-devops-mlops + ref: master + - repository: TEMPLATES_CICD + type: git + name: TEMPLATES_CICD/TEMPLATES_CICD + ref: main + +variables: +- template: oke_agents_atendimento/variables.yaml@templates +- group: sa-timsa-it-agnt-ai-atendimento-cicd-prd-auth-token +- group: tim-ai-atend-agnt-integ-tia-dev-fqa # parece que prod é a mesma que fqa +- name: imageRepository + value: '$(ociRegistry)/$(OCI_NAMESPACE)/agnt-ai-atendimento/prd/agnt-ai-atendimento-tia' +- name: imageLivekit + value: '$(ociRegistry)/$(OCI_NAMESPACE)/agnt-ai-atendimento/prd/agnt-ai-atendimento-tia-livekit' +- name: DNS + value: agt-ai-atendimento-tia.internal.timbrasil.com.br +- name: REDIS_IP + value: 10.152.89.43 +- name: REDIS_HOST + value: aaaaehl73aawjafeo24e5zuqosggsrnjzlomctu4h636pxdhuf4bspa-p.redis.sa-saopaulo-1.oci.oraclecloud.com +# Livekit Config +- name: LIVEKIT_REPLICAS + value: 3 +- name: CPU_LIVEKIT_REQ + value: 2 +- name: CPU_LIVEKIT_LIM + value: 8 +- name: MEM_LIVEKIT_REQ + value: 2Gi +- name: MEM_LIVEKIT_LIM + value: 12Gi + +# TIA Config +- name: TIA_REPLICAS + value: 10 +# esse valor de réplica vale para ambos os abaixo +- name: CPU_TIA_REQ + value: 1 +- name: CPU_TIA_LIM + value: 6 +- name: MEM_TIA_REQ + value: 4Gi +- name: MEM_TIA_LIM + value: 8Gi + +# TIA Bridge Config +- name: CPU_TIA_BRIDGE_REQ + value: 1 +- name: CPU_TIA_BRIDGE_LIM + value: 4 +- name: MEM_TIA_BRIDGE_REQ + value: 2Gi +- name: MEM_TIA_BRIDGE_LIM + value: 4Gi + +name: 0.0.$(Rev:r) + +stages: +- stage: Build + jobs: + - job: Build + steps: + - script: | + echo "$(auth-token)" | docker login $(ociRegistry) -u "$(OCI_NAMESPACE)/$(SA_NAME)" --password-stdin + displayName: 'Login to OCI Registry' + + - script: | + set -eo pipefail + + docker build -f $(Build.SourcesDirectory)/k8s/tia/Dockerfile -t $(imageRepository):$(Build.BuildNumber) $(Build.SourcesDirectory) + + docker build -f $(Build.SourcesDirectory)/k8s/livekit/Dockerfile -t $(imageLivekit):$(Build.BuildNumber) $(Build.SourcesDirectory) + displayName: 'Build the image' + + - script: | + docker push $(imageRepository):$(Build.BuildNumber) + docker push $(imageLivekit):$(Build.BuildNumber) + displayName: 'Push image to OCI Registry' + +- stage: Deploy + dependsOn: Build + condition: succeeded() + jobs: + - deployment: deployb8s + displayName: 'Deploy Application' + environment: 'agents_pnc_producao' + pool: + name: oci-kubernetes + variables: + K8S_NAMESPACE: 'agnt-ai-atendimento-tia' + IMAGE_REPOSITORY: '$(imageRepository)' + IMAGE_REPOSITORY_LIVEKIT: '$(imageLivekit)' + IMAGE_TAG: '$(Build.BuildNumber)' + APP_NAME: '$(Build.Repository.Name)' + OCI_CLI_CONFIG_FILE: '$(Pipeline.Workspace)/oci_config' + strategy: + runOnce: + deploy: + steps: + - checkout: self + + - template: oke_agents_atendimento/autenticacao_kubernetes_v2.yaml@templates + + - template: oke_agents_atendimento/apply_env_as_configmap.yaml@templates + parameters: + envFile: '.env.kube.prd' + + - template: oke_agents_atendimento/sa_gcp_secret.yaml@templates + + - script: | + set -eo pipefail + envsubst < k8s/secrets.yaml | kubectl apply -n $(K8S_NAMESPACE) -f - + displayName: "aplicando secrets" + env: + AZURE_SPEECH_KEY: $(AZURE_SPEECH_KEY) + XAI_API_KEY: $(fXAI_API_KEY_PRD) + LIVEKIT_REDIS_USERNAME: $(REDIS_PRD_USERNAME) + LIVEKIT_REDIS_PASSWORD: $(REDIS_PRD_PASSWORD) + + - script: | + set -eo pipefail + + mkdir -p helm_build/templates + envsubst < k8s/livekit/configmap.yaml > helm_build/templates/configmap-livekit.yaml + envsubst < k8s/livekit/deployment.yaml > helm_build/templates/deployment-livekit.yaml + envsubst < k8s/tia/deployment.yaml > helm_build/templates/deployment-tia.yaml + + envsubst < k8s/helm_deploy.yaml > helm_build/Chart.yaml + helm upgrade --install $(APP_NAME) ./helm_build \ + --namespace $(K8S_NAMESPACE) \ + --wait \ + --timeout 180s \ + --atomic + displayName: 'Apply kube custom helm' + +# - stage: CreateWIT +# displayName: 'Create WIT Entrega (Manual)' +# dependsOn: Deploy +# jobs: +# - template: wi_entrega/wi_entrega.yaml@templates +# parameters: +# environmentName: 'WI_entrega' \ No newline at end of file diff --git a/ddl/README.md b/ddl/README.md new file mode 100644 index 0000000..c1aa672 --- /dev/null +++ b/ddl/README.md @@ -0,0 +1,3 @@ +# Introduction + DDLs para criação de tabelas ou views + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e319341 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,150 @@ +# Stack local de observabilidade com Langfuse. +# A aplicacao principal continua sendo executada via `make agent`, `make bridge` e `make livekit`. + +x-langfuse-env: &langfuse-env + NEXTAUTH_URL: http://localhost:3005 + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + SALT: devsalt + ENCRYPTION_KEY: b127cbb367ba27ddf3851750686b88a984acd818c0b8444e9370d11fb75fb7df + NEXTAUTH_SECRET: devsecret + TELEMETRY_ENABLED: "false" + LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: "true" + CLICKHOUSE_MIGRATION_URL: clickhouse://clickhouse:9000 + CLICKHOUSE_URL: http://clickhouse:8123 + CLICKHOUSE_USER: clickhouse + CLICKHOUSE_PASSWORD: clickhouse + CLICKHOUSE_CLUSTER_ENABLED: "false" + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_AUTH: devredis + REDIS_TLS_ENABLED: "false" + LANGFUSE_USE_AZURE_BLOB: "false" + LANGFUSE_S3_EVENT_UPLOAD_BUCKET: langfuse + LANGFUSE_S3_EVENT_UPLOAD_REGION: auto + LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: minio + LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: miniosecret + LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://minio:9000 + LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true" + LANGFUSE_S3_EVENT_UPLOAD_PREFIX: events/ + LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: langfuse + LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto + LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: minio + LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: miniosecret + LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: http://minio:9000 + LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true" + LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: media/ + LANGFUSE_S3_BATCH_EXPORT_ENABLED: "false" + LANGFUSE_S3_BATCH_EXPORT_BUCKET: langfuse + LANGFUSE_S3_BATCH_EXPORT_PREFIX: exports/ + LANGFUSE_S3_BATCH_EXPORT_REGION: auto + LANGFUSE_S3_BATCH_EXPORT_ENDPOINT: http://minio:9000 + LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT: http://localhost:9090 + LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID: minio + LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY: miniosecret + LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE: "true" + +x-langfuse-depends-on: &langfuse-depends-on + postgres: + condition: service_healthy + redis: + condition: service_healthy + clickhouse: + condition: service_healthy + minio: + condition: service_started + +services: + langfuse-web: + image: docker.io/langfuse/langfuse:3 + restart: unless-stopped + depends_on: *langfuse-depends-on + ports: + - "3005:3000" + environment: + <<: *langfuse-env + + langfuse-worker: + image: docker.io/langfuse/langfuse-worker:3 + restart: unless-stopped + depends_on: *langfuse-depends-on + environment: + <<: *langfuse-env + + postgres: + image: docker.io/postgres:17 + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + TZ: UTC + PGTZ: UTC + ports: + - "127.0.0.1:5433:5432" + volumes: + - langfuse_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 3s + timeout: 3s + retries: 20 + + redis: + image: docker.io/redis:7 + restart: unless-stopped + command: + - --requirepass + - devredis + - --maxmemory-policy + - noeviction + ports: + - "127.0.0.1:6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "-a", "devredis", "ping"] + interval: 3s + timeout: 10s + retries: 20 + + clickhouse: + image: docker.io/clickhouse/clickhouse-server + restart: unless-stopped + user: "101:101" + environment: + CLICKHOUSE_DB: default + CLICKHOUSE_USER: clickhouse + CLICKHOUSE_PASSWORD: clickhouse + ports: + - "127.0.0.1:8124:8123" + - "127.0.0.1:9002:9000" + volumes: + - langfuse_clickhouse_data:/var/lib/clickhouse + - langfuse_clickhouse_logs:/var/log/clickhouse-server + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 5s + + minio: + image: minio/minio + restart: unless-stopped + entrypoint: sh + command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data' + environment: + MINIO_ROOT_USER: minio + MINIO_ROOT_PASSWORD: miniosecret + ports: + - "9090:9000" + - "127.0.0.1:9091:9001" + volumes: + - langfuse_minio_data:/data + +volumes: + langfuse_postgres_data: + langfuse_clickhouse_data: + langfuse_clickhouse_logs: + langfuse_minio_data: diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..b018f08 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,17 @@ +# Docs + +Esta pasta concentra a documentacao viva do projeto. + +Arquivos: +- `../README.md`: setup local, bootstrap do virtualenv, faixa de Python suportada, comandos de teste e estrutura de imagens/manifests Kubernetes +- `refactor-plan.md`: alvo arquitetural e fases do refactor incremental +- `refactor-log.md`: historico operacional das mudancas feitas no repositorio +- `api-overview.md`: descricao da API, fluxos e responsabilidades atuais + +Regra de uso durante o refactor: +1. toda mudanca relevante de arquitetura deve atualizar `refactor-log.md` +2. quando o comportamento publico mudar, atualizar `api-overview.md` +3. se a ordem das etapas do refactor mudar, atualizar `refactor-plan.md` + +Objetivo final: +- chegar ao fim do refactor com uma base documental suficiente para gerar a documentacao final da API sem depender de reconstruir contexto depois diff --git a/docs/api-overview.md b/docs/api-overview.md new file mode 100644 index 0000000..bf7bffb --- /dev/null +++ b/docs/api-overview.md @@ -0,0 +1,728 @@ +# API Overview + +## Status + +Documento vivo. Descreve o estado atual da API e sera atualizado ao longo do refactor. + +## O que esta API faz + +Esta API implementa um fluxo de voz-to-voz para TIA: +- recebe conexao websocket do cliente +- recebe audio PCM do cliente +- encaminha audio para o agent via LiveKit +- transcreve, processa a pipeline de negocio e sintetiza resposta +- devolve audio para o cliente +- finaliza a chamada e exporta resultado + +## Componentes principais + +### Bridge + +Arquivo principal: +- `app/ws_gateway/main.py` +- `app/ws_gateway/voice_client.html` + +Responsabilidades: +- aceitar conexao em `/ws/agent` +- receber `start` +- montar contexto da chamada +- despachar agent para uma room LiveKit +- mandar audio do cliente para LiveKit +- devolver audio do agent para o cliente +- encerrar websocket ao receber `DONE` +- servir um cliente web de teste em `/voice-client` + +### Agent + +Arquivos principais: +- `app/livekit/main.py` +- `app/livekit/runtime/call_runtime.py` +- `app/livekit/runtime/state.py` +- `app/livekit/runtime/commands.py` +- `app/livekit/runtime/command_executor.py` +- `app/livekit/runtime/scheduler.py` +- `app/livekit/policies/` +- `app/livekit/adapters/` + +Responsabilidades: +- entrar na room do LiveKit +- configurar STT, VAD e TTS +- receber transcricao final do usuario +- chamar o backend de IA configurado +- vocalizar resposta +- tratar interrupcao, idle nudge e finalizacao + +Organizacao atual: +- `main.py` faz o wiring do agent e das dependencias +- `CallRuntime` coordena o ciclo de vida da chamada +- `CallState` concentra o estado mutavel da sessao +- `commands.py` define os comandos internos para side effects +- `RuntimeCommandExecutor` executa bridge, export, speech, pipeline e start da sessao +- `TimerScheduler` concentra os timers nomeados do runtime +- `InterruptPolicy`, `IdlePolicy` e `FinalizationPolicy` concentram regras operacionais da chamada +- adapters encapsulam acesso ao backend de IA, speech, bridge e export + +### Pipeline de negocio + +Backends disponiveis no runtime websocket: +- `remote_ws`: + - `app/livekit/adapters/remote_agent_ws_adapter.py` +- `remote_sse`: + - `app/livekit/adapters/remote_agent_sse_adapter.py` + +Responsabilidades: +- preparar dados de atendimento +- controlar os estagios da chamada ou delegar esse controle ao agent remoto +- gerar a resposta textual por etapa +- registrar metadados e finalizar a conversa + +Selecao atual: +- `AGENT_BACKEND=remote_ws` envia cada turno transcrito para `REMOTE_AGENT_WS_URL` +- `AGENT_BACKEND=remote_sse` usa contratos especificos por agente: + - `conta` usa `GET /agent/sse` para inicializar a sessao e `POST /agent/sse` para executar cada acao + - `oferta` usa `POST /agent/execute` por turno e consome os eventos SSE `schedule_message`, `message` e `done` +- `AGENT_BACKEND=remote_ws_fake` usa um fake interno no proprio processo, sem abrir websocket local +- o fluxo do runtime local continua o mesmo: STT produz texto, o backend de IA devolve texto e o TTS vocaliza +- o roteamento entre agentes acontece pelo campo `data.agent` +- quando `AGENT_BACKEND=remote_ws`, o adapter escolhe a URL por `agent` se existirem: + - `REMOTE_AGENT_WS_URL_CONTA` + - `REMOTE_AGENT_WS_URL_OFERTA` + - `REMOTE_AGENT_WS_URL_COBRANCA` ou `REMOTE_AGENT_WS_URL_COBRA` +- quando `AGENT_BACKEND=remote_sse`, o adapter exige a URL especifica do `agent`, sem fallback generico: + - `REMOTE_AGENT_SSE_URL_CONTA` + - `REMOTE_AGENT_SSE_URL_OFERTA` + - `REMOTE_AGENT_SSE_URL_COBRANCA` ou `REMOTE_AGENT_SSE_URL_COBRA` + +Contrato remoto atual por turno: +- `timestamp` +- `agent` +- `RouterCallKeyDay` +- `RouterCallKey` +- `ANI` +- `GSM` +- `callIdGed` +- `ID_FATURA` somente para `agent=conta` +- `text` +- `protocol` +- `stage` + +Contrato especifico atual de `conta`: +- request: + - na abertura da sessao, o transporte envia query string com `ani`, `channelId` e `uraCallId` + - `action: "chat"` + - `payload.message` + - `payload.message_id` no transporte SSE de `conta`, com o mesmo UUID enviado na query string + - `payload.channel` no transporte websocket + - `payload.interruption` e `payload.events` quando existirem +- response: + - `type: "ready"` para a primeira fala ou fala que abre uma janela de resposta do cliente + - a fala de `ready` nao e interrompivel; se o cliente falar enquanto o audio do `ready` ainda estiver tocando, a transcricao e descartada e registrada em log + - apos o fim do audio de `ready`, o runtime mantem uma janela protegida de 750ms para absorver atraso de playback do cliente; fala iniciada nessa janela tambem e descartada + - depois dessa janela protegida, o runtime passa a esperar resposta do cliente + - `type: "result"` + - `action: "chat"` + - `result.type: "final"` para respostas intermediarias + - `result.content` como texto para TTS + - `type: "feedback"` ou `result.type: "feedback"` para mensagens de acompanhamento enquanto o backend continua processando o turno atual + - a fala de `feedback` nao e interrompivel, nao abre novo turno, nao espera resposta do cliente e nao arma timeout de silencio do cliente + - se o cliente falar durante `feedback`, o runtime descarta a transcricao, registra a tentativa em log e continua aguardando a resposta final do backend + - `feedback` nao dispara `metadata.wait_retry_messages`, como mensagens do tipo "Voce esta ai?" + - para finalizacoes esperadas, o runtime aguarda uma janela curta de silencio estavel do cliente, fala o `result.content` e depois envia `stop`; se uma nova transcricao final chegar durante a espera, a finalizacao anterior e descartada em favor do novo turno + - falas de finalizacao esperada (`resolvido`, `nao_resolvido`, `erro_no_match` etc.) nao sao interrompiveis; qualquer fala do cliente durante a finalizacao e descartada + - nesses casos o runtime nao chama finalizacao remota adicional (`end`/`end_service_once`) + - quando o `ready` vier com `metadata.wait_timeout_seconds`, o runtime aguarda esse tempo apos falar a mensagem de `ready` + - se tambem vier `metadata.wait_retry_messages`, o runtime fala cada item do array a cada novo estouro de `wait_timeout_seconds`; depois do ultimo item, aguarda mais um intervalo igual e envia + `stop_silencio_longo` com `reason: "no_user_response"` + - sem `metadata.wait_retry_messages`, o comportamento continua sendo encerrar direto no primeiro estouro de `wait_timeout_seconds` + - enquanto aguarda processamento depois do fim de fala do usuario, o runtime toca somente o audio local longo de conforto; no padrao atual, usa intervalo de 12s e no maximo 6 vezes + - se a resposta do backend remoto nao chegar em 300s, o runtime encerra com `stop_agent_backend_unavailable` +- se o participante do agent LiveKit desconectar sem `DONE`, o bridge tenta redispatch na mesma room; se o agent nao voltar, encerra com `stop_agent_runtime_unavailable` + +Mapeamento de finalizacoes esperadas de `conta`: +- `result.type: "resolvido"` -> `stop_resolvido_e_finalizado` +- `result.type: "nao_resolvido"` -> `stop_nao_resolvido` +- `result.type: "resolvido_outros_assuntos"` -> `stop_outro_assunto` +- `result.type: "outros_assuntos"` -> `stop_outro_assunto` +- `result.type: "erro_falha_sistema"` -> `stop_falha_sistema` +- `result.type: "erro_no_match"` -> `stop_no_match` + +Contrato SSE atual de `conta`: +- o runtime guarda o `session_id` retornado pelo backend nos eventos `ready` +- abre `GET /agent/sse?msisdn=...&invoice_id=...&ani=...&protocol_id=...&session_id=...&message_id=...&channelId=ura&uraCallId=...` no `prepare` +- consome o stream de inicializacao ate `ready` e ate o termino do prefetch (`prefetch_done`, `prefetch_skipped` ou `prefetch_failed`) ou fechamento da resposta +- envia cada turno com `POST /agent/sse?session_id=...&ani=...&protocol_id=...&message_id=...&channelId=ura&uraCallId=...` +- `message_id` e um UUID gerado por turno e independente de `session_id`; nos turnos de acao, `session_id` continua sendo o identificador de sessao retornado pelo backend de contas +- body do `POST`: + - `action: "chat"` + - `payload.message` + - `payload.message_id` com o mesmo UUID do parametro `message_id` da query string + - `payload.interruption` e `payload.events` quando existirem +- a resposta do proprio `POST` e `text/event-stream`; o runtime consome `ready`, `progress`, `result` e `error` ate receber o resultado da acao + +Contrato SSE atual de `oferta`: +- `prepare` nao abre stream remoto; o primeiro turno do agente chama `POST /agent/execute` +- o turno inicial envia `message: "inicio_atendimento"` para o backend remoto produzir a primeira fala +- cada turno usa body: + - `messageId` gerado por turno como UUID + - `message` com `inicio_atendimento` no primeiro turno ou a transcricao do cliente nos demais + - `context.protocolNumber` e `context.protocolo` vindos de `data.protocolo` + - `context.gsm` vindo de `data.gsm` + - `context.uraId` vindo de `data.callIdGed` + - `context.callIdGed`, `context.ani`, `context.routerCallKey`, `context.routerCallKeyDay`, `context.agent` e `context.assetId` quando existirem +- `context.sessionId` e enviado a partir de `data.session_id`/`data.sessionId` recebido no `start`; protocolo, `callIdGed` e `routerCallKey` nao sao usados como fallback de `sessionId` +- headers enviados: `Accept: text/event-stream`, `Content-Type: application/json` e `Channel-id` vindo de `channelId` ou `ura` +- eventos recebidos: + - `schedule_message`: fala imediatamente `scheduledMessage.message` + - `message`: fala `response` + - `done`: encerra o stream do turno; a chamada so e finalizada quando o status recebido indicar encerramento terminal +- de/para do `done.additionalInformations.service_status`: + - `RESOLVED` -> `stop_resolvido_e_finalizado` + - `UNRESOLVED` ou ausente -> `stop_nao_resolvido` + - `RESOLVED_WITH_NEW_REQUEST` -> nao envia `stop`; a conversa permanece aberta para o novo assunto +- `done.status=transferred` e reconhecido, mas ainda nao orquestra transferencia; nesta versao encerra como nao resolvido e preserva metadados de handover para evolucao futura + +Endpoint dev fornecido para `oferta`: +- host: `https://agt-ai-atendimento-ofertas-dev.internal.timbrasil.com.br` +- execute: `https://agt-ai-atendimento-ofertas-dev.internal.timbrasil.com.br/agent/execute` +- health: nao configurado por enquanto; a readiness nao deve derivar nem chamar `/health` para oferta ate essa rota ser fornecida +- enquanto o certificado interno nao estiver confiavel no ambiente local, `REMOTE_AGENT_SSE_TLS_VERIFY_OFERTA=0` permite testar o fluxo ignorando a validacao TLS do `httpx` +- pod de referencia: `tim-ai-atend-agnt-sales-65764fcf8d-xpzmb` +- IP de referencia: `http://10.153.35.23` +- portas: `80:31332/TCP`, `443:30635/TCP` + +Origem desses campos: +- o bridge extrai esses valores do `data` recebido em `WS /ws/agent` +- o agent local apenas reaproveita esse contexto para chamar o websocket remoto +- `protocol`/`protocolo` e identificador de negocio; `session_id` e identificador explicito de sessao recebido no `start`. O runtime nao preenche `session_id` com protocolo, `callIdGed` ou `routerCallKey`. + +Configuracao opcional por chamada: +- o cliente pode enviar `callConfig` no `start`, mas esse bloco e opcional +- objetivo atual: testes, homologacao e overrides tecnicos por sessao +- `callConfig.agentBackend` faz override do backend websocket para aquela chamada +- `callConfig.agentBackend` tambem pode selecionar o transporte SSE para a sessao +- `callConfig.stt` pode ajustar `provider`, `initialPrompt`, `configOverride` e `minProbSingleWord` +- `callConfig.tts` pode ajustar `provider`, `voiceId` e `modelId` +- valores aceitos hoje em `callConfig.agentBackend`: + - `remote_ws` + - `remote_sse` + - `remote_ws_fake` +- hoje os providers suportados no agent local sao: + - STT: `internal_http` (`Sofya Batch` no cliente de teste), `fake` + - TTS: `elevenlabs`, `azure`, `xai`, `fake` + +## Endpoints atuais + +### `GET /health` + +Retorna status simples de saude do bridge. + +### `GET /health/resources` + +Retorna o deep health do bridge com validacao dos recursos usados pelo `WS /ws/agent`. + +Comportamento: +- `200` quando os checks obrigatorios estao saudaveis +- `503` quando algum recurso obrigatorio falha +- inclui `active_connections`, `max_connections`, `cached`, `failed_resources` e `checks` +- os checks atuais cobrem: + - `agent_runtime` + - `agent_backend` + - `stt` + - `tts` + +### `GET /health/services` + +Retorna o health consolidado dos servicos do TIA no formato consumido pela esteira de operacao. + +Comportamento: +- `200` quando todos os servicos monitorados estao saudaveis +- `503` quando algum servico monitorado falha +- `status` no corpo retorna `ok` ou `fail` +- `htp_cod_status` preserva o status HTTP retornado pelo health do servico quando houver resposta HTTP +- `hhtp_cod_desc` preserva a descricao retornada pelo health quando houver; se nao houver descricao no corpo, usa a reason phrase HTTP +- ignora `AGENT_BACKEND=remote_ws_fake` e usa as rotas reais configuradas nos envs dos servicos +- `checks` cobre apenas os servicos atualmente monitorados: + - `agent_runtime` + - `agent_backend.contas`: `REMOTE_AGENT_HEALTH_URL_CONTA`, `REMOTE_AGENT_HEALTH_URL_CONTAS` ou `REMOTE_AGENT_HEALTH_URL` + - `agent_backend.oferta`: `REMOTE_AGENT_HEALTH_URL_OFERTA`, `REMOTE_AGENT_HEALTH_URL_OFERTAS` ou health derivado de `REMOTE_AGENT_SSE_URL_OFERTA` + - `stt.sofya`: `STT_HEALTH_URL` ou health derivado de `STT_URL` + - `tts.xAI`: provider xAI com `XAI_WEBSOCKET_URL`/`XAI_API_KEY` + +Para checks sem resposta HTTP, como falha de conexao ou probe por WebSocket, a rota usa fallback `200`/`500` e a mensagem interna do erro ou sucesso. + +Formato: + +```json +{ + "status": "ok", + "checks": { + "agent_runtime": { + "htp_cod_status": 200, + "hhtp_cod_desc": "SUCCESS" + }, + "agent_backend": { + "contas": { + "htp_cod_status": 200, + "hhtp_cod_desc": "SUCCESS" + }, + "oferta": { + "htp_cod_status": 200, + "hhtp_cod_desc": "SUCCESS" + } + }, + "stt": { + "sofya": { + "htp_cod_status": 200, + "hhtp_cod_desc": "SUCCESS" + } + }, + "tts": { + "xAI": { + "htp_cod_status": 200, + "hhtp_cod_desc": "SUCCESS" + } + } + } +} +``` + +### `GET /voice-client` + +Cliente web de teste para capturar microfone, enviar audio para `WS /ws/agent`, +reproduzir o audio do agent e configurar `STT`, `TTS` e `AGENT` por chamada. + +### `WS /fake-agent/ws` + +Websocket fake para homologacao local do backend remoto. + +Uso esperado: +- usar apenas para homologacao manual do contrato websocket fake +- manter `agent=conta|oferta|cobranca` +- deixar um cliente websocket externo chamar o fake diretamente quando precisar testar esse endpoint + +Comportamento: +- suporta o contrato `conta` com `action/payload` +- suporta o contrato generico com `text/stage` +- responde com progressao simples de stages +- encerra quando recebe textos como `encerrar`, `obrigado` ou `tchau` + +### `WS /ws/agent` + +Fluxo principal de voz: +1. cliente conecta +2. envia mensagem `start` +3. bridge valida capacidade e readiness dos recursos obrigatorios +4. recebe `ready` ou `stop` +5. se receber `ready`, envia audio binario +6. recebe audio binario de resposta +7. recebe `stop` ao fim da chamada ou em falha terminal + +Contrato de inicio da chamada `type=start`: +- a primeira mensagem de inicio deve ser um JSON textual com `type: "start"`; opcionalmente, `transferencia_session_id` pode chegar antes dela para informar somente o `session_id` +- os campos de negocio devem ser enviados em `data` +- chaves canonicas obrigatorias em `data`: + - `agent` + - `ani` + - `gsm` + - `session_id` + - `routerCallKey` + - `routerCallKeyDay` + - `callIdGed` +- chaves obrigatorias por agente: + - `agentData.idFatura` quando `agent=conta` + - `protocolo` quando `agent=oferta` +- valores canonicos recomendados para `agent`: + - `conta` + - `oferta` + - `cobranca` + +Exemplo recomendado de `start` para `conta`: + +```json +{ + "type": "start", + "data": { + "agent": "conta", + "ani": "5511999990000", + "gsm": "5511999990000", + "session_id": "550e8400-e29b-41d4-a716-446655440000", + "routerCallKeyDay": "20260409", + "routerCallKey": "RCK-001", + "callIdGed": "GED-123456", + "agentData": { + "idFatura": "FAT-123" + } + }, + "audioFormat": { + "encoding": "linear16", + "sampleRateHz": 16000, + "channels": 1 + }, + "callConfig": { + "agentBackend": "remote_ws" + } +} +``` + +Exemplo recomendado de `start` para `oferta`: + +```json +{ + "type": "start", + "data": { + "agent": "oferta", + "ani": "5511999990000", + "gsm": "5511999990000", + "session_id": "550e8400-e29b-41d4-a716-446655440000", + "routerCallKeyDay": "20260409", + "routerCallKey": "RCK-001", + "callIdGed": "GED-123456", + "protocolo": "PRT-20260409-0001" + }, + "audioFormat": { + "encoding": "linear16", + "sampleRateHz": 16000, + "channels": 1 + }, + "callConfig": { + "agentBackend": "remote_sse" + } +} +``` + +Mensagem opcional aceita antes do `start` em cenarios de transferencia: + +```json +{ + "type": "transferencia_session_id", + "data": { + "session_id": "550e8400-e29b-41d4-a716-446655440000" + } +} +``` + +Se essa mensagem chegar antes do `start`, o bridge usa esse valor apenas para preencher `data.session_id` quando o `start` ainda nao trouxer o campo. Em producao, o caminho recomendado e enviar `session_id` diretamente dentro de `data` no `start`. + +Contrato de audio: +- apos o `ready`, o cliente deve enviar audio binario bruto em `PCM16/LINEAR16`, `16000 Hz`, `1 canal` +- o `ready` informa os parametros operacionais atuais do bridge: + - `session_id`: eco do `data.session_id` aceito para a chamada + - `sample_rate: 16000` + - `channels: 1` + - `frame_ms: 20` + - `bytes_per_frame: 640` +- no contrato atual, `ready` significa que o bridge esta pronto para receber os bytes de audio do cliente +- quando `agent_starts_conversation` esta habilitado, o agent mantem a entrada do usuario desativada no `RoomIO` desde antes do `StartSession` ate o fim da primeira mensagem; esse gate impede que fala ou backlog vindo da URA alcance VAD/STT durante o setup e a saudacao +- o gate e liberado no fim do primeiro turno do agent e tambem em falha do pipeline, finalizacao ou timeout de setup; chamadas em que o usuario inicia a conversa nao usam esse bloqueio +- `audioFormat` no `start` e opcional e hoje funciona como campo informativo/reservado +- enviar outro codec, sample rate ou numero de canais nesse campo nao reconfigura o bridge atualmente +- se houver necessidade de outro formato, homologar com a equipe de desenvolvimento antes da integracao + +Mensagens devolvidas pelo servidor: +- `ready` quando a sessao foi aceita e o bridge esta pronto para receber audio +- audio binario PCM16 durante a resposta do agent +- `stop` como mensagem terminal em qualquer encerramento do `WS /ws/agent` + +Recuperacao do participante LiveKit do agent: +- quando o participant identificado como agent desconecta e a chamada ainda nao terminou, o bridge faz redispatch do mesmo `AGENT_NAME` na mesma room +- quando o novo participant entra, o bridge reenvia o controle `client_audio_enabled` e passa a consumir o audio desse novo participant +- se o redispatch nao trouxer um novo agent dentro do timeout configurado, o bridge envia `stop_agent_runtime_unavailable` com `reason: "agent_disconnected"` + +Contrato de `stop`: +- toda mensagem terminal em `WS /ws/agent` usa: + +```json +{ + "type": "stop", + "data": {} +} +``` + +- bloqueio antes do `ready`: + +```json +{ + "type": "stop", + "data": { + "status": "stop_stt_unavailable", + "reason": "resource_unhealthy", + "resource": "stt", + "failed_resources": ["stt"], + "phase": "pre_ready" + } +} +``` + +- falha de recurso durante a sessao: + +```json +{ + "type": "stop", + "data": { + "status": "stop_agent_backend_unavailable", + "reason": "resource_unhealthy", + "resource": "agent_backend", + "failed_resources": ["agent_backend"], + "phase": "in_session" + } +} +``` + +- fim normal da chamada: + +```json +{ + "type": "stop", + "data": { + "status": "stop_resolvido_e_finalizado", + "reason": "stage_done", + "phase": "in_session" + } +} +``` + +- falha terminal durante a sessao: + +```json +{ + "type": "stop", + "data": { + "status": "stop_bridge_failed", + "reason": "bridge_failed", + "resource": "bridge", + "phase": "in_session" + } +} +``` + +Status terminais atualmente usados em `WS /ws/agent`: +- `stop_capacity_tia` +- `stop_agent_runtime_unavailable` +- `stop_agent_backend_unavailable` +- `stop_stt_unavailable` +- `stop_tts_unavailable` +- `stop_resolvido_e_finalizado` +- `stop_nao_resolvido` +- `stop_falha_sistema` +- `stop_no_match` +- `stop_outro_assunto` +- `stop_silencio_longo` +- `stop_bridge_failed` + +Regra de interpretacao: +- `phase: "pre_ready"` indica bloqueio antes de a sessao aceitar audio +- `phase: "in_session"` indica falha terminal ou encerramento depois do `ready` +- os status de recurso podem aparecer nas duas fases, dependendo de quando a indisponibilidade foi detectada + +Campos opcionais de testes e homologacao: +- `audioFormat` pode ser enviado no `start`, mas hoje nao altera o pipeline de audio +- `callConfig` e opcional e existe para testes, smoke test local e homologacao tecnica +- para integracao produtiva com cliente externo, o contrato pode omitir `callConfig` + +Configuracao de chamada para teste de carga com STT Sofya, agente fake e TTS xAI: + +```json +{ + "debugEvents": true, + "callConfig": { + "agentBackend": "remote_ws_fake", + "agentFake": { + "delayMs": 2500, + "responses": "Primeira resposta simulada com tamanho intermediario;Segunda resposta simulada com tamanho intermediario;Resposta final encerrando o atendimento simulado" + }, + "stt": { + "provider": "internal_http", + "disableVosk": true + }, + "tts": { + "provider": "xai" + } + } +} +``` + +Regras desse modo: +- `agentFake.responses` contem de 2 a 10 frases separadas por `;`; espacos laterais sao removidos e cada frase deve ter de 40 a 180 caracteres +- `agentFake.delayMs` e aplicado antes de cada resposta, usa `2500` por padrao e aceita valores de `0` a `180000` +- a saudacao inicial continua sendo o `intro` normal; cada fala posterior reconhecida pelo STT consome uma resposta fake, sem usar o texto transcrito para escolher a resposta +- respostas anteriores as duas ultimas usam `ARGUMENTATION`, a penultima usa `FORMALIZATION` e a ultima usa `DONE`; chamadas posteriores ao fim recebem novamente o mesmo resultado terminal +- a sequencia e isolada por sessao +- `debugEvents=true` publica pelo websocket somente eventos tecnicos e valores agregados de fala, STT Sofya, TTS xAI e sheds do Bridge; o conteudo integral da conversa nao e incluido nas metricas +- o endpoint nao possui uma autorizacao adicional especifica para o fake: qualquer cliente ja autorizado a abrir `/ws/agent` pode selecionar `remote_ws_fake` por `callConfig` + +### `WS /ws/text` + +Fluxo de texto sem audio para testes e integracao basica. + +### `WS /ws/text_stream` + +Fluxo textual com resposta em streaming. + +## Fluxo ponta a ponta atual + +1. Cliente abre websocket em `/ws/agent` +2. Bridge recebe `start` e faz parse do contexto da chamada +3. Bridge valida capacidade e readiness dos recursos +4. Bridge cria room/token e envia `ready` +5. Cliente envia audio para o bridge +6. Bridge publica audio no LiveKit +7. Agent recebe audio, STT produz texto +8. Agent chama o backend de IA configurado +9. Agent usa TTS para vocalizar resposta +10. Bridge devolve audio ao cliente +11. Agent sinaliza `DONE` ou ocorre erro terminal +12. Bridge envia `stop` e fecha a chamada + +## Dependencias externas relevantes + +- LiveKit +- STT interno HTTP +- Vosk +- ElevenLabs +- modelo LLM da pipeline +- API de fidelizacao + +## Timeline de chamada + +O projeto agora gera uma timeline estruturada por chamada em formato `jsonl`. + +Configuracao: +- `CALL_TIMELINE_ENABLED=1` ativa a escrita da timeline +- `CALL_TIMELINE_CONSOLE=1` replica os eventos tambem no stdout +- `CALL_TIMELINE_DIR=./timeline` define o diretorio dos arquivos +- `CALL_TIMELINE_QUEUE_MAX=10000` limita eventos pendentes para escrita assincrona +- `CALL_LOG_QUEUE_MAX=20000` limita registros pendentes dos arquivos por chamada +- `ASYNC_IO_WARNING_INTERVAL_S=60` limita a frequencia dos avisos de descarte/erro + +A timeline e o arquivo de log por chamada sao gravados por threads de fundo para +nao executar I/O de disco no event loop de audio. Quando uma fila atinge o limite, +o evento e descartado em vez de bloquear o audio e um warning rate-limited registra +o total acumulado. Os limites aceitos ficam entre `1` e `1000000`. + +Os spans estruturados usam `BatchSpanProcessor`: `span.end()` apenas enfileira o +span, enquanto o envio OTLP acontece em lote fora da thread chamadora. O provider +faz flush e shutdown no encerramento normal do processo. + +Fake remoto: +- `remote_ws_fake` usa um fake interno em memoria e nao depende de `REMOTE_AGENT_WS_FAKE_URL` +- o endpoint `ws://127.0.0.1:8000/fake-agent/ws` continua disponivel apenas para testes manuais do contrato websocket + +Logs de websocket remoto: +- o adapter `remote_ws` agora registra no stdout eventos `REMOTE_AGENT_WS_CONNECT_OPEN`, `REMOTE_AGENT_WS_CONNECT_OK`, `REMOTE_AGENT_WS_REQUEST`, `REMOTE_AGENT_WS_RESPONSE` e falhas `*_FAIL` +- os logs incluem `instance` (hostname/pod), `agent`, `url`, `host`, `stage`, `protocol` e metadados do payload para facilitar comparar pods com erro de DNS/host + +Mock de encerramento apos primeiro audio: +- `MOCK_STOP_AFTER_FIRST_AUDIO_ENABLED=1` faz o bridge enviar o `stop` terminal configurado para o `reason` depois que o primeiro audio real do agente for entregue ao cliente e a saida ficar em silencio pelo intervalo configurado +- `MOCK_STOP_AFTER_FIRST_AUDIO_SILENCE_S=0.35` controla quanto tempo de silencio o bridge espera antes de disparar o `stop` +- `MOCK_STOP_AFTER_FIRST_AUDIO_REASON=stage_done` define o `reason` exato enviado no `stop` + +Status terminais configuraveis por env: +- `FINAL_STOP_STATUS_RESOLVED=stop_resolvido_e_finalizado` +- `FINAL_STOP_STATUS_UNRESOLVED=stop_nao_resolvido` +- `FINAL_STOP_STATUS_OTHER_SUBJECT=stop_outro_assunto` +- `FINAL_STOP_STATUS_LONG_SILENCE=stop_silencio_longo` +- `FINAL_STOP_DEFAULT_KIND=resolved` define o fallback quando o `reason` nao bater em nenhum valor configurado +- `FINAL_STOP_REASON_RESOLVED=stage_done` +- `FINAL_STOP_REASON_UNRESOLVED=nao_resolvido` +- `FINAL_STOP_REASON_OTHER_SUBJECT=outro_assunto` +- `FINAL_STOP_REASON_LONG_SILENCE=no_user_response` +- o mapeamento agora usa comparacao exata do `reason`, sem aliases + +Espera de resposta do backend remoto: +- `REMOTE_AGENT_INFLIGHT_WAIT_INTERVAL_S=12` +- `REMOTE_AGENT_INFLIGHT_WAIT_SHORT_AUDIO_DIR=src/app/livekit/assets/comfort/short` +- `REMOTE_AGENT_INFLIGHT_WAIT_LONG_AUDIO_DIR=src/app/livekit/assets/comfort/long` +- o primeiro audio de conforto usa um WAV aleatorio da pasta `short`; a partir do segundo, usa WAVs aleatorios da pasta `long` +- o intervalo dos audios de conforto conta a partir do fim do audio anterior +- `REMOTE_AGENT_INFLIGHT_WAIT_TIMEOUT_S=180` +- `REMOTE_AGENT_INFLIGHT_WAIT_MAX_NOTICES=0` (`0` mantém os confortos sem limite de quantidade até o timeout) +- `REMOTE_AGENT_INFLIGHT_WAIT_TEXT=Um momento, ainda estou consultando para te ajudar.` +- este timeout e tecnico: limita quanto tempo o runtime aguarda o backend remoto processar um turno +- ele e diferente do timeout de resposta do cliente configurado por `metadata.wait_timeout_seconds` +- mensagens `feedback`, tanto top-level quanto `result.type: "feedback"`, usam a espera tecnica do backend, mas nao contam silencio do cliente nem disparam `metadata.wait_retry_messages` +- em um turno normal, depois que o agent termina de falar e o cliente responde, o fim de fala detectado pelo VAD pode antecipar o primeiro conforto curto enquanto o STT conclui a transcricao +- o conforto antecipado do VAD nao e agendado enquanto o agent esta falando; se outra fala do agent ocupar o `say_lock` depois do agendamento, o conforto tambem e abortado para nao sair colado ao fim da mensagem +- se o cliente interromper uma fala interrompivel do agent, o VAD nao enfileira conforto atras dessa fala; o novo pipeline ainda pode usar os confortos normais caso o processamento demore +- se o cliente falar por mais de `800ms` enquanto o backend ja processa outro turno e o agent esta em silencio, o conforto curto especulativo do VAD e suprimido e a transcricao abre uma interrupcao diferida +- cada interrupcao diferida toca `src/app/livekit/assets/comfort/interruption/01.wav`, correspondente a "Ouvi o que voce falou, um instante", e invalida a resposta anterior quando ela chegar +- no pipeline substituto, o conforto dedicado conta como o primeiro aviso ja consumido: o audio `short` normal nao toca logo depois; se o processamento continuar, o proximo aviso permitido e `long` e respeita o intervalo configurado +- o estado de interrupcao e rearmado antes de iniciar o pipeline substituto: cada nova fala valida durante o novo processamento repete o conforto dedicado e substitui novamente a resposta em voo, sem descartar a nova transcricao nem deixar silencio na chamada +- falas de ate `800ms` durante processamento sao tratadas como ruido ou backchannel curto e nao abrem interrupcao diferida +- os logs principais desse fluxo sao `pre_backend_wait_notice_skipped` (`agent_speaking` ou `backend_processing`), `deferred_interruption_accumulated`, `deferred_interruption_dispatched` e os estagios TTS `AGENT_BACKEND_WAIT`/`INTERRUPTION_COMFORT` + +Recuperacao do participant LiveKit do agent: +- `AGENT_RECONNECT_ENABLED=1` +- `AGENT_RECONNECT_MAX_ATTEMPTS=1` +- `AGENT_RECONNECT_TIMEOUT_S=10` + +Protecao de reenvio TTS: +- `TTS_EMPTY_FRAME_RETRY_TIMEOUT_S=3` interrompe uma tentativa de TTS sem primeiro frame de audio ou com gap entre frames no meio da fala apos o timeout e reenvia o mesmo texto uma vez +- antes do reenvio, toca `src/app/livekit/assets/comfort/fails/tts_fail_recovery.wav` +- quando o reenvio tem sucesso, registra um unico `envio msg` com `http_cod_status=200` e `erro_msg=TTS_regerado` + +STT fake: +- `STT_PROVIDER=fake` dispensa `STT_URL` +- usa `FAKE_STT_TRANSCRIPTS` como fila de falas por turno +- `FAKE_STT_MODE=repeat_last|cycle` controla o comportamento ao consumir a fila + +TTS fake: +- `TTS_PROVIDER=fake` dispensa credenciais externas +- gera audio PCM sintetico local para smoke tests do pipeline + +Formato: +- um arquivo por chamada +- nome do arquivo baseado no `room` +- eventos do `bridge` e do `agent` entram no mesmo arquivo +- cada linha contem: + - `ts` + - `t_rel_ms` + - `component` + - `event` + - `protocol` + - `room` + - campos especificos do evento + +Eventos relevantes: +- `bridge`: + - `call_start` + - `ready_sent` + - `dispatch_started` + - `livekit_room_connected` + - `agent_join` + - `client_audio_first_frame_received` + - `client_audio_first_frame_published` + - `client_audio_enabled` + - `done_packet_received` + - `stop_sent` + - `call_end` +- `agent`: + - `call_start` + - `room_enter` + - `session_start_requested` + - `stt_recognize_started` + - `stt_http_completed` + - `user_transcript_final` + - `pipeline_run_started` + - `pipeline_run_completed` + - `remote_agent_request` + - `remote_agent_response` + - `tts_stage_started` + - `tts_stage_result` + - `interrupt_marked` + - `finalize_started` + - `finalize_completed` + +Uso pratico: +1. iniciar a chamada normalmente +2. identificar no terminal o `room` ou o `protocol` +3. abrir o arquivo correspondente em `./timeline` +4. ler os eventos em ordem de `t_rel_ms` + +## Ponto de atencao + +Durante o refactor, este documento deve continuar descrevendo: +- comportamento publico +- contrato do websocket +- responsabilidades de cada camada + +Na versao final, ele deve evoluir para a documentacao oficial da API. diff --git a/docs/refactor-log.md b/docs/refactor-log.md new file mode 100644 index 0000000..00884c6 --- /dev/null +++ b/docs/refactor-log.md @@ -0,0 +1,612 @@ +# Refactor Log + +Este arquivo registra o que mudou a cada etapa do refactor incremental. + +Nota: +- as entradas `000` a `010` foram escritas antes da consolidacao da estrutura atual do repositorio +- por isso, varias delas ainda referenciam caminhos antigos sem o prefixo `src/` +- a partir da entrada `011`, os caminhos refletem o layout atual da raiz + `src/` + +## Como preencher + +Para cada mudanca relevante, registrar: +- data +- objetivo +- arquivos alterados +- comportamento preservado +- comportamento alterado +- risco conhecido +- validacao executada +- proximo passo + +## Entrada 000 - Baseline documental + +- Data: 2026-03-27 +- Objetivo: criar uma base de documentacao viva para acompanhar o refactor incremental +- Arquivos alterados: + - `README.md` + - `docs/README.md` + - `docs/refactor-plan.md` + - `docs/refactor-log.md` + - `docs/api-overview.md` +- Comportamento preservado: nenhum codigo de execucao foi alterado +- Comportamento alterado: nenhum +- Risco conhecido: a documentacao precisa ser mantida junto das mudancas, senao perde valor +- Validacao executada: revisao manual do conteudo criado +- Proximo passo: iniciar Fase 1 com extracao dos adapters de pipeline, speech, bridge e export + +## Entrada 001 - Fase 1 / adapters extraidos + +- Data: 2026-03-27 +- Objetivo: extrair boundaries de pipeline, speech, bridge e export sem mudar o comportamento publico da API +- Arquivos alterados: + - `app/livekit/main.py` + - `app/livekit/adapters/pipeline_adapter.py` + - `app/livekit/adapters/speech_service.py` + - `app/livekit/adapters/bridge_gateway.py` + - `app/livekit/adapters/export_service.py` + - `docs/refactor-log.md` +- Comportamento preservado: + - contrato do websocket do bridge + - fluxo STT -> pipeline -> TTS + - notificacao `DONE` para o bridge + - exportacao final de sessao +- Comportamento alterado: + - nenhum comportamento publico planejado + - `app/livekit/main.py` passa a delegar responsabilidades para adapters +- Risco conhecido: + - como a extracao preserva a logica inline, ainda existe acoplamento forte no runtime + - faltam testes automatizados de regressao para interrupcao, idle nudge e finalizacao +- Validacao executada: + - revisao manual do diff + - validacao sintatica prevista apos a extracao +- Proximo passo: + - reduzir `nonlocal` no runtime com um `CallRuntime` explicito + - preparar o terreno para policies e scheduler unificados + +## Entrada 002 - Fase 2 / runtime explicito + +- Data: 2026-03-27 +- Objetivo: encapsular o fluxo da chamada em um `CallRuntime` com `CallState` explicito, reduzindo estado implícito e `nonlocal` em `app/livekit/main.py` +- Arquivos alterados: + - `app/livekit/main.py` + - `app/livekit/runtime/state.py` + - `app/livekit/runtime/call_runtime.py` + - `docs/refactor-log.md` + - `docs/api-overview.md` +- Comportamento preservado: + - contrato do websocket do bridge + - fluxo STT -> pipeline -> TTS + - idle nudge, interrupcao e finalizacao continuam com a mesma logica operacional + - exportacao e notificacao `DONE` continuam sendo disparadas pelo agent +- Comportamento alterado: + - `app/livekit/main.py` passa a ser majoritariamente wiring do session e construcao do runtime + - o estado mutavel da chamada fica concentrado em `CallState` +- Risco conhecido: + - as regras ainda nao foram transformadas em policies puras; a logica segue acoplada ao runtime, apenas mais organizada + - ainda faltam testes automatizados para cenarios concorrentes de fala, interrupcao e fechamento +- Validacao executada: + - revisao manual do diff + - `PYTHONDONTWRITEBYTECODE=1 python3 -m py_compile app/livekit/main.py app/livekit/adapters/bridge_gateway.py app/livekit/adapters/export_service.py app/livekit/adapters/pipeline_adapter.py app/livekit/adapters/speech_service.py app/livekit/runtime/state.py app/livekit/runtime/call_runtime.py` +- Proximo passo: + - introduzir command/execution boundaries no runtime + - preparar a migracao de interrupcao, idle e finalize para policies + scheduler + +## Entrada 003 - Fase 3 / commands e executor + +- Data: 2026-03-29 +- Objetivo: separar efeitos colaterais do `CallRuntime` por meio de comandos tipados e um executor dedicado +- Arquivos alterados: + - `app/livekit/main.py` + - `app/livekit/runtime/commands.py` + - `app/livekit/runtime/command_executor.py` + - `app/livekit/runtime/call_runtime.py` + - `docs/refactor-log.md` + - `docs/api-overview.md` +- Comportamento preservado: + - contrato do websocket do bridge + - fluxo STT -> pipeline -> TTS + - sequencia de idle nudge, interrupcao e finalizacao + - integracao com LiveKit, pipeline e export sem troca de contrato +- Comportamento alterado: + - `CallRuntime` deixa de chamar diretamente bridge/export/speech/pipeline para os principais side effects + - side effects passam a trafegar por comandos (`commands.py`) executados por `RuntimeCommandExecutor` +- Risco conhecido: + - ainda existe conhecimento de regras dentro de `CallRuntime`; a extracao atual isola efeitos, mas nao transforma as regras em policies puras + - `agent._ready`, `agent._run_lock` e coordenacao de fila ainda pertencem ao runtime +- Validacao executada: + - revisao manual do diff + - validacao sintatica com `python3 -c 'from pathlib import Path; paths = [...]; [compile(Path(p).read_text(), p, "exec") for p in paths]'` +- Proximo passo: + - extrair policies de interrupcao, idle e finalizacao + - introduzir scheduler unico para timers e reduzir logica condicional no runtime + +## Entrada 004 - Fase 4 / policies e scheduler + +- Data: 2026-03-29 +- Objetivo: mover regras de interrupcao, idle e finalize-once para policies dedicadas e centralizar timers em um scheduler unico +- Arquivos alterados: + - `app/livekit/main.py` + - `app/livekit/policies/interrupt_policy.py` + - `app/livekit/policies/idle_policy.py` + - `app/livekit/policies/finalization_policy.py` + - `app/livekit/runtime/scheduler.py` + - `app/livekit/runtime/state.py` + - `app/livekit/runtime/call_runtime.py` + - `docs/refactor-log.md` + - `docs/api-overview.md` +- Comportamento preservado: + - contrato do websocket do bridge + - fluxo STT -> pipeline -> TTS + - timers de idle nudge e idle close continuam ativos + - finalizacao por `DONE`, `room_empty`, `shutdown_callback` e `no_user_response` continua existindo +- Comportamento alterado: + - `InterruptPolicy` passa a decidir interrupcao por stage e filtro de backchannel + - `IdlePolicy` passa a concentrar regras de nudge/close + - `FinalizationPolicy` passa a concentrar regras de finalize-once e room-empty + - `TimerScheduler` passa a concentrar arm/cancel/token dos timers + - `CallState` deixa de carregar estado interno de timers +- Risco conhecido: + - o `CallRuntime` ainda conhece a ordem operacional completa da chamada; as policies reduzem acoplamento, mas ainda nao existe reducer/event engine + - `RuntimeConfig` ainda carrega campos hoje parcialmente sobrepostos pelas policies; isso pode ser simplificado em um corte futuro +- Validacao executada: + - revisao manual do diff + - validacao sintatica com `python3 -c 'from pathlib import Path; paths = [...]; [compile(Path(p).read_text(), p, "exec") for p in paths]'` +- Proximo passo: + - avaliar se vale introduzir eventos/comandos mais declarativos no runtime ou parar aqui e estabilizar + - se seguir, o proximo salto natural e um reducer/event engine ou a evolucao da integracao de IA (`llm_node` ou agente remoto) + +## Entrada 005 - Estabilizacao / testes unitarios do runtime + +- Data: 2026-03-29 +- Objetivo: adicionar cobertura automatizada para os cenarios mais criticos do runtime sem depender de LiveKit real +- Arquivos alterados: + - `tests/__init__.py` + - `tests/livekit/__init__.py` + - `tests/livekit/test_runtime.py` + - `docs/refactor-log.md` +- Comportamento preservado: + - nenhum contrato publico da API foi alterado + - nenhum fluxo de audio ou websocket foi modificado +- Comportamento alterado: + - o repositorio passa a ter uma suite unitária para policies, scheduler, command executor e cenarios críticos do `CallRuntime` +- Risco conhecido: + - os testes ainda usam doubles/fakes e nao substituem validacao integrada com LiveKit real + - ainda faltam cenarios mais completos de timer real, callback de room e speech handle real +- Validacao executada: + - `python3 -m unittest tests.livekit.test_runtime -v` + - `python3 -m unittest discover -s tests -v` +- Proximo passo: + - ampliar cobertura para speech handle real e integracao de callbacks com objetos LiveKit reais, se isso passar a ser area de regressao + - ou encerrar a fase de estabilizacao e voltar a discutir a evolucao da camada de IA + +## Entrada 006 - Backend remoto via websocket + +- Data: 2026-03-29 +- Objetivo: permitir trocar a pipeline local por um agent remoto via websocket, preservando o fluxo STT -> IA -> TTS e o runtime atual +- Arquivos alterados: + - `app/livekit/main.py` + - `app/livekit/adapters/agent_backend.py` + - `app/livekit/adapters/backend_factory.py` + - `app/livekit/adapters/pipeline_adapter.py` + - `app/livekit/adapters/remote_agent_ws_adapter.py` + - `app/livekit/runtime/command_executor.py` + - `requirements.txt` + - `tests/livekit/test_remote_agent_ws_adapter.py` + - `docs/refactor-log.md` + - `docs/api-overview.md` +- Comportamento preservado: + - contrato do websocket do bridge + - fluxo de audio com LiveKit e TTS no agent local + - runtime de interrupcao, idle nudge e finalizacao + - backend `langgraph` segue como default +- Comportamento alterado: + - o backend da camada de IA passa a ser selecionavel por `AGENT_BACKEND` + - quando `AGENT_BACKEND=remote_ws`, o agent envia cada turno transcrito para um websocket remoto e vocaliza a resposta retornada + - a finalizacao pode opcionalmente buscar um `result` remoto via mensagem `type=end` + - o bridge agora repassa `agent`, `RouterCallKeyDay`, `RouterCallKey`, `ANI`, `GSM` e `ID_FATURA` para o agent quando esses campos vierem no metadata do cliente + - o adapter remoto roteia a chamada para endpoints diferentes de acordo com `agent` (`conta`, `oferta`, `cobranca`) + - o agent `conta` passou a usar contrato proprio em websocket com `action/payload` na entrada e `result.content` na resposta +- Risco conhecido: + - o contrato do websocket remoto ainda e interno e precisa ser homologado com o agent externo real + - o adapter atual usa conexao websocket por turno, nao uma sessao persistente + - se o agent remoto nao devolver `stage`, a politica local dependera do ultimo stage conhecido ou do default configurado + - o campo `timestamp` e gerado localmente no momento de cada turno; se o integrador exigir outro formato, isso ainda precisa ser alinhado +- Validacao executada: + - testes unitarios do adapter remoto e factory + - execucao da suite `unittest` +- Proximo passo: + - homologar o contrato do websocket remoto com payload/resposta reais + - decidir se a conexao remota deve continuar por turno ou evoluir para sessao persistente + +## Entrada 007 - Cliente web de voz e call_config por chamada + +- Data: 2026-03-30 +- Objetivo: disponibilizar um cliente web para testar o fluxo de voz pelo navegador e permitir overrides de `STT`, `TTS` e `AGENT` por chamada +- Arquivos alterados: + - `app/ws_gateway/main.py` + - `app/ws_gateway/voice_client.html` + - `app/ws_gateway/call_config.py` + - `app/livekit/main.py` + - `app/livekit/call_config.py` + - `app/livekit/adapters/backend_factory.py` + - `tests/config/test_call_config.py` + - `docs/api-overview.md` + - `docs/refactor-log.md` +- Comportamento preservado: + - fluxo principal de audio via `/ws/agent` + - bridge continua recebendo `start`, audio binario e encerrando com `stop` + - defaults de `STT`, `TTS` e backend continuam vindo de env quando nao houver override +- Comportamento alterado: + - `/voice-client` agora serve um cliente web para capturar microfone e ouvir o audio retornado + - o `start` pode carregar `call_config` com overrides por chamada + - o agent agora aplica overrides de backend, STT e TTS para a sessao corrente +- Risco conhecido: + - o cliente web usa `ScriptProcessorNode`, que e suficiente para homologacao mas nao e a opcao mais moderna da Web Audio API + - os providers dinamicos suportados ainda sao os que ja existem no projeto (`internal_http` e `elevenlabs`) +- Validacao executada: + - testes puros de `call_config` + - execucao da suite `unittest` +- Proximo passo: + - se o cliente web passar a ser usado em rotina de homologacao, vale migrar a captura/playback para `AudioWorklet` + - se surgirem novos providers de STT/TTS, plugar nas factories por chamada + +## Entrada 008 - Timeline estruturada por chamada + +- Data: 2026-03-30 +- Objetivo: criar observabilidade ponta a ponta para entender o caminho `bridge -> livekit -> stt -> backend -> tts -> stop` por chamada +- Arquivos alterados: + - `app/utils/call_timeline.py` + - `app/ws_gateway/main.py` + - `app/livekit/main.py` + - `app/livekit/runtime/call_runtime.py` + - `app/livekit/adapters/backend_factory.py` + - `app/livekit/adapters/pipeline_adapter.py` + - `app/livekit/adapters/remote_agent_ws_adapter.py` + - `app/livekit/adapters/bridge_gateway.py` + - `app/providers/stt_internal_livekit.py` + - `tests/utils/test_call_timeline.py` + - `docs/api-overview.md` + - `docs/refactor-log.md` +- Comportamento preservado: + - contrato publico de `WS /ws/agent` + - fluxo de audio ja existente entre cliente, bridge, LiveKit e agent + - backends de IA, STT e TTS seguem com a mesma responsabilidade funcional +- Comportamento alterado: + - bridge e agent agora escrevem uma timeline estruturada em `jsonl` compartilhada por chamada + - a timeline usa o mesmo `timeline_id` e o mesmo `origin_unix_ms` entre os dois processos para manter a ordem relativa dos eventos + - o bridge passou a registrar eventos como `ready_sent`, `dispatch_started`, `agent_join`, `client_audio_enabled`, `done_packet_received` e `call_end` + - o agent passou a registrar eventos como `user_transcript_final`, `pipeline_run_started`, `pipeline_run_completed`, `tts_stage_started`, `interrupt_marked`, `finalize_started` e `finalize_completed` + - o provider de STT agora registra etapas de reconhecimento (`stt_recognize_started`, `stt_vosk_completed`, `stt_http_completed`) + - o backend remoto por websocket agora registra `remote_agent_request` e `remote_agent_response` +- Risco conhecido: + - a timeline registra payloads de request do backend remoto, o que aumenta a verbosidade e pode expor dados sensiveis em ambiente de debug + - o arquivo `jsonl` e compartilhado entre dois processos locais; o uso atual com `flock` e suficiente para dev/homologacao, mas nao substitui observabilidade centralizada +- Validacao executada: + - `py_compile` dos arquivos alterados + - teste unitario novo de timeline + - execucao completa da suite `unittest` +- Proximo passo: + - decidir se a timeline deve continuar sempre habilitada em dev ou ficar atras de flag por ambiente + - se a homologacao exigir, adicionar visualizador simples da timeline no cliente web + +## Entrada 009 - Fake remoto para homologacao via UI + +- Data: 2026-03-30 +- Objetivo: permitir testar o fluxo `STT -> remote_ws -> TTS` pela UI mesmo sem o agent remoto real estar de pe +- Arquivos alterados: + - `app/ws_gateway/fake_remote_agent.py` + - `app/ws_gateway/main.py` + - `app/ws_gateway/voice_client.html` + - `app/livekit/adapters/backend_factory.py` + - `tests/ws_gateway/test_fake_remote_agent.py` + - `tests/livekit/test_remote_agent_ws_adapter.py` + - `docs/api-overview.md` + - `docs/refactor-log.md` +- Comportamento preservado: + - backend `remote_ws` continua exigindo URL real quando selecionado + - o contrato do adapter remoto segue o mesmo +- Comportamento alterado: + - o `bridge` agora expoe `WS /fake-agent/ws` + - a UI ganhou a opcao `remote_ws_fake` + - quando `remote_ws_fake` e selecionado, o agent local aponta para `REMOTE_AGENT_WS_FAKE_URL` ou usa por padrao `ws://127.0.0.1:8000/fake-agent/ws` + - o fake responde nos contratos `conta` e generico, com progressao simples de stages e encerramento por palavras-chave como `encerrar`, `obrigado` e `tchau` +- Risco conhecido: + - o fake nao simula comportamento de negocio real; ele serve apenas para homologacao tecnica do fluxo de voz + - o fake e stateless por conexao, entao a progressao depende do `stage` enviado pelo agent local +- Validacao executada: + - testes unitarios do fake remoto + - testes do backend fake no adapter remoto + - execucao completa da suite `unittest` +- Proximo passo: + - se a homologacao pedir cenarios mais realistas, adicionar scripts por agent (`conta`, `oferta`, `cobranca`) com respostas configuraveis + +## Template de nova entrada + +- Data: +- Objetivo: +- Arquivos alterados: +- Comportamento preservado: +- Comportamento alterado: +- Risco conhecido: +- Validacao executada: +- Proximo passo: + +## Entrada 010 - Correcao do roteamento `remote_ws_fake` + +- Data: 2026-03-30 +- Objetivo: garantir que a selecao `remote_ws_fake` na UI nao seja sobrescrita pela URL real configurada em `REMOTE_AGENT_WS_URL` +- Arquivos alterados: + - `app/livekit/adapters/backend_factory.py` + - `app/livekit/adapters/remote_agent_ws_adapter.py` + - `tests/livekit/test_remote_agent_ws_adapter.py` + - `docs/api-overview.md` + - `docs/refactor-log.md` +- Comportamento preservado: + - `remote_ws` continua usando `REMOTE_AGENT_WS_URL` + - o adapter remoto continua sendo o mesmo para backend real e fake +- Comportamento alterado: + - `remote_ws_fake` agora prioriza sempre `REMOTE_AGENT_WS_FAKE_URL`, mesmo quando `REMOTE_AGENT_WS_URL` existe no ambiente + - a timeline do adapter remoto agora diferencia `backend=remote_ws_fake` de `backend=remote_ws` +- Risco conhecido: + - aliases como `fake_remote_ws` e `ws_fake` continuam sendo tratados como `remote_ws_fake`, mas o label emitido na timeline fica canonico como `remote_ws_fake` +- Validacao executada: + - `python3 -m unittest tests.livekit.test_remote_agent_ws_adapter -v` + - `python3 -m py_compile app/livekit/adapters/backend_factory.py app/livekit/adapters/remote_agent_ws_adapter.py tests/livekit/test_remote_agent_ws_adapter.py` +- Proximo passo: + - validar na chamada real da UI que o timeline e o request usam `ws://127.0.0.1:8000/fake-agent/ws` + +## Entrada 011 - Reorganizacao da raiz e remocao de boilerplate + +- Data: 2026-04-06 +- Objetivo: consolidar a estrutura real do projeto na raiz do repositorio, mantendo `src/app` e `src/agent` como codigo-fonte canonico +- Arquivos alterados: + - `README.md` + - `makefile` + - `Dockerfile` + - `pytest.ini` + - `.env.example` + - `.gitignore` + - `.dockerignore` + - `requirements.txt` + - `livekit.yaml` + - `docs/*` + - `tests/*` + - `k8s/deployment.yaml` + - remocao de `pyproject.toml`, `uv.lock`, `README copy.md`, `configs/config.example.yaml` e scripts herdados do boilerplate +- Comportamento preservado: + - `src/app` e `src/agent` permanecem como raiz do codigo de execucao + - o bridge FastAPI continua em `src/app/ws_gateway/main.py` + - o projeto continua usando `PYTHONPATH=src` +- Comportamento alterado: + - a raiz do repositorio passa a refletir o projeto real, e nao mais o boilerplate original + - `requirements.txt` passa a ser a fonte principal de dependencias + - artefatos de runtime (`logs`, `timeline`, `__pycache__`) deixam de ser versionados +- Risco conhecido: + - arquivos de infra legados fora do fluxo principal ainda poderiam carregar suposicoes antigas do boilerplate + - a migracao para `requirements.txt` simplifica a operacao, mas remove o lockfile anterior +- Validacao executada: + - `python3 -m compileall src tests` + - `python3 -m pytest tests/utils/test_call_timeline.py -q` +- Proximo passo: + - revisar a infra auxiliar restante e reduzir ruido operacional na raiz + +## Entrada 012 - Simplificacao do compose local de observabilidade + +- Data: 2026-04-06 +- Objetivo: transformar o `docker-compose.yml` em uma stack minima e coerente para Langfuse local +- Arquivos alterados: + - `docker-compose.yml` +- Comportamento preservado: + - a stack opcional de Langfuse continua disponivel para uso local + - a aplicacao principal segue fora do compose, operada via `make` +- Comportamento alterado: + - remocao do servico `mongo` + - consolidacao apenas de `langfuse-web`, `langfuse-worker`, `postgres`, `redis`, `clickhouse` e `minio` + - reducao de duplicacao com anchors para configuracoes compartilhadas +- Risco conhecido: + - o compose continua sendo opcional e depende de configuracao explicita do projeto para apontar para Langfuse self-hosted +- Validacao executada: + - validacao sintatica do YAML com parser local +- Proximo passo: + - se a equipe mantiver uso frequente, considerar alvos dedicados no `makefile` para subir e derrubar a stack + +## Entrada 013 - Provider TTS opcional desacoplado do import global + +- Data: 2026-04-06 +- Objetivo: impedir que a falta do SDK do ElevenLabs quebrasse o import do modulo inteiro de TTS +- Arquivos alterados: + - `src/app/providers/tts.py` + - `tests/providers/test_tts.py` +- Comportamento preservado: + - selecao de provider TTS por configuracao + - suporte aos providers ja existentes +- Comportamento alterado: + - o SDK do ElevenLabs deixa de ser carregado no topo do modulo e passa a ser resolvido sob demanda + - ausencia do SDK passa a gerar `missing_elevenlabs_sdk`, em vez de falha de import que derruba testes e providers nao relacionados +- Risco conhecido: + - o provider continua opcional, mas a disciplina de dependencias ainda segue concentrada em `requirements.txt` +- Validacao executada: + - `python3 -m pytest tests/providers/test_tts.py -q` + - `python3 -m pytest tests/adapters/test_azure_tts.py -q` +- Proximo passo: + - continuar removendo acoplamentos desnecessarios entre providers e entrypoints + +## Entrada 014 - Remocao de mocks e hardcodes dos pipelines de texto + +- Data: 2026-04-06 +- Objetivo: tirar codigo de demonstracao do caminho produtivo dos endpoints de texto +- Arquivos alterados: + - `src/app/services/session_context.py` + - `src/app/services/text_pipeline.py` + - `src/app/services/text_pipeline_stream.py` + - `src/app/ws_gateway/main.py` + - `tests/services/test_session_context.py` +- Comportamento preservado: + - os endpoints `/ws/text` e `/ws/text_stream` continuam aceitando a mesma sessao de entrada + - os pipelines continuam recebendo `mailing` e `intro` +- Comportamento alterado: + - remocao do uso de `app.models.mock` + - remocao de protocolo e dados fixos hardcoded nos pipelines + - introducao de extracao explicita de protocolo via `session_context` +- Risco conhecido: + - se existiam fluxos de dev que dependiam dos mocks antigos, eles passam a precisar de payloads reais +- Validacao executada: + - `python3 -m pytest -q` + - resultado observado na etapa: `36 passed` +- Proximo passo: + - consolidar contratos duplicados entre gateway e runtime + +## Entrada 015 - Unificacao de `call_config` + +- Data: 2026-04-06 +- Objetivo: eliminar duplicacao entre as implementacoes de `call_config` do gateway e do runtime LiveKit +- Arquivos alterados: + - `src/app/common/call_config.py` + - `src/app/ws_gateway/call_config.py` + - `src/app/livekit/call_config.py` + - `tests/config/test_call_config.py` +- Comportamento preservado: + - API publica de `build_call_config(...)` + - API publica de `normalize_call_config(...)` e resolucao dos campos auxiliares +- Comportamento alterado: + - a normalizacao passa a ter um nucleo compartilhado em `src/app/common/call_config.py` + - `ws_gateway` e `livekit` viram wrappers leves para o mesmo contrato +- Risco conhecido: + - qualquer evolucao futura de `call_config` passa a ter impacto compartilhado entre bridge e agent, o que e desejado, mas exige disciplina de compatibilidade +- Validacao executada: + - `python3 -m pytest tests/config/test_call_config.py -q` + - `python3 -m pytest -q` + - resultado observado na etapa: `36 passed` +- Proximo passo: + - iniciar a quebra incremental do monolito em `src/app/ws_gateway/main.py` + +## Entrada 016 - Extracao do parsing inicial da sessao no bridge + +- Data: 2026-04-06 +- Objetivo: remover do `ws_gateway/main.py` a logica de parsing da mensagem `start`, resolucao de mailing e montagem de `intro`/`nudge` +- Arquivos alterados: + - `src/app/ws_gateway/session_start.py` + - `src/app/ws_gateway/main.py` + - `tests/ws_gateway/test_session_start.py` +- Comportamento preservado: + - contrato de primeira mensagem `type=start` + - geracao de `mailing`, `intro` e `nudge` + - construcao do contexto do agente remoto a partir de metadata + mailing +- Comportamento alterado: + - criacao da dataclass `StartSessionContext` + - centralizacao de `parse_start_payload`, `recv_start_message` e `build_remote_agent_context` em modulo proprio + - `/ws/agent`, `/ws/text` e `/ws/text_stream` passam a consumir um contexto estruturado, e nao uma tuple solta +- Risco conhecido: + - o contrato de entrada continua dependente do payload do cliente; a extracao melhora testabilidade, mas nao redefine o protocolo +- Validacao executada: + - `python3 -m compileall src/app/ws_gateway src/app/services tests/ws_gateway` + - `python3 -m pytest tests/ws_gateway/test_session_start.py -q` + - `python3 -m pytest -q` + - resultado observado na etapa: `39 passed` +- Proximo passo: + - extrair o bootstrap da chamada para reduzir ainda mais a responsabilidade do endpoint + +## Entrada 017 - Extracao do bootstrap da chamada do bridge + +- Data: 2026-04-06 +- Objetivo: separar a preparacao da chamada do runtime do endpoint `/ws/agent` +- Arquivos alterados: + - `src/app/ws_gateway/session_bootstrap.py` + - `src/app/ws_gateway/main.py` + - `tests/ws_gateway/test_session_bootstrap.py` +- Comportamento preservado: + - geracao de token de acesso ao LiveKit + - montagem de `call_config`, `remote_agent_context`, `timeline` e `dispatch_metadata` + - fallbacks de protocolo e telefone +- Comportamento alterado: + - introducao da dataclass `BridgeSessionBootstrap` + - centralizacao de `room_name`, `identity`, `token`, `protocol`, `phone_number` e `dispatch_metadata` em um builder dedicado + - `main.py` passa a consumir um pacote pronto de bootstrap +- Risco conhecido: + - o bootstrap ainda depende de envs e factories externas; a extracao organiza responsabilidade, mas nao muda a politica de configuracao +- Validacao executada: + - `python3 -m compileall src/app/ws_gateway tests/ws_gateway` + - `python3 -m pytest tests/ws_gateway/test_session_bootstrap.py -q` + - `python3 -m pytest -q` + - resultado observado na etapa: `41 passed` +- Proximo passo: + - extrair o ciclo de vida da sessao LiveKit do endpoint + +## Entrada 018 - Extracao do lifecycle da sessao LiveKit no bridge + +- Data: 2026-04-06 +- Objetivo: remover do endpoint os handlers de participante, o processamento do pacote `DONE` e o watcher de encerramento +- Arquivos alterados: + - `src/app/ws_gateway/session_lifecycle.py` + - `src/app/ws_gateway/main.py` + - `tests/ws_gateway/test_session_lifecycle.py` +- Comportamento preservado: + - deteccao de entrada e saida do agente remoto + - processamento do pacote `agent.stage` com `DONE` + - envio de `stop` para o cliente ao final da chamada +- Comportamento alterado: + - criacao de `RoomLifecycleState` para concentrar `agent_participant` e `done_payload` + - extracao de `register_room_lifecycle_handlers(...)` + - extracao de `watch_call_done(...)` +- Risco conhecido: + - o fluxo ainda depende de coordenacao concorrente entre tasks do bridge; a extracao reduz tamanho do endpoint, mas nao muda o modelo de concorrencia +- Validacao executada: + - `python3 -m compileall src/app/ws_gateway tests/ws_gateway` + - `python3 -m pytest tests/ws_gateway/test_session_lifecycle.py -q` + - `python3 -m pytest -q` + - resultado observado na etapa: `43 passed` +- Proximo passo: + - extrair a camada de intro/sinalizacao do bridge + +## Entrada 019 - Extracao da intro TTS e da sinalizacao de audio no bridge + +- Data: 2026-04-07 +- Objetivo: isolar do endpoint o trecho responsavel por intro TTS, liberacao do audio do cliente, sinalizacao `client_audio_enabled` e bootstrap do streaming do agente +- Arquivos alterados: + - `src/app/ws_gateway/session_audio.py` + - `src/app/ws_gateway/main.py` + - `tests/ws_gateway/test_session_audio.py` +- Comportamento preservado: + - intro sintetizada antes da liberacao do audio do cliente + - emissao do controle `client_audio_enabled` para o agente + - inicializacao do worker que consome audio do agente quando ele fica pronto +- Comportamento alterado: + - extracao de `play_intro_to_client(...)` + - extracao de `notify_client_audio_enabled(...)` + - extracao de `stream_agent_audio_when_ready(...)` + - `main.py` passa a gerenciar explicitamente a task `t_control`, em vez de disparar a notificacao como fire-and-forget + - ajuste de typing para evitar import-time acidental de `audioop` em testes unitarios do modulo novo +- Risco conhecido: + - o caminho quente de audio continua centralizado em `main.py`; ainda faltam as primitivas de transporte para o arquivo deixar de ser monolitico +- Validacao executada: + - `python3 -m compileall src/app/ws_gateway tests/ws_gateway` + - `python3 -m pytest tests/ws_gateway/test_session_audio.py -q` + - `python3 -m pytest -q` + - resultado observado na etapa: `46 passed` +- Proximo passo: + - extrair as primitivas de transporte de audio e LiveKit (`ws_audio_receiver`, `publish_queue_to_livekit`, `connect_publish_livekit`, `stream_agent_audio_to_queue`) + +## Entrada 020 - Validacao do Dockerfile para CI/CD + +- Data: 2026-04-07 +- Objetivo: revisar o `Dockerfile` frente ao layout atual do projeto e corrigir o principal risco de compatibilidade para pipeline +- Arquivos alterados: + - `Dockerfile` +- Comportamento preservado: + - execucao do bridge via `uvicorn app.ws_gateway.main:app` + - exposicao da porta `8000` + - `PYTHONPATH=/app/src` + - healthcheck em `/health` +- Comportamento alterado: + - troca da base de `python:3.13-slim` para `python:3.12-slim` + - o ajuste foi necessario porque o projeto ainda depende de `audioop` em `src/app/ws_gateway/main.py` e `src/app/utils/background.py` +- Risco conhecido: + - o build real da imagem nao conseguiu ser concluido nesta maquina porque nem o daemon Docker nem a conexao efetiva do Podman estavam operacionais no momento da validacao + - portanto, a validacao foi estrutural e de coerencia do arquivo, nao um smoke test completo de container +- Validacao executada: + - revisao manual de `Dockerfile`, `.dockerignore`, `requirements.txt`, `makefile` e `k8s/deployment.yaml` + - tentativa de `docker build`, bloqueada por daemon indisponivel + - tentativa de `podman build`, bloqueada por conexao local com a VM do Podman +- Proximo passo: + - executar um build real da imagem assim que houver runtime de containers disponivel no host ou direto no pipeline diff --git a/docs/refactor-plan.md b/docs/refactor-plan.md new file mode 100644 index 0000000..a4337aa --- /dev/null +++ b/docs/refactor-plan.md @@ -0,0 +1,116 @@ +# Refactor Plan + +## Objetivo + +Fazer um refactor incremental da camada de runtime de voz sem quebrar: +- contrato do bridge websocket +- integracao com LiveKit +- pipeline atual de negocio +- comportamento de interrupcao, idle nudge e finalizacao + +## Problema atual + +Hoje o arquivo `app/livekit/main.py` concentra responsabilidades demais: +- wiring do AgentSession +- timers +- politica de interrupcao +- idle nudge +- finalizacao +- chamada da pipeline +- speak / wait_for_playout +- notificacao ao bridge + +Isso dificulta manutencao, teste e evolucao para alternativas futuras como: +- `llm_node` +- agente remoto via API +- politica de chamada mais previsivel + +## Direcao arquitetural + +Separar o runtime em camadas: +- adapters: wrappers dos componentes atuais (pipeline, speech, bridge, export) +- runtime: coordenacao da chamada +- domain: estado, eventos e comandos +- policies: regras puras de interrupcao, idle e finalizacao +- engine: reducer e scheduler + +## Principios + +- preservar comportamento antes de trocar mecanismo +- extrair boundaries antes de mudar a orquestracao +- isolar side effects +- tornar finalizacao idempotente +- introduzir estado explicito da chamada + +## Fases sugeridas + +### Fase 1: Boundaries + +Extrair sem mudar comportamento: +- `PipelineAdapter` +- `SpeechService` +- `BridgeGateway` +- `ExportService` + +Saida esperada: +- `app/livekit/main.py` menor +- regras ainda iguais as de hoje + +### Fase 2: Runtime explicito + +Introduzir: +- `CallState` +- `CallRuntime` +- comandos e eventos + +Saida esperada: +- menor uso de `nonlocal` +- caminhos de execucao mais faceis de seguir + +### Fase 3: Policies e scheduler + +Migrar: +- interrupcao +- idle nudge +- idle close +- finalize once + +Saida esperada: +- regras de chamada isoladas e testaveis + +### Fase 4: Evolucao de IA + +Avaliar depois da estabilizacao: +- `llm_node` +- agente remoto via API +- SSE / websocket para agente externo + +Saida esperada: +- STT / IA / TTS com fronteiras mais limpas + +## Fora de escopo inicial + +- reescrever bridge +- substituir pipeline de negocio +- trocar protocolo websocket externo +- mudar contrato de audio + +## Definition of done por fase + +### Fase 1 +- sem mudanca de contrato externo +- sem mudanca de fluxo de audio +- classes novas usadas pelo `main.py` + +### Fase 2 +- estado da chamada centralizado +- menos logica de coordenacao inline + +### Fase 3 +- timers centralizados +- finalizacao unica e previsivel +- regras de interrupcao sem duplicacao + +### Fase 4 +- nova integracao de IA desacoplada do fluxo manual atual +- comparacao controlada com comportamento anterior diff --git a/docs/regional/ARQUITETURA_TIA_XAI_REGIONAL.md b/docs/regional/ARQUITETURA_TIA_XAI_REGIONAL.md new file mode 100644 index 0000000..2744235 --- /dev/null +++ b/docs/regional/ARQUITETURA_TIA_XAI_REGIONAL.md @@ -0,0 +1,416 @@ +# Arquitetura TIA Regional com Pool xAI Pré-Aquecido + +## 1. Introdução + +Este documento descreve a evolução do TIA para operar TTS xAI em alta disponibilidade e alta volumetria usando réplicas Kubernetes distribuídas por região. A solução parte da implementação atual do TIA/LiveKit e mantém seu contrato de TTS, suas métricas de underflow/TTFB e sua lógica de proteção contra repetição após áudio parcial. + +A mudança principal é mover a manutenção do conjunto de conexões WebSocket xAI para um **pool persistente por Pod TIA**, implementado como sidecar. Cada Pod regional pode manter até `XAI_POOL_SIZE` conexões xAI pré-aquecidas e prontas para uso. O TIA continua enxergando um endpoint compatível com xAI, porém local (`127.0.0.1`). + +## 2. Dificuldade do modelo anterior + +Na versão anterior, cada instância `OraclexAITTS` mantinha uma única conexão reutilizável. Como o LiveKit executa chamadas em processos de job, essas conexões são naturalmente distribuídas por chamadas/processos e não formam um pool global do Pod. + +Isso cria alguns riscos em alta volumetria: + +1. **Burst de handshakes WebSocket.** Muitas chamadas podem abrir conexões ao xAI praticamente ao mesmo tempo. +2. **Capacidade não compartilhada.** Uma chamada pode manter uma conexão ociosa enquanto outra precisa abrir uma nova. +3. **Ausência de backpressure no balanceador.** O LB enxerga o TIA como saudável mesmo quando a capacidade de TTS daquele Pod está totalmente ocupada. +4. **Acoplamento entre sessão e conexão xAI.** A quantidade de chamadas pode virar, indiretamente, a quantidade de conexões abertas, mesmo quando apenas uma parte das chamadas está sintetizando naquele instante. +5. **Falha regional afeta novas chamadas.** Sem readiness orientada à saúde/capacidade do xAI, novas sessões podem continuar chegando a uma réplica cuja região está degradada. + +O histórico de testes do TTS já mostrou que problemas de estabelecimento de WebSocket e jitter podem se manifestar de forma regional e em função de carga. A arquitetura proposta transforma esses sinais em capacidade operacional do Pod. + +## 3. Objetivos + +A solução tem os seguintes objetivos: + +- manter conexões OCI xAI abertas e pré-aquecidas; +- reutilizar uma conexão entre diferentes sínteses; +- reservar conexão upstream apenas durante uma utterance; +- liberar a conexão imediatamente após `audio.done`; +- impedir bursts de abertura de WebSockets no caminho crítico da chamada; +- permitir TIA ativo/ativo em múltiplas regiões; +- retirar automaticamente uma réplica saturada do balanceamento de novas conexões; +- manter chamadas existentes durante draining/rollout; +- renovar sockets antes do TTL de forma escalonada, evitando reconexão simultânea; +- preservar o protocolo atual do `OraclexAITTS` e minimizar mudanças no código de voz; +- permitir escalabilidade horizontal em Kubernetes. + +## 4. Arquitetura proposta + +```text + Clientes / Telefonia + | + v + Load Balancer / Service + (novas conexões WebSocket) + | + +--------------------+--------------------+ + | | + v v + Deployment ORD Deployment IAD + | | + +--------+--------+ +--------+--------+ + | Pod TIA ORD | | Pod TIA IAD | + | | | | + | bridge | | bridge | + | agent/livekit | | agent/livekit | + | | | | | | + | v | | v | + | xAI pool sidecar| | xAI pool sidecar| + | 50 WS warm | | 50 WS warm | + +-------+---------+ +-------+---------+ + | | + v v + OCI xAI ORD OCI xAI IAD +``` + +O mesmo `Service` Kubernetes seleciona Pods ORD e IAD. Cada deployment acrescenta o label `tia-region`, útil para métricas e operação, mas ambos compartilham `app=${APP_NAME}-regional`. + +## 5. Componentes + +### 5.1 Bridge TIA + +Mantém o WebSocket de entrada e o comportamento existente do TIA. O código principal não precisa conhecer o endpoint xAI regional. + +### 5.2 LiveKit Agent + +Continua instanciando `OraclexAITTS`, porém passa a usar: + +```text +XAI_WEBSOCKET_URL=ws://127.0.0.1:18100/xai/v1/tts +``` + +O adapter continua enviando: + +```text +text.clear +text.delta +text.done +``` + +e recebendo: + +```text +audio.clear +audio.delta +audio.done +``` + +Portanto, a lógica atual de streaming, TTFB, gaps, underflow e prevenção de replay permanece válida. + +### 5.3 Sidecar `xai-pool` + +Implementado em: + +```text +src/app/livekit/adapters/xai_pool_proxy.py +``` + +Responsabilidades: + +- abrir `XAI_POOL_SIZE` WebSockets no startup; +- usar abertura em ondas controladas (`XAI_POOL_PREWARM_CONCURRENCY`); +- manter os sockets vivos; +- recuperar automaticamente slots desconectados; +- renovar sockets antes do TTL; +- aplicar jitter no refresh para não reconectar todos simultaneamente; +- emprestar uma conexão a uma utterance; +- devolver a conexão ao pool depois de `audio.done`; +- expor health, readiness, status, drain e métricas. + +### 5.4 OCI xAI regional + +Cada deployment recebe seu endpoint próprio por `XAI_POOL_UPSTREAM_URL`. + +Exemplo: + +```text +ORD -> wss://...us-chicago-1.../xai/v1/tts +IAD -> wss://...us-ashburn-1.../xai/v1/tts +``` + +As credenciais reais ficam somente no sidecar de pool. O container `agent` usa uma credencial local dummy porque se conecta apenas a `localhost`. + +### 5.5 Kubernetes Service / Load Balancer + +O Service seleciona todas as réplicas regionais. O WebSocket funciona normalmente através do LB: o balanceador escolhe um Pod durante o HTTP Upgrade e aquela conexão permanece no mesmo backend durante sua vida. + +A estratégia de capacidade não tenta migrar uma conexão existente. Ela afeta **novas conexões**. + +## 6. Pool compartilhado por Pod + +Uma chamada não reserva um socket xAI por toda sua duração. + +```text +Call A falando ---------- sem TTS +Call B ouvindo ---------- sem nova síntese +Call C precisa falar ---- acquire WS #17 + text.clear + text.delta + text.done + audio.delta... + audio.done + release WS #17 +``` + +Portanto, 200 chamadas podem coexistir em um Pod com pool de 50, desde que não existam mais de 50 sínteses concorrentes naquele instante. + +Essa é a principal diferença entre dimensionar por **chamadas simultâneas** e por **utterances TTS simultâneas**. + +## 7. Readiness orientada à capacidade + +O sidecar expõe: + +```text +GET /healthz +GET /readyz +GET /pool/status +GET /metrics +POST /drain +``` + +`/healthz` responde se o processo está vivo. + +`/readyz` responde se o Pod deve aceitar **novas chamadas**. + +Exemplo padrão: + +```text +XAI_POOL_SIZE=50 +XAI_POOL_UNAVAILABLE_FREE=2 +XAI_POOL_RECOVER_FREE=5 +``` + +Com o Pod inicialmente pronto: + +```text +free > 2 -> ready +free <= 2 -> not ready (HTTP 503) +``` + +Depois de sair da rotação, só retorna quando: + +```text +free >= 5 -> ready novamente +``` + +Isso cria histerese e evita flapping de readiness em torno do limite. + +Para a política estrita sugerida de somente sair quando todas estiverem ocupadas: + +```text +XAI_POOL_UNAVAILABLE_FREE=0 +XAI_POOL_RECOVER_FREE=5 +``` + +Em produção recomenda-se uma pequena reserva (por exemplo 2 a 5 conexões), pois ela absorve rajadas e reduz a chance de uma sessão recém-chegada não encontrar capacidade. + +## 8. Como o LB redireciona tráfego + +Em Kubernetes, um Pod é Ready apenas quando todos os containers que possuem readiness probe estão Ready. + +O sidecar `xai-pool` possui uma probe em `/readyz`. + +Quando a capacidade acaba: + +```text +xai-pool /readyz -> 503 + | + v +Pod becomes NotReady + | + v +Pod removed from Service Endpoints + | + v +LB stops sending NEW connections +``` + +As conexões WebSocket já estabelecidas não são redirecionadas e continuam no Pod enquanto o processo continuar disponível. + +## 9. Renovação escalonada das conexões + +Manter 50 sockets abertos indefinidamente sem renovação é arriscado porque serviços upstream normalmente aplicam TTL e renovação de autorização. + +A configuração padrão usa: + +```text +XAI_POOL_CONNECTION_TTL_S=540 +XAI_POOL_REFRESH_JITTER_S=45 +``` + +Cada conexão recebe um refresh deadline diferente: + +```text +WS01 -> ~501s +WS02 -> ~527s +WS03 -> ~509s +... +``` + +Somente conexões livres são renovadas. Isso evita um evento no qual 50 conexões expiram e fazem handshake simultaneamente. + +## 10. Alta disponibilidade regional + +Operação normal: + +```text +LB +|-- ORD Pod 1 -> ready +|-- ORD Pod 2 -> ready +|-- IAD Pod 1 -> ready +`-- IAD Pod 2 -> ready +``` + +Se ORD perder saúde/capacidade xAI, os slots começam a falhar e a quantidade de conexões saudáveis/livres cai. Quando a readiness cruza o threshold, os Pods ORD saem dos endpoints e novas chamadas passam a ser atendidas pelos Pods IAD disponíveis. + +Não há necessidade de alterar o cliente ou o LiveKit para escolher a região. + +## 11. Escala horizontal + +A capacidade teórica de pool é: + +```text +capacidade regional de sockets = replicas_region * XAI_POOL_SIZE +``` + +Exemplo: + +```text +ORD: 2 pods x 25 sockets = 50 +IAD: 2 pods x 25 sockets = 50 +TOTAL = 100 sockets prewarmed +``` + +ou, se a OCI conceder capacidade independente suficiente: + +```text +ORD: 2 pods x 50 = 100 +IAD: 2 pods x 50 = 100 +TOTAL = 200 +``` + +### Restrição crítica + +`replicas * XAI_POOL_SIZE` **não pode ultrapassar o limite real concedido pela OCI para o endpoint/tenancy/região**. + +Se a OCI disser que o limite 50 é global por endpoint, então duas réplicas de 50 no mesmo endpoint seriam incorretas. Nesse caso use, por exemplo: + +```text +2 replicas x 25 = 50 total +``` + +ou obtenha endpoints/capacidades independentes. + +## 12. Escala visual + +```text +Carga baixa +========= +LB + |-- ORD-1 [pool 50: 10 leased / 40 free] + `-- IAD-1 [pool 50: 8 leased / 42 free] + +Carga aumenta +============= +LB + |-- ORD-1 [47 leased / 3 free] READY + `-- IAD-1 [30 leased /20 free] READY + +ORD satura +=========== +LB + |-- ORD-1 [48 leased /2 free] NOT READY -> sem novas chamadas + `-- IAD-1 [31 leased /19 free] READY -> recebe novas chamadas + +ORD recupera +============= +ORD-1 chega a 45 leased /5 free +/readyz volta a 200 +LB volta a considerá-lo para novas conexões +``` + +## 13. Draining e rollout + +O deployment usa: + +```yaml +strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 +``` + +O sidecar executa no `preStop`: + +```text +POST /drain +``` + +Isso torna `/readyz` imediatamente 503, removendo o Pod da entrada de novas conexões antes de sua finalização. + +`terminationGracePeriodSeconds` deve ser compatível com a duração/grace desejada para as chamadas existentes. + +## 14. Segurança + +As credenciais OCI/xAI reais ficam no Secret indicado por `XAI_SECRET_NAME` e são montadas apenas no sidecar. + +O agent conecta a localhost e não precisa conhecer a chave real do xAI. + +Para produção recomenda-se evoluir para `OKE_WORKLOAD_IDENTITY` sempre que suportado pela política do ambiente, eliminando API keys estáticas. + +## 15. Observabilidade + +`GET /pool/status` retorna: + +```json +{ + "status": "ready", + "region": "ord", + "configured": 50, + "healthy": 50, + "leased": 17, + "free": 33, + "total_acquires": 845, + "total_acquire_timeouts": 0, + "total_proxy_failures": 0 +} +``` + +`GET /metrics` expõe métricas Prometheus simples: + +- `tia_xai_pool_connections{state="healthy"}` +- `tia_xai_pool_connections{state="leased"}` +- `tia_xai_pool_connections{state="free"}` +- `tia_xai_pool_acquires_total` +- `tia_xai_pool_acquire_timeouts_total` +- `tia_xai_pool_proxy_failures_total` + +Estas métricas devem ser correlacionadas com as métricas já existentes no TIA, especialmente TTFB, gap e underflow. + +## 16. O que esta versão resolve + +| Problema | Solução | +|---|---| +| handshakes xAI no caminho crítico | pool pré-aquecido | +| burst de WebSockets | prewarm em ondas + refresh com jitter | +| conexão presa a uma chamada | lease somente durante utterance | +| LB envia tráfego a Pod sem capacidade TTS | `/readyz` baseado no pool | +| flapping de health | histerese unavailable/recover | +| indisponibilidade regional | Deployments ORD/IAD no mesmo Service | +| rollout derruba novas sessões | drain + RollingUpdate | +| chave xAI em todos os processos | credencial real somente no sidecar | +| observabilidade de capacidade | `/pool/status` + `/metrics` | + +## 17. O que esta versão não resolve sozinha + +A solução não cria capacidade de inferência no OCI xAI. Se todos os endpoints regionais terminarem no mesmo pool de inferência saturado, o TIA terá failover e melhor utilização de sockets, mas não multiplicará a capacidade real do modelo. + +É necessário confirmar com a OCI: + +1. limite de WebSockets por endpoint/região/tenancy; +2. se endpoints dedicados possuem capacidade independente; +3. se API servers/LB e inference workers são dedicados ou compartilhados; +4. quais limites podem ser reservados/negociados para a TIM. diff --git a/docs/regional/CHANGELOG_IMPLEMENTACAO.md b/docs/regional/CHANGELOG_IMPLEMENTACAO.md new file mode 100644 index 0000000..079f06f --- /dev/null +++ b/docs/regional/CHANGELOG_IMPLEMENTACAO.md @@ -0,0 +1,32 @@ +# Changelog — TIA Regional / xAI Pool + +## Implementação adicionada + +- sidecar `xai_pool_proxy` compatível com o protocolo WebSocket xAI usado pelo TIA; +- pool pré-aquecido configurável por Pod (`XAI_POOL_SIZE`, default de exemplo 50); +- acquire por utterance após `text.clear`; +- release automático após `audio.done`; +- recuperação de slots desconectados; +- refresh escalonado por TTL + jitter; +- prewarm em ondas para evitar burst de handshake; +- `/healthz`, `/readyz`, `/pool/status`, `/metrics` e `/drain`; +- readiness com histerese; +- Deployments regionais ORD/IAD usando a mesma imagem; +- credencial xAI real isolada no sidecar; +- Service único para balancear Pods regionais; +- RollingUpdate, PDB e HPA; +- scripts de renderização, validação e deployment; +- manuais de arquitetura, implantação e testes. + +## Compatibilidade + +O modo anterior permanece disponível. O deployment regional é opcional e não remove `XAI_WEBSOCKET_URL` direto para OCI xAI. + +## Validações executadas na geração + +- `py_compile` / `compileall` do novo sidecar: PASS; +- parsing YAML dos templates Kubernetes: PASS; +- renderização ORD/IAD via `envsubst`: PASS; +- parsing YAML dos manifests renderizados: PASS. + +Não foi executado teste real contra OCI xAI, pois depende das credenciais/endpoints do ambiente TIM/OCI. O manual descreve smoke, saturação, failover e stress test a executar em FQA. diff --git a/docs/regional/DEPLOYMENT_TIA_XAI_REGIONAL.md b/docs/regional/DEPLOYMENT_TIA_XAI_REGIONAL.md new file mode 100644 index 0000000..1fb5edb --- /dev/null +++ b/docs/regional/DEPLOYMENT_TIA_XAI_REGIONAL.md @@ -0,0 +1,385 @@ +# Deployment — TIA Regional com Pool xAI + +## 1. Pré-requisitos + +- cluster Kubernetes/OKE funcional; +- `kubectl` configurado; +- `envsubst` (`gettext`) instalado na máquina de deployment; +- imagem TIA construída a partir desta versão; +- ConfigMap `${APP_NAME}-config` já existente; +- Secrets `${APP_NAME}-api-secrets`, `${APP_NAME}-google-sa-secret` e `shared-tls-secret` já existentes conforme deployment atual; +- endpoint e credencial xAI de cada região; +- quota xAI validada para o número total de WebSockets configurado. + +## 2. Arquivos adicionados + +```text +src/app/livekit/adapters/xai_pool_proxy.py + +k8s/regional/ + deployment-region.yaml + service.yaml + pdb.yaml + hpa-region.yaml + xai-secret.example.yaml + regions.env.example + +scripts/ + render-regional-k8s.sh + validate-regional-k8s.sh + deploy-regional-k8s.sh +``` + +## 3. Construir a imagem + +Use o pipeline existente ou Dockerfile atual do TIA. O novo sidecar utiliza a mesma imagem e apenas muda o módulo executado: + +```text +python -m app.livekit.adapters.xai_pool_proxy +``` + +Exemplo local: + +```bash +docker build -f k8s/tia/Dockerfile \ + -t iad.ocir.io//tia:regional-xai-pool-v1 . +``` + +Faça push para o registry usado pelo cluster. + +## 4. Criar arquivo de ambiente de deployment + +```bash +cp k8s/regional/regions.env.example k8s/regional/regions.env +``` + +Edite no mínimo: + +```bash +APP_NAME=tim-ai-atend-agnt-integ-tia +K8S_NAMESPACE=... +IMAGE_REPOSITORY=... +IMAGE_TAG=... + +ORD_XAI_UPSTREAM_URL=wss://...us-chicago-1.../xai/v1/tts +ORD_XAI_SECRET_NAME=xai-ord-credentials + +IAD_XAI_UPSTREAM_URL=wss://...us-ashburn-1.../xai/v1/tts +IAD_XAI_SECRET_NAME=xai-iad-credentials +``` + +## 5. Definir tamanho do pool + +Para um Pod com 50 sockets: + +```bash +XAI_POOL_SIZE=50 +``` + +Readiness recomendada: + +```bash +XAI_POOL_UNAVAILABLE_FREE=2 +XAI_POOL_RECOVER_FREE=5 +``` + +Se desejar exatamente o comportamento "só sair quando os 50 estiverem ocupados": + +```bash +XAI_POOL_UNAVAILABLE_FREE=0 +XAI_POOL_RECOVER_FREE=5 +``` + +### Importante + +Se houver `N` réplicas apontando para o mesmo endpoint: + +```text +sockets máximos = N * XAI_POOL_SIZE +``` + +Nunca configure isso acima da quota xAI real. + +## 6. Criar Secrets regionais + +Não versione chaves reais. + +Exemplo por linha de comando: + +```bash +kubectl -n "$K8S_NAMESPACE" create secret generic xai-ord-credentials \ + --from-literal=XAI_API_KEY='' \ + --from-literal=OCI_COMPARTMENT_ID='' + +kubectl -n "$K8S_NAMESPACE" create secret generic xai-iad-credentials \ + --from-literal=XAI_API_KEY='' \ + --from-literal=OCI_COMPARTMENT_ID='' +``` + +Para `API_KEY`, `OCI_COMPARTMENT_ID` pode ficar vazio se o fluxo upstream não o exigir. + +Para produção, prefira OCI Vault/External Secrets/Workload Identity em vez de gravar chaves no repositório. + +## 7. Renderizar manifests + +```bash +./scripts/render-regional-k8s.sh +``` + +Saída: + +```text +k8s/regional/rendered/ + deployment-ord.yaml + deployment-iad.yaml + hpa-ord.yaml + hpa-iad.yaml + service.yaml + pdb.yaml +``` + +É possível escolher outro arquivo e diretório: + +```bash +./scripts/render-regional-k8s.sh ./minha-config.env /tmp/tia-regional +``` + +## 8. Validar sem implantar + +```bash +./scripts/validate-regional-k8s.sh +``` + +O script usa: + +```bash +kubectl apply --dry-run=client +``` + +Revise também: + +```bash +kubectl diff -f k8s/regional/rendered/ +``` + +## 9. Implantar + +```bash +./scripts/deploy-regional-k8s.sh +``` + +Ou manualmente: + +```bash +kubectl apply -f k8s/regional/rendered/service.yaml +kubectl apply -f k8s/regional/rendered/pdb.yaml +kubectl apply -f k8s/regional/rendered/deployment-ord.yaml +kubectl apply -f k8s/regional/rendered/deployment-iad.yaml +kubectl apply -f k8s/regional/rendered/hpa-ord.yaml +kubectl apply -f k8s/regional/rendered/hpa-iad.yaml +``` + +## 10. Verificar rollout + +```bash +kubectl -n "$K8S_NAMESPACE" get pods -l app=${APP_NAME}-regional -o wide +``` + +Todos os containers devem ficar Ready: + +```text +bridge 1/1 +agent 1/1 +xai-pool 1/1 +``` + +Confira os deployments: + +```bash +kubectl -n "$K8S_NAMESPACE" rollout status deployment/${APP_NAME}-ord +kubectl -n "$K8S_NAMESPACE" rollout status deployment/${APP_NAME}-iad +``` + +## 11. Validar o pool + +Escolha um Pod: + +```bash +POD=$(kubectl -n "$K8S_NAMESPACE" get pod \ + -l app=${APP_NAME}-regional,tia-region=ord \ + -o jsonpath='{.items[0].metadata.name}') +``` + +Port-forward: + +```bash +kubectl -n "$K8S_NAMESPACE" port-forward "$POD" 18100:18100 +``` + +Em outro terminal: + +```bash +curl -s http://127.0.0.1:18100/healthz | jq +curl -s http://127.0.0.1:18100/readyz | jq +curl -s http://127.0.0.1:18100/pool/status | jq +curl -s http://127.0.0.1:18100/metrics +``` + +Resultado esperado após prewarm: + +```json +{ + "region": "ord", + "configured": 50, + "healthy": 50, + "leased": 0, + "free": 50, + "status": "ready" +} +``` + +## 12. Logs + +```bash +kubectl -n "$K8S_NAMESPACE" logs "$POD" -c xai-pool -f +``` + +Eventos relevantes: + +```text +XAI_POOL_SLOT_OPENED +XAI_POOL_PREWARM_FAILED +XAI_POOL_SLOT_RECOVERY_FAILED +XAI_POOL_STARTED +``` + +## 13. Testar saturação/readiness + +Objetivo: provar que o Pod sai da rotação para novas sessões quando o pool atinge o limite. + +1. Gere sínteses concorrentes suficientes para ocupar o pool. +2. Observe: + +```bash +watch -n 1 'curl -s http://127.0.0.1:18100/pool/status | jq' +``` + +3. Quando `free <= XAI_POOL_UNAVAILABLE_FREE`, espere: + +```bash +curl -i http://127.0.0.1:18100/readyz +``` + +Resultado: + +```text +HTTP/1.1 503 Service Unavailable +``` + +4. Verifique o Pod: + +```bash +kubectl get pod "$POD" +``` + +Ele deve ficar `NotReady` enquanto o sidecar estiver sem capacidade. + +5. Quando conexões forem liberadas e `free >= XAI_POOL_RECOVER_FREE`, o `/readyz` volta a 200 e o Pod retorna aos endpoints. + +## 14. Verificar endpoints do Service + +```bash +kubectl -n "$K8S_NAMESPACE" get endpoints ${APP_NAME}-regional -o wide +``` + +ou, em clusters novos: + +```bash +kubectl -n "$K8S_NAMESPACE" get endpointslice \ + -l kubernetes.io/service-name=${APP_NAME}-regional -o yaml +``` + +Um Pod NotReady não deve ser usado para novas conexões do Service. + +## 15. Testar failover regional + +### Teste controlado de ORD + +Coloque ORD em drain: + +```bash +ORD_POD=$(kubectl -n "$K8S_NAMESPACE" get pod \ + -l app=${APP_NAME}-regional,tia-region=ord \ + -o jsonpath='{.items[0].metadata.name}') + +kubectl -n "$K8S_NAMESPACE" exec "$ORD_POD" -c xai-pool -- \ + python -c "import urllib.request; urllib.request.urlopen(urllib.request.Request('http://127.0.0.1:18100/drain', method='POST')).read()" +``` + +Confirme `/readyz=503` e gere **nova chamada**. Ela deve ser entregue a um Pod IAD ainda Ready. + +Esse teste não deve ser interpretado como migração de uma chamada existente; somente novas conexões são rebalanceadas. + +## 16. Testar recuperação + +O drain manual é intencional e não é revertido. Para voltar o Pod, reinicie-o: + +```bash +kubectl -n "$K8S_NAMESPACE" delete pod "$ORD_POD" +``` + +O novo Pod deve: + +1. iniciar sidecar; +2. pré-aquecer sockets; +3. atingir `recover_free_threshold`; +4. ficar Ready; +5. entrar novamente nos endpoints. + +## 17. Load Balancer e WebSocket + +WebSocket é suportado porque a conexão começa como HTTP Upgrade. O LB escolhe o backend no handshake e mantém a conexão naquele Pod. + +Não use sticky session como requisito de correção. A conexão WebSocket em si já é persistente ao backend selecionado. Em caso de perda do Pod, o cliente precisa reconectar. + +Uma política equivalente a least-connections pode ser útil quando o LB permite configuração, mas o mecanismo primário de proteção desta arquitetura é a readiness baseada em capacidade real do TTS. + +## 18. HPA + +Os manifests incluem HPA por CPU como proteção inicial. + +Atenção: aumentar réplicas também aumenta o número de sockets xAI pré-aquecidos. Portanto, HPA só deve ter `maxReplicas` maior que 1 se a quota xAI permitir: + +```text +HPA_MAX_REPLICAS * XAI_POOL_SIZE <= quota regional permitida +``` + +Para evolução futura, prefira uma métrica customizada que considere chamadas/TTS ativos e também um controlador que respeite orçamento global de sockets. + +## 19. Rollback + +Para voltar ao modelo anterior: + +1. redirecione o Service/LB para o deployment antigo; +2. ou restaure o manifesto `k8s/tia/deployment.yaml` original; +3. o código antigo `OraclexAITTS` continua presente e compatível com endpoint xAI direto. + +A alteração não remove suporte ao modo anterior. + +## 20. Checklist de produção + +- [ ] confirmar quota real de WebSockets por endpoint/região/tenancy; +- [ ] confirmar se ORD e IAD têm pools de capacidade independentes; +- [ ] validar `XAI_POOL_SIZE * replicas` por região; +- [ ] validar Secret/Vault; +- [ ] medir tempo de prewarm dos 50 sockets; +- [ ] observar taxa de erro de handshake; +- [ ] validar refresh escalonado em janela > 10 minutos; +- [ ] testar saturation -> `/readyz=503`; +- [ ] testar recovery -> `/readyz=200`; +- [ ] testar drain durante chamada ativa; +- [ ] testar rollout sem perda de novas chamadas; +- [ ] testar perda total de ORD e entrada em IAD; +- [ ] correlacionar TTFB/gap/underflow com `pool_free`; +- [ ] validar LB/Service com WebSocket real; +- [ ] executar stress test com perfil semelhante ao tráfego de produção. diff --git a/docs/regional/TESTES_TIA_XAI_REGIONAL.md b/docs/regional/TESTES_TIA_XAI_REGIONAL.md new file mode 100644 index 0000000..677f55d --- /dev/null +++ b/docs/regional/TESTES_TIA_XAI_REGIONAL.md @@ -0,0 +1,151 @@ +# Plano de Testes — TIA Regional / Pool xAI + +## Objetivo + +Validar separadamente capacidade do pool, comportamento do Kubernetes, failover regional, impacto de latência e resiliência do xAI. + +## Camada 1 — Smoke + +1. subir 1 Pod ORD com pool pequeno (`XAI_POOL_SIZE=3`); +2. confirmar 3 conexões healthy; +3. realizar uma chamada e uma síntese; +4. confirmar que `leased` vai 0 -> 1 -> 0; +5. confirmar que o socket upstream continua healthy após `audio.done`. + +## Camada 2 — Prewarm + +Subir com `XAI_POOL_SIZE=50` e medir: + +- tempo total até Ready; +- taxa de sucesso de handshake; +- número máximo de handshakes simultâneos; +- impacto de `XAI_POOL_PREWARM_CONCURRENCY` 2, 5 e 10. + +Critério inicial: nenhuma rajada deve reproduzir os erros de conexão observados no modelo burst. + +## Camada 3 — Saturação + +Gerar mais utterances concorrentes que slots. + +Esperado: + +- leases nunca superam `XAI_POOL_SIZE`; +- acquire espera até `XAI_POOL_ACQUIRE_TIMEOUT_S`; +- readiness fica 503 quando free cruza threshold; +- novas chamadas deixam de entrar naquele Pod; +- chamadas existentes continuam. + +## Camada 4 — Histerese + +Com size=10: + +```text +UNAVAILABLE_FREE=2 +RECOVER_FREE=5 +``` + +Esperado: + +- free=2 -> NotReady; +- free=3/4 -> continua NotReady; +- free=5 -> Ready. + +## Camada 5 — Refresh + +Use TTL curto em FQA: + +```text +XAI_POOL_CONNECTION_TTL_S=60 +XAI_POOL_REFRESH_JITTER_S=20 +``` + +Observe por 5 minutos. + +Esperado: + +- sockets são renovados individualmente; +- não existe burst de 50 reconnects; +- slots ocupados não são renovados no meio da síntese; +- pool retorna ao tamanho configurado. + +## Camada 6 — Falha upstream + +Bloqueie ORD ou aponte temporariamente para endpoint inválido. + +Esperado: + +- slots ORD tornam-se unhealthy; +- maintenance tenta recuperação; +- readiness ORD cai; +- Service deixa de enviar novas chamadas a ORD; +- IAD continua Ready. + +## Camada 7 — Latência + +Compare três cenários: + +A. TIA -> xAI direto sem pool + +B. TIA -> localhost pool -> xAI com socket já aquecido + +C. TIA -> localhost pool -> xAI durante recuperação/abertura de socket + +Meça: + +- `provider_ttfb_ms`; +- `end_to_end_ttfb_ms`; +- `max_audio_delta_gap_ms`; +- `xai_underrun_estimado_ms`; +- connect time do upstream; +- `pool_free` e `pool_leased`. + +Hipótese: o hop localhost adiciona latência desprezível frente ao TTFB do provider, enquanto remove handshake xAI do caminho crítico na situação normal. + +## Camada 8 — Carga semelhante a produção + +Evite somente burst C=200. Use sockets persistentes e concorrência de síntese representativa da operação real, seguindo a metodologia que produziu resultados reprodutíveis nos testes anteriores. + +Rodar pelo menos: + +```text +20% da carga alvo +50% +80% +100% +120% por janela curta +``` + +## Camada 9 — Rollout + +Com chamadas ativas: + +```bash +kubectl rollout restart deployment/ +``` + +Validar: + +- Pod antigo entra em drain; +- não recebe novas conexões; +- Pod novo preaquece antes de ficar Ready; +- `maxUnavailable=0` preserva capacidade durante rollout. + +## Camada 10 — Failover regional + +1. ORD e IAD Ready; +2. iniciar tráfego contínuo; +3. tornar ORD NotReady; +4. verificar novas chamadas em IAD; +5. recuperar ORD; +6. verificar reentrada progressiva. + +## Evidências a guardar + +- logs do sidecar; +- `/pool/status` em intervalos de 1s; +- métricas Prometheus; +- logs TIA de TTFB/underflow; +- quantidade de endpoints Ready por região; +- distribuição de chamadas por Pod; +- erros de WebSocket upstream; +- timestamps de `audio.done`. diff --git a/docs/vad-pause-logs.md b/docs/vad-pause-logs.md new file mode 100644 index 0000000..2e712ea --- /dev/null +++ b/docs/vad-pause-logs.md @@ -0,0 +1,193 @@ +# VAD pause logs + +Este documento resume como analisar pausas de fala do usuario nos logs locais do agente LiveKit. + +## Onde procurar + +Os logs por chamada ficam em `logs/`. + +O arquivo costuma trazer os identificadores principais logo no inicio: + +```text +CALL_START | room=dev-room-75a205ec | protocol=PRT-20260409-0001 | session_id=hf05d7c2-1a1a-42e9-8651-ccd6351faff4 | bridge=ws-bridge-dev-2755fb53 +``` + +Use principalmente: + +- `room`: identifica a sala LiveKit e permite cruzar com `timeline/.jsonl`. +- `session_id`: identifica a conversa/sessao. +- `message_id`: identifica cada turno de usuario ou resposta do agente. +- `user_seq`: sequencia de falas finais do usuario. + +## Evento principal: `vad_user_pause` + +`vad_user_pause` indica que o wrapper de VAD registrou uma pausa relevante na fala do usuario. + +Exemplo: + +```text +FLOW | step=vad_user_pause | decision=end_of_speech | silence_ms=704 | pause_min_ms=700 | speech_duration_ms=896 | min_interrupt_ms=1600 | eligible=False | probability=0.000 | raw_speech_ms=0 | raw_silence_ms=0 +``` + +Campos essenciais: + +| Campo | Como usar | +| --- | --- | +| `decision` | Tipo de pausa detectada. `end_of_speech` e o principal para segmentacao real. `pause_threshold_reached` e um alerta intermediario. | +| `silence_ms` | Silencio observado pelo VAD, em milissegundos. E o campo mais importante para pausa. | +| `pause_min_ms` | Minimo de silencio necessario para fechar a fala. Vem de `LIVEKIT_VAD_MIN_SILENCE_DURATION_S`. | +| `speech_duration_ms` | Duracao do trecho de fala fechado pelo VAD. Trechos muito baixos indicam fala picotada. | +| `min_interrupt_ms` | Minimo de fala para considerar interrupcao do bot. Nao e o limite de pausa. | +| `eligible` | Se a fala passou de `min_interrupt_ms`. Mais util para barge-in do que para pausa. | +| `probability` | Probabilidade de fala no frame atual. Ajuda a entender ruido/atividade fraca. | +| `raw_speech_ms` | Acumulo bruto usado pelo VAD para detectar inicio de fala. | +| `raw_silence_ms` | Acumulo bruto usado pelo VAD para detectar silencio. | + +## Como interpretar pausas + +### Pausa que fechou fala + +Priorize `decision=end_of_speech`. + +Exemplo: + +```text +decision=end_of_speech | silence_ms=704 | pause_min_ms=700 | speech_duration_ms=896 +``` + +Leitura: + +- o VAD fechou o trecho apos observar `704ms` de silencio; +- o minimo configurado era `700ms`; +- como passou apenas `4ms` do minimo, a configuracao esta bem sensivel; +- se isso acontece varias vezes durante uma frase natural, a fala esta sendo segmentada cedo demais. + +### Alerta intermediario + +`decision=pause_threshold_reached` significa que o silencio passou do limite minimo antes do fechamento final. + +Exemplo: + +```text +decision=pause_threshold_reached | silence_ms=960 | pause_min_ms=700 | speech_duration_ms=0 | raw_speech_ms=32 +``` + +Esse evento deve ser lido com cuidado quando: + +- `speech_duration_ms=0`; +- `raw_speech_ms` e muito baixo, como `32`; +- `silence_ms` e muito alto, como dezenas de segundos. + +Nesses casos, pode ser artefato de estado acumulado ou ruido antes de uma fala real. Para confirmar impacto na conversa, cruze com `stt_final`. + +## Eventos auxiliares + +### `vad_speech_start` + +Indica inicio de fala detectado pelo VAD. + +```text +FLOW | step=vad_speech_start | speech_duration_ms=128 | min_interrupt_ms=1600 +``` + +Use para ver quando o sistema saiu de `listening` para `speaking`. + +### `vad_speech_end` + +Indica fechamento do trecho de fala. + +```text +FLOW | step=vad_speech_end | decision=too_short | speech_duration_ms=896 | eligible=False | silence_ms=704 | pause_min_ms=700 +``` + +Use junto com `vad_user_pause`. Se `speech_duration_ms` for baixo varias vezes, a fala pode estar sendo picotada. + +### `vad_interrupt_check` + +Indica que a fala atingiu duracao suficiente para interrupcao do bot. + +```text +FLOW | step=vad_interrupt_check | decision=eligible_by_duration | speech_duration_ms=1600 | min_interrupt_ms=1600 +``` + +Esse evento ajuda mais a analisar barge-in/interrupcao do bot do que pausa final de fala. + +### `stt_final` + +Mostra o texto final enviado como turno de usuario. + +```text +FLOW | step=stt_final | message_id=12345678-1234-4234-9234-123456789abc | user_seq=3 | text=Veio mais cara +``` + +Use este evento para validar o efeito real da segmentacao. Se uma frase natural virou varios `stt_final`, o VAD/STT segmentou demais. + +## Checklist de analise + +1. Encontre o `CALL_START` e anote `room`, `session_id` e `protocol`. +2. Filtre os eventos `vad_user_pause`. +3. Priorize `decision=end_of_speech`. +4. Compare `silence_ms` com `pause_min_ms`. +5. Verifique se `speech_duration_ms` esta muito baixo. +6. Cruze com os `stt_final` seguintes. +7. Se uma frase esperada virou varias mensagens curtas, a segmentacao esta agressiva. + +## Sinais de segmentacao agressiva + +Exemplo de fala esperada: + +```text +por que a minha fatura veio mais cara +``` + +Exemplo de saida segmentada: + +```text +stt_final | text=porque +stt_final | text=a minha fatura +stt_final | text=Veio mais cara +``` + +Se isso vier acompanhado de pausas assim: + +```text +vad_user_pause | decision=end_of_speech | silence_ms=704 | pause_min_ms=700 +``` + +provavelmente o limite de pausa esta fechando a fala cedo demais. + +## Parametro de ajuste + +O limite principal e: + +```env +LIVEKIT_VAD_MIN_SILENCE_DURATION_S=0.7 +``` + +Ele aparece no log como: + +```text +pause_min_ms=700 +``` + +Aumentar esse valor tende a juntar mais frases, porque o VAD espera mais silencio antes de encerrar a fala. O custo e aumentar a latencia percebida: o agente demora um pouco mais para responder depois que o usuario termina. + +Valores para teste manual: + +- `0.85`: ajuste conservador. +- `1.0`: tende a reduzir mais a segmentacao. +- `1.2`: pode ajudar em fala pausada, mas pode deixar a conversa lenta. + +## Regra pratica + +Para pausa, olhe primeiro: + +```text +decision + silence_ms + pause_min_ms +``` + +Para impacto na conversa, cruze com: + +```text +stt_final + user_seq + message_id +``` diff --git a/k8s/helm_deploy.yaml b/k8s/helm_deploy.yaml new file mode 100644 index 0000000..dc3d409 --- /dev/null +++ b/k8s/helm_deploy.yaml @@ -0,0 +1,4 @@ +apiVersion: v2 +name: ${APP_NAME} +description: helm para tia +version: ${IMAGE_TAG} \ No newline at end of file diff --git a/k8s/livekit/Dockerfile b/k8s/livekit/Dockerfile new file mode 100644 index 0000000..dd2b09e --- /dev/null +++ b/k8s/livekit/Dockerfile @@ -0,0 +1,7 @@ +FROM livekit/livekit-server:v1.11.0 + +COPY livekit.yaml /etc/livekit/livekit.yaml + +EXPOSE 7880 7881 7882 + +CMD ["--config", "/etc/livekit/livekit.yaml"] diff --git a/k8s/livekit/configmap.yaml b/k8s/livekit/configmap.yaml new file mode 100644 index 0000000..d3aff92 --- /dev/null +++ b/k8s/livekit/configmap.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: ${APP_NAME}-livekit-config + namespace: ${K8S_NAMESPACE} +data: + livekit.yaml: | + port: 7880 + log_level: warn + rtc: + tcp_port: 7881 + udp_port: 7882 + port_range_start: 50000 + port_range_end: 60000 + redis: + address: "${REDIS_HOST}:6379" + use_tls: true + keys: + tia_livek_tia_api_key: "TiaLivekitSecret2026KeyBridgeSync01" \ No newline at end of file diff --git a/k8s/livekit/deployment.yaml b/k8s/livekit/deployment.yaml new file mode 100644 index 0000000..612c4f8 --- /dev/null +++ b/k8s/livekit/deployment.yaml @@ -0,0 +1,100 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${APP_NAME}-livekit + namespace: ${K8S_NAMESPACE} + labels: + app: ${APP_NAME}-livekit + app.kubernetes.io/name: tia-livekit + app.kubernetes.io/part-of: tia +spec: + replicas: ${LIVEKIT_REPLICAS} + strategy: + type: Recreate + selector: + matchLabels: + app: ${APP_NAME} + template: + metadata: + labels: + app: ${APP_NAME} + app.kubernetes.io/name: tia-livekit + app.kubernetes.io/part-of: tia + spec: + hostAliases: + - ip: ${REDIS_IP} + hostnames: + - ${REDIS_HOST} + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + volumes: + - name: config-volume + configMap: + name: ${APP_NAME}-livekit-config + containers: + - name: ${APP_NAME}-livekit + image: ${IMAGE_REPOSITORY_LIVEKIT}:${IMAGE_TAG} + imagePullPolicy: IfNotPresent + args: + - "--config" + - "/etc/livekit/livekit.yaml" + envFrom: + - secretRef: + name: ${APP_NAME}-api-secrets + volumeMounts: + - name: config-volume + mountPath: /etc/livekit + readOnly: true + ports: + - name: signal + containerPort: 7880 + - name: rtc-tcp + containerPort: 7881 + - name: rtc-udp + containerPort: 7882 + protocol: UDP + readinessProbe: + tcpSocket: + port: 7880 + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + livenessProbe: + tcpSocket: + port: 7880 + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + requests: + cpu: "${CPU_LIVEKIT_REQ}" + memory: "${MEM_LIVEKIT_REQ}" + limits: + cpu: "${CPU_LIVEKIT_LIM}" + memory: "${MEM_LIVEKIT_LIM}" +--- +apiVersion: v1 +kind: Service +metadata: + name: ${APP_NAME}-livekit + namespace: ${K8S_NAMESPACE} +spec: + type: NodePort + selector: + app.kubernetes.io/name: tia-livekit + ports: + - name: signal + port: 7880 + targetPort: 7880 + - name: rtc-tcp + port: 7881 + targetPort: 7881 + - name: rtc-udp + port: 7882 + targetPort: 7882 + protocol: UDP diff --git a/k8s/regional/deployment-region.yaml b/k8s/regional/deployment-region.yaml new file mode 100644 index 0000000..15be7b7 --- /dev/null +++ b/k8s/regional/deployment-region.yaml @@ -0,0 +1,211 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${APP_NAME}-${REGION_ID} + namespace: ${K8S_NAMESPACE} + labels: + app: ${APP_NAME}-regional + tia-region: ${REGION_ID} +spec: + replicas: ${REGION_REPLICAS} + strategy: + type: RollingUpdate + rollingUpdate: + maxUnavailable: 0 + maxSurge: 1 + selector: + matchLabels: + app: ${APP_NAME}-regional + tia-region: ${REGION_ID} + template: + metadata: + labels: + app: ${APP_NAME}-regional + tia-region: ${REGION_ID} + spec: + terminationGracePeriodSeconds: ${TERMINATION_GRACE_SECONDS} + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + containers: + - name: bridge + image: ${IMAGE_REPOSITORY}:${IMAGE_TAG} + imagePullPolicy: IfNotPresent + args: ["app.bridge_entry", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"] + ports: + - name: bridge-http + containerPort: 8000 + env: + - name: GOOGLE_APPLICATION_CREDENTIALS + value: /etc/google/credentials.json + - name: PYTHONPATH + value: /app/src + - name: REQUESTS_CA_BUNDLE + value: /etc/ssl/custom/tls.crt + - name: SSL_CERT_FILE + value: /etc/ssl/custom/tls.crt + - name: TIA_XAI_REGION + value: ${REGION_ID} + envFrom: + - configMapRef: + name: ${APP_NAME}-config + - secretRef: + name: ${APP_NAME}-api-secrets + readinessProbe: + httpGet: {path: /health, port: 8000} + initialDelaySeconds: 15 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + livenessProbe: + httpGet: {path: /health, port: 8000} + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + requests: {cpu: "${CPU_TIA_BRIDGE_REQ}", memory: "${MEM_TIA_BRIDGE_REQ}"} + limits: {cpu: "${CPU_TIA_BRIDGE_LIM}", memory: "${MEM_TIA_BRIDGE_LIM}"} + volumeMounts: + - {name: google-sa-volume, mountPath: /etc/google, readOnly: true} + - {name: trusted-ca-volume, mountPath: /etc/ssl/custom, readOnly: true} + + - name: agent + image: ${IMAGE_REPOSITORY}:${IMAGE_TAG} + imagePullPolicy: IfNotPresent + args: ["app.agent_entry", "start", "--log-level", "info"] + ports: + - name: agent-http + containerPort: 18081 + envFrom: + - configMapRef: + name: ${APP_NAME}-config + - secretRef: + name: ${APP_NAME}-api-secrets + env: + - name: GOOGLE_APPLICATION_CREDENTIALS + value: /etc/google/credentials.json + - name: PYTHONPATH + value: /app/src + - name: AGENT_SERVER_PORT + value: "18081" + - name: NUM_IDLE_PROCESSES + value: "1" + - name: REQUESTS_CA_BUNDLE + value: /etc/ssl/custom/tls.crt + - name: SSL_CERT_FILE + value: /etc/ssl/custom/tls.crt + - name: TIA_XAI_REGION + value: ${REGION_ID} + # Agent sees a local xAI-compatible endpoint. Real OCI credentials stay in xai-pool. + - name: XAI_WEBSOCKET_URL + value: ws://127.0.0.1:18100/xai/v1/tts + - name: XAI_TTS_AUTH_METHOD + value: API_KEY + - name: XAI_API_KEY + value: local-pool-proxy + startupProbe: + httpGet: {path: /, port: 18081} + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 24 + readinessProbe: + httpGet: {path: /, port: 18081} + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + livenessProbe: + httpGet: {path: /, port: 18081} + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + requests: {cpu: "${CPU_TIA_REQ}", memory: "${MEM_TIA_REQ}"} + limits: {cpu: "${CPU_TIA_LIM}", memory: "${MEM_TIA_LIM}"} + volumeMounts: + - {name: google-sa-volume, mountPath: /etc/google, readOnly: true} + - {name: trusted-ca-volume, mountPath: /etc/ssl/custom, readOnly: true} + + - name: xai-pool + image: ${IMAGE_REPOSITORY}:${IMAGE_TAG} + imagePullPolicy: IfNotPresent + args: ["app.livekit.adapters.xai_pool_proxy"] + ports: + - name: xai-pool + containerPort: 18100 + env: + - name: PYTHONPATH + value: /app/src + - name: TIA_XAI_REGION + value: ${REGION_ID} + - name: XAI_POOL_UPSTREAM_URL + value: ${XAI_UPSTREAM_URL} + - name: XAI_POOL_SIZE + value: "${XAI_POOL_SIZE}" + - name: XAI_POOL_UNAVAILABLE_FREE + value: "${XAI_POOL_UNAVAILABLE_FREE}" + - name: XAI_POOL_RECOVER_FREE + value: "${XAI_POOL_RECOVER_FREE}" + - name: XAI_POOL_CONNECTION_TTL_S + value: "${XAI_POOL_CONNECTION_TTL_S}" + - name: XAI_POOL_REFRESH_JITTER_S + value: "${XAI_POOL_REFRESH_JITTER_S}" + - name: XAI_POOL_PREWARM_CONCURRENCY + value: "${XAI_POOL_PREWARM_CONCURRENCY}" + - name: XAI_TTS_VOICE + value: ${XAI_TTS_VOICE} + - name: XAI_TTS_LANGUAGE + value: ${XAI_TTS_LANGUAGE} + - name: XAI_TTS_AUTH_METHOD + value: ${XAI_UPSTREAM_AUTH_METHOD} + - name: OCI_COMPARTMENT_ID + valueFrom: + secretKeyRef: + name: ${XAI_SECRET_NAME} + key: OCI_COMPARTMENT_ID + optional: true + - name: XAI_API_KEY + valueFrom: + secretKeyRef: + name: ${XAI_SECRET_NAME} + key: XAI_API_KEY + optional: true + - name: REQUESTS_CA_BUNDLE + value: /etc/ssl/custom/tls.crt + - name: SSL_CERT_FILE + value: /etc/ssl/custom/tls.crt + readinessProbe: + httpGet: {path: /readyz, port: 18100} + initialDelaySeconds: 5 + periodSeconds: 2 + timeoutSeconds: 1 + failureThreshold: 2 + successThreshold: 1 + livenessProbe: + httpGet: {path: /healthz, port: 18100} + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + lifecycle: + preStop: + exec: + command: ["/bin/sh", "-c", "curl -sf -X POST http://127.0.0.1:18100/drain || true; sleep ${DRAIN_SECONDS}"] + resources: + requests: {cpu: "${CPU_XAI_POOL_REQ}", memory: "${MEM_XAI_POOL_REQ}"} + limits: {cpu: "${CPU_XAI_POOL_LIM}", memory: "${MEM_XAI_POOL_LIM}"} + volumeMounts: + - {name: trusted-ca-volume, mountPath: /etc/ssl/custom, readOnly: true} + + volumes: + - name: google-sa-volume + secret: + secretName: ${APP_NAME}-google-sa-secret + - name: trusted-ca-volume + secret: + secretName: shared-tls-secret diff --git a/k8s/regional/hpa-region.yaml b/k8s/regional/hpa-region.yaml new file mode 100644 index 0000000..2215cfe --- /dev/null +++ b/k8s/regional/hpa-region.yaml @@ -0,0 +1,32 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: ${APP_NAME}-${REGION_ID} + namespace: ${K8S_NAMESPACE} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: ${APP_NAME}-${REGION_ID} + minReplicas: ${HPA_MIN_REPLICAS} + maxReplicas: ${HPA_MAX_REPLICAS} + behavior: + scaleUp: + stabilizationWindowSeconds: 0 + policies: + - type: Percent + value: 100 + periodSeconds: 60 + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Percent + value: 25 + periodSeconds: 60 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: ${HPA_CPU_TARGET} diff --git a/k8s/regional/pdb.yaml b/k8s/regional/pdb.yaml new file mode 100644 index 0000000..eac869e --- /dev/null +++ b/k8s/regional/pdb.yaml @@ -0,0 +1,10 @@ +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: ${APP_NAME}-regional + namespace: ${K8S_NAMESPACE} +spec: + minAvailable: ${PDB_MIN_AVAILABLE} + selector: + matchLabels: + app: ${APP_NAME}-regional diff --git a/k8s/regional/regions.env.example b/k8s/regional/regions.env.example new file mode 100644 index 0000000..a471928 --- /dev/null +++ b/k8s/regional/regions.env.example @@ -0,0 +1,51 @@ +# Common +APP_NAME=tim-ai-atend-agnt-integ-tia +K8S_NAMESPACE=agnt-ai-atendimento +IMAGE_REPOSITORY=iad.ocir.io/SEU_NAMESPACE/tia +IMAGE_TAG=regional-xai-pool-v1 +TIA_SERVICE_TYPE=LoadBalancer +TERMINATION_GRACE_SECONDS=600 +DRAIN_SECONDS=30 +PDB_MIN_AVAILABLE=2 + +# Existing TIA resources +CPU_TIA_BRIDGE_REQ=250m +MEM_TIA_BRIDGE_REQ=512Mi +CPU_TIA_BRIDGE_LIM=1000m +MEM_TIA_BRIDGE_LIM=1Gi +CPU_TIA_REQ=500m +MEM_TIA_REQ=1Gi +CPU_TIA_LIM=2000m +MEM_TIA_LIM=2Gi +CPU_XAI_POOL_REQ=200m +MEM_XAI_POOL_REQ=256Mi +CPU_XAI_POOL_LIM=1000m +MEM_XAI_POOL_LIM=768Mi + +# Pool profile - 50 means 50 prewarmed upstream WebSockets PER POD. +XAI_POOL_SIZE=50 +XAI_POOL_UNAVAILABLE_FREE=2 +XAI_POOL_RECOVER_FREE=5 +XAI_POOL_CONNECTION_TTL_S=540 +XAI_POOL_REFRESH_JITTER_S=45 +XAI_POOL_PREWARM_CONCURRENCY=5 +XAI_TTS_VOICE=c8x2ieiocufs +XAI_TTS_LANGUAGE=pt-BR +XAI_UPSTREAM_AUTH_METHOD=API_KEY + +# HPA. WARNING: replicas * XAI_POOL_SIZE must respect OCI/xAI quota. +HPA_MIN_REPLICAS=1 +HPA_MAX_REPLICAS=3 +HPA_CPU_TARGET=65 + +# ORD +ORD_REGION_ID=ord +ORD_REGION_REPLICAS=1 +ORD_XAI_UPSTREAM_URL=wss://peordagnt002prd.pe.inference.generativeai.us-chicago-1.oci.oraclecloud.com/xai/v1/tts +ORD_XAI_SECRET_NAME=xai-ord-credentials + +# IAD +IAD_REGION_ID=iad +IAD_REGION_REPLICAS=1 +IAD_XAI_UPSTREAM_URL=wss://peiadagnt003prd.pe.inference.generativeai.us-ashburn-1.oci.oraclecloud.com/xai/v1/tts +IAD_XAI_SECRET_NAME=xai-iad-credentials diff --git a/k8s/regional/service.yaml b/k8s/regional/service.yaml new file mode 100644 index 0000000..54f2891 --- /dev/null +++ b/k8s/regional/service.yaml @@ -0,0 +1,17 @@ +apiVersion: v1 +kind: Service +metadata: + name: ${APP_NAME}-regional + namespace: ${K8S_NAMESPACE} + labels: + app: ${APP_NAME}-regional +spec: + type: ${TIA_SERVICE_TYPE} + sessionAffinity: None + selector: + app: ${APP_NAME}-regional + ports: + - name: ws-http + protocol: TCP + port: 80 + targetPort: 8000 diff --git a/k8s/regional/xai-secret.example.yaml b/k8s/regional/xai-secret.example.yaml new file mode 100644 index 0000000..8102ff5 --- /dev/null +++ b/k8s/regional/xai-secret.example.yaml @@ -0,0 +1,19 @@ +apiVersion: v1 +kind: Secret +metadata: + name: xai-ord-credentials + namespace: ${K8S_NAMESPACE} +type: Opaque +stringData: + XAI_API_KEY: "REPLACE_ME" + OCI_COMPARTMENT_ID: "" +--- +apiVersion: v1 +kind: Secret +metadata: + name: xai-iad-credentials + namespace: ${K8S_NAMESPACE} +type: Opaque +stringData: + XAI_API_KEY: "REPLACE_ME" + OCI_COMPARTMENT_ID: "" diff --git a/k8s/secrets.yaml b/k8s/secrets.yaml new file mode 100644 index 0000000..8c4b3d5 --- /dev/null +++ b/k8s/secrets.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: ${APP_NAME}-api-secrets + namespace: ${K8S_NAMESPACE} +type: Opaque +stringData: + AZURE_SPEECH_KEY: "${AZURE_SPEECH_KEY}" + XAI_API_KEY: "${XAI_API_KEY}" + LIVEKIT_REDIS_USERNAME: "${LIVEKIT_REDIS_USERNAME}" + LIVEKIT_REDIS_PASSWORD: "${LIVEKIT_REDIS_PASSWORD}" \ No newline at end of file diff --git a/k8s/tia/Dockerfile b/k8s/tia/Dockerfile new file mode 100644 index 0000000..73de5ce --- /dev/null +++ b/k8s/tia/Dockerfile @@ -0,0 +1,31 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV PYTHONPATH=/app/src +ENV HF_HOME=/hf +ENV HF_HUB_CACHE=/hf/hub +ENV HUGGINGFACE_HUB_CACHE=/hf/hub +ENV TRANSFORMERS_CACHE=/hf/hub + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + curl \ + libsndfile1 \ +&& rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/ ./src/ +RUN mkdir -p "$HF_HUB_CACHE" && \ + python -m app.livekit.main download-files + +RUN useradd -m -u 1000 agent && chown -R agent:agent /app /hf +USER agent + +EXPOSE 8000 18081 + +ENTRYPOINT ["python", "-m"] diff --git a/k8s/tia/deployment.yaml b/k8s/tia/deployment.yaml new file mode 100644 index 0000000..bd69658 --- /dev/null +++ b/k8s/tia/deployment.yaml @@ -0,0 +1,264 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${APP_NAME}-app + namespace: ${K8S_NAMESPACE} +spec: + replicas: ${TIA_REPLICAS} + strategy: + type: Recreate + selector: + matchLabels: + app: ${APP_NAME}-app + template: + metadata: + labels: + app: ${APP_NAME}-app + spec: + hostAliases: + - ip: "10.151.225.135" + hostnames: + - "speech-agent-ai-atendi-fqa-01.cognitiveservices.azure.com" + - ip: 10.153.35.23 + hostnames: + - tim-ai-atend-agnt-opentelemetry + - ip: 10.154.0.154 + hostnames: + - peordagnt002prd.pe.inference.generativeai.us-chicago-1.oci.oraclecloud.com + - ip: 10.154.16.244 + hostnames: + - peiadagnt003prd.pe.inference.generativeai.us-ashburn-1.oci.oraclecloud.com + - ip: ${REDIS_IP} + hostnames: + - ${REDIS_HOST} + - ip: 10.154.16.244 + hostnames: + - peiadagnt003prd.pe.inference.generativeai.us-ashburn-1.oci.oraclecloud.com + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + containers: + - name: ${APP_NAME}-app-bridge + image: ${IMAGE_REPOSITORY}:${IMAGE_TAG} + imagePullPolicy: IfNotPresent + args: + - app.bridge_entry + - --host + - 0.0.0.0 + - --port + - "8000" + - --log-level + - info + ports: + - name: http + containerPort: 8000 + env: + - name: GOOGLE_APPLICATION_CREDENTIALS + value: "/etc/google/credentials.json" + - name: PYTHONPATH + value: /app/src + - name: REQUESTS_CA_BUNDLE + value: "/etc/ssl/custom/tls.crt" + - name: SSL_CERT_FILE + value: "/etc/ssl/custom/tls.crt" + envFrom: + - configMapRef: + name: ${APP_NAME}-config + - secretRef: + name: ${APP_NAME}-api-secrets + volumeMounts: + - name: google-sa-volume + mountPath: /etc/google + readOnly: true + - name: trusted-ca-volume + mountPath: "/etc/ssl/custom" + readOnly: true + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + successThreshold: 1 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + requests: + cpu: "${CPU_TIA_BRIDGE_REQ}" + memory: "${MEM_TIA_BRIDGE_REQ}" + limits: + cpu: "${CPU_TIA_BRIDGE_LIM}" + memory: "${MEM_TIA_BRIDGE_LIM}" + + - name: ${APP_NAME}-app-agent + image: ${IMAGE_REPOSITORY}:${IMAGE_TAG} + imagePullPolicy: IfNotPresent + args: + - app.agent_entry + - start + - --log-level + - info + ports: + - name: agent-http + containerPort: 18081 + env: + - name: GOOGLE_APPLICATION_CREDENTIALS + value: "/etc/google/credentials.json" + - name: PYTHONPATH + value: /app/src + - name: AGENT_SERVER_PORT + value: "18081" + - name: NUM_IDLE_PROCESSES + value: "1" + - name: REQUESTS_CA_BUNDLE + value: "/etc/ssl/custom/tls.crt" + - name: SSL_CERT_FILE + value: "/etc/ssl/custom/tls.crt" + envFrom: + - configMapRef: + name: ${APP_NAME}-config + - secretRef: + name: ${APP_NAME}-api-secrets + volumeMounts: + - name: google-sa-volume + mountPath: /etc/google + readOnly: true + - name: trusted-ca-volume + mountPath: "/etc/ssl/custom" + readOnly: true + startupProbe: + httpGet: + path: / + port: 18081 + initialDelaySeconds: 10 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 24 + readinessProbe: + httpGet: + path: / + port: 18081 + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + successThreshold: 1 + failureThreshold: 3 + livenessProbe: + httpGet: + path: / + port: 18081 + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + requests: + cpu: "${CPU_TIA_REQ}" + memory: "${MEM_TIA_REQ}" + limits: + cpu: "${CPU_TIA_LIM}" + memory: "${MEM_TIA_LIM}" + volumes: + - name: google-sa-volume + secret: + secretName: ${APP_NAME}-google-sa-secret + - name: trusted-ca-volume + secret: + secretName: shared-tls-secret +--- +apiVersion: v1 +kind: Service +metadata: + name: ${APP_NAME}-app + namespace: ${K8S_NAMESPACE} + labels: + app: ${APP_NAME}-app +spec: + type: NodePort + selector: + app: ${APP_NAME}-app + ports: + - name: http + protocol: TCP + port: 80 + targetPort: 8000 + - name: https + protocol: TCP + port: 443 + targetPort: 8000 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: ${APP_NAME}-route + namespace: ${K8S_NAMESPACE} +spec: + parentRefs: + - name: istio-gateway + namespace: istio-gateway + hostnames: + - ${APP_NAME} + rules: + - matches: + - path: + type: PathPrefix + value: / + timeouts: + request: 1600s + backendRefs: + - name: ${APP_NAME}-app + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: ${APP_NAME}-route-http + namespace: ${K8S_NAMESPACE} +spec: + parentRefs: + - name: istio-gateway + namespace: istio-gateway + hostnames: + - ${DNS} + rules: + - matches: + - path: + type: PathPrefix + value: / + timeouts: + request: 1600s + backendRefs: + - name: ${APP_NAME}-app + port: 80 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: ${APP_NAME}-route-https + namespace: ${K8S_NAMESPACE} +spec: + parentRefs: + - name: istio-gateway + namespace: istio-gateway + hostnames: + - ${DNS} + rules: + - matches: + - path: + type: PathPrefix + value: / + timeouts: + request: 1600s + backendRefs: + - name: ${APP_NAME}-app + port: 443 diff --git a/livekit.yaml b/livekit.yaml new file mode 100644 index 0000000..7501d0d --- /dev/null +++ b/livekit.yaml @@ -0,0 +1,8 @@ +port: 7880 +log_level: warn +rtc: + tcp_port: 7881 + port_range_start: 50000 + port_range_end: 60000 +keys: + tia_livek_tia_api_key: "TiaLivekitSecret2026KeyBridgeSync01" diff --git a/makefile b/makefile new file mode 100644 index 0000000..7e3119b --- /dev/null +++ b/makefile @@ -0,0 +1,495 @@ +SHELL := /bin/bash +OS := $(shell uname -s) + +# ====================== +# Local (DEV) - python +# ====================== +VENV ?= .venv +PY ?= $(VENV)/bin/python +BOOTSTRAP_PY ?= +SRC ?= app +PYTHONPATH_HOST ?= $(CURDIR)/src +PYTHONPATH_CONT ?= /app/src + +AGENT_FILE ?= $(SRC).agent_entry +BRIDGE_MODULE ?= $(SRC).bridge_entry +RUN_DIR ?= .run +AGENT_PID_FILE ?= $(RUN_DIR)/agent.pid +BRIDGE_PID_FILE ?= $(RUN_DIR)/bridge.pid +AGENT_LOG_FILE ?= $(RUN_DIR)/agent.log +BRIDGE_LOG_FILE ?= $(RUN_DIR)/bridge.log + +WS_HOST ?= 0.0.0.0 +DEV_WS_PORT ?= 8000 +AGENT_SERVER_PORT ?= 18081 + +# envs +ENV_DEV ?= .env.dev +ENV_PROD ?= .env.prod + +# ====================== +# Podman (PROD) - host network +# ====================== +IMAGE ?= api-tia-lk:latest +POD_NAME ?= api-tia-pod + +PROD_WS_PORT ?= 8000 +EXPORT_DIR_HOST ?= /RemoteChannelsOutBound +EXPORT_DIR_CONT ?= /RemoteChannelsOutBound + +# HuggingFace cache (turn-detector models) +HF_CACHE_VOL ?= lk-hf-cache +HF_HOME_CONT ?= /hf +HF_HUB_CACHE_CONT ?= /hf/hub + +# ====================== +# LiveKit (build/run) - host network +# ====================== +LIVEKIT_IMAGE ?= livekit/livekit-server:latest +LIVEKIT_NAME ?= livekit-prod +LIVEKIT_CFG ?= $(CURDIR)/livekit.yaml +LIVEKIT_PORT_HTTP ?= 7880 +LIVEKIT_PORT_TCP ?= 7881 + +.PHONY: help \ + setup check-venv \ + agent agent-models bridge test \ + agent-up agent-down agent-status agent-logs \ + bridge-up bridge-down bridge-status bridge-logs \ + local-up local-down local-status local-logs local-stresstest \ + docker-build docker-models docker-up docker-down docker-status docker-logs docker-logs-bridge \ + livekit livekit-stop livekit-logs livekit-status livekit-config-check \ + copy-logs download-call-segments download-entire-calls + +help: + @echo "Targets (DEV local):" + @echo " make setup - cria $(VENV), instala dependencias e prepara $(ENV_DEV)" + @echo " make local-up - sobe livekit + bridge + agent em background" + @echo " make local-down - derruba bridge + agent locais e para o livekit" + @echo " make local-status - mostra status do livekit + pids locais" + @echo " make local-logs - informa onde estao os logs locais" + @echo " make local-stresstest - valida STT/TTS/Bridge/LiveKit reais e gera report.md" + @echo " make agent - roda agent (usa $(ENV_DEV))" + @echo " make agent-up - sobe agent em background" + @echo " make agent-down - derruba agent local em background" + @echo " make agent-logs - tail do log local do agent" + @echo " make agent-models - baixa modelos locais do turn-detector (usa $(ENV_DEV))" + @echo " make bridge - roda bridge (usa $(ENV_DEV)) na porta $(DEV_WS_PORT)" + @echo " make bridge-up - sobe bridge em background" + @echo " make bridge-down - derruba bridge local em background" + @echo " make bridge-logs - tail do log local do bridge" + @echo " make download-call-segments DATE=AAAA-MM-DD SESSION_ID=id [BUCKET=nome]" + @echo " make download-entire-calls DATE=AAAA-MM-DD [BUCKET=nome]" + @echo " make test - roda a suite pytest com PYTHONPATH=src" + @echo + @echo "Targets (PROD podman / host network):" + @echo " make docker-build - build imagem (usa PROD por padrão)" + @echo " make docker-models - baixa modelos do turn-detector no volume $(HF_CACHE_VOL)" + @echo " make docker-up - sobe agent+bridge no pod (host network), bridge na porta $(PROD_WS_PORT)" + @echo " make docker-logs - logs do agent" + @echo " make docker-logs-bridge - logs do bridge" + @echo " make docker-down - derruba pod" + @echo + @echo "Targets (LiveKit host network):" + @echo " make livekit - sobe LiveKit (host network)" + @echo " make livekit-logs - logs LiveKit" + @echo " make livekit-stop - remove container LiveKit" + +# ====================== +# DEV (local) +# ====================== +setup: + @if [ -x "$(PY)" ]; then \ + if $(PY) -c 'import sys; raise SystemExit(0 if (sys.version_info.major == 3 and 9 <= sys.version_info.minor <= 13) else 1)' >/dev/null 2>&1; then \ + echo "[make] usando virtualenv existente em $(VENV)"; \ + else \ + echo "ERRO: $(VENV) usa uma versao de Python nao suportada por este projeto."; \ + echo "Remova $(VENV) e rode 'make BOOTSTRAP_PY=python3.12 setup'."; \ + exit 1; \ + fi; \ + else \ + bootstrap_py="$(BOOTSTRAP_PY)"; \ + if [ -n "$$bootstrap_py" ]; then \ + if ! command -v "$$bootstrap_py" >/dev/null 2>&1; then \ + echo "ERRO: interpretador '$$bootstrap_py' nao encontrado no PATH."; \ + exit 1; \ + fi; \ + if ! "$$bootstrap_py" -c 'import sys; raise SystemExit(0 if (sys.version_info.major == 3 and 9 <= sys.version_info.minor <= 13) else 1)' >/dev/null 2>&1; then \ + echo "ERRO: '$$bootstrap_py' nao eh compativel. Use Python 3.9-3.13."; \ + exit 1; \ + fi; \ + else \ + for candidate in python3.13 python3.12 python3.11 python3.10 python3.9 python3 python; do \ + if command -v "$$candidate" >/dev/null 2>&1 && "$$candidate" -c 'import sys; raise SystemExit(0 if (sys.version_info.major == 3 and 9 <= sys.version_info.minor <= 13) else 1)' >/dev/null 2>&1; then \ + bootstrap_py="$$candidate"; \ + break; \ + fi; \ + done; \ + fi; \ + if [ -n "$$bootstrap_py" ]; then \ + echo "[make] criando virtualenv com $$bootstrap_py"; \ + "$$bootstrap_py" -m venv "$(VENV)"; \ + elif command -v uv >/dev/null 2>&1; then \ + echo "ERRO: nenhum Python compativel encontrado no PATH."; \ + echo "Instale Python 3.9-3.13 ou rode 'make BOOTSTRAP_PY=python3.12 setup'."; \ + exit 1; \ + else \ + echo "ERRO: nenhum interpretador encontrado. Instale Python 3.9-3.13 e rode 'make setup' novamente."; \ + exit 1; \ + fi; \ + fi + @$(PY) -m pip install --upgrade pip + @$(PY) -m pip install -r requirements.txt + @if [ ! -f "$(ENV_DEV)" ]; then \ + cp .env.example "$(ENV_DEV)"; \ + echo "[make] criado $(ENV_DEV) a partir de .env.example"; \ + fi + @echo "[make] ambiente pronto. Para rodar os testes: make test" + +check-venv: + @if [ ! -x "$(PY)" ]; then \ + echo "ERRO: virtualenv nao encontrado em $(PY). Rode 'make setup' antes."; \ + exit 1; \ + fi + @if ! $(PY) -c 'import sys; raise SystemExit(0 if (sys.version_info.major == 3 and 9 <= sys.version_info.minor <= 13) else 1)' >/dev/null 2>&1; then \ + echo "ERRO: o virtualenv em $(VENV) nao usa um Python suportado."; \ + echo "Remova $(VENV) e rode 'make BOOTSTRAP_PY=python3.12 setup'."; \ + exit 1; \ + fi + +agent: check-venv + @set -a; [ -f "$(ENV_DEV)" ] && source "$(ENV_DEV)"; set +a; \ + AGENT_SERVER_PORT="$(AGENT_SERVER_PORT)" \ + STT_DUMP_DIR="$${AGENT_DIAG_STT_DUMP_DIR:-$${STT_DUMP_DIR:-}}" \ + FLOW_LOG_VAD_DECISIONS="$${AGENT_DIAG_FLOW_LOG_VAD_DECISIONS:-$${FLOW_LOG_VAD_DECISIONS:-0}}" \ + FLOW_LOG_VAD_ACTIVITY="$${AGENT_DIAG_FLOW_LOG_VAD_ACTIVITY:-$${FLOW_LOG_VAD_ACTIVITY:-0}}" \ + FLOW_LOG_VAD_ACTIVITY_MIN_PROB="$${AGENT_DIAG_FLOW_LOG_VAD_ACTIVITY_MIN_PROB:-$${FLOW_LOG_VAD_ACTIVITY_MIN_PROB:-0.03}}" \ + PYTHONPATH="$(PYTHONPATH_HOST):$$PYTHONPATH" \ + $(PY) -m $(AGENT_FILE) start --log-level info + +agent-models: check-venv + @set -a; [ -f "$(ENV_DEV)" ] && source "$(ENV_DEV)"; set +a; \ + AGENT_SERVER_PORT="$(AGENT_SERVER_PORT)" PYTHONPATH="$(PYTHONPATH_HOST):$$PYTHONPATH" $(PY) -m $(AGENT_FILE) download-files + +bridge: check-venv + @set -a; [ -f "$(ENV_DEV)" ] && source "$(ENV_DEV)"; set +a; \ + PYTHONPATH="$(PYTHONPATH_HOST):$$PYTHONPATH" $(PY) -m $(BRIDGE_MODULE) --host $(WS_HOST) --port $(DEV_WS_PORT) --log-level info + +download-call-segments: check-venv + @test -n "$(DATE)" || (echo "ERRO: informe DATE=AAAA-MM-DD"; exit 2) + @test -n "$(SESSION_ID)" || (echo "ERRO: informe SESSION_ID="; exit 2) + @set -a; [ -f "$(ENV_DEV)" ] && source "$(ENV_DEV)"; set +a; \ + PYTHONPATH="$(PYTHONPATH_HOST):$$PYTHONPATH" \ + $(PY) -m app.tools.oci_audio_download \ + $(if $(BUCKET),--bucket "$(BUCKET)",) \ + segments --date "$(DATE)" --session-id "$(SESSION_ID)" + +download-entire-calls: check-venv + @test -n "$(DATE)" || (echo "ERRO: informe DATE=AAAA-MM-DD"; exit 2) + @set -a; [ -f "$(ENV_DEV)" ] && source "$(ENV_DEV)"; set +a; \ + PYTHONPATH="$(PYTHONPATH_HOST):$$PYTHONPATH" \ + $(PY) -m app.tools.oci_audio_download \ + $(if $(BUCKET),--bucket "$(BUCKET)",) \ + entire-calls --date "$(DATE)" + +test: check-venv + PYTHONPATH="$(PYTHONPATH_HOST):$$PYTHONPATH" $(PY) -m pytest + +agent-up: check-venv + @mkdir -p "$(RUN_DIR)" + @if [ -f "$(AGENT_PID_FILE)" ] && kill -0 "$$(cat "$(AGENT_PID_FILE)")" >/dev/null 2>&1; then \ + echo "[make] agent ja esta rodando com pid $$(cat "$(AGENT_PID_FILE)")"; \ + exit 0; \ + fi + @port_pids="$$(lsof -t -iTCP:$(AGENT_SERVER_PORT) -sTCP:LISTEN 2>/dev/null || true)"; \ + if [ -n "$$port_pids" ]; then \ + echo "ERRO: a porta $(AGENT_SERVER_PORT) ja esta em uso pelos pid(s): $$port_pids"; \ + echo "Rode 'make agent-down' para limpar o worker local antigo ou finalize o processo manualmente."; \ + exit 1; \ + fi + @nohup /bin/bash -lc 'set -a; [ -f "$(ENV_DEV)" ] && source "$(ENV_DEV)"; set +a; AGENT_SERVER_PORT="$(AGENT_SERVER_PORT)" STT_DUMP_DIR="$${AGENT_DIAG_STT_DUMP_DIR:-$${STT_DUMP_DIR:-}}" FLOW_LOG_VAD_DECISIONS="$${AGENT_DIAG_FLOW_LOG_VAD_DECISIONS:-$${FLOW_LOG_VAD_DECISIONS:-0}}" FLOW_LOG_VAD_ACTIVITY="$${AGENT_DIAG_FLOW_LOG_VAD_ACTIVITY:-$${FLOW_LOG_VAD_ACTIVITY:-0}}" FLOW_LOG_VAD_ACTIVITY_MIN_PROB="$${AGENT_DIAG_FLOW_LOG_VAD_ACTIVITY_MIN_PROB:-$${FLOW_LOG_VAD_ACTIVITY_MIN_PROB:-0.03}}" PYTHONPATH="$(PYTHONPATH_HOST):$$PYTHONPATH" exec "$(PY)" -m $(AGENT_FILE) start --log-level info' >"$(AGENT_LOG_FILE)" 2>&1 & echo $$! >"$(AGENT_PID_FILE)" + @sleep 1 + @if kill -0 "$$(cat "$(AGENT_PID_FILE)")" >/dev/null 2>&1; then \ + echo "[make] agent iniciado em background (pid $$(cat "$(AGENT_PID_FILE)"))"; \ + echo "[make] log: $(AGENT_LOG_FILE)"; \ + else \ + echo "ERRO: agent falhou ao iniciar. Veja $(AGENT_LOG_FILE)"; \ + rm -f "$(AGENT_PID_FILE)"; \ + exit 1; \ + fi + +agent-down: + @if [ -f "$(AGENT_PID_FILE)" ]; then \ + pid="$$(cat "$(AGENT_PID_FILE)")"; \ + if kill -0 "$$pid" >/dev/null 2>&1; then \ + kill "$$pid" >/dev/null 2>&1 || true; \ + sleep 1; \ + if kill -0 "$$pid" >/dev/null 2>&1; then \ + kill -9 "$$pid" >/dev/null 2>&1 || true; \ + echo "[make] agent encerrado com SIGKILL (pid $$pid)"; \ + else \ + echo "[make] agent encerrado (pid $$pid)"; \ + fi; \ + else \ + echo "[make] agent nao estava rodando, removendo pid stale"; \ + fi; \ + rm -f "$(AGENT_PID_FILE)"; \ + else \ + echo "[make] agent nao esta rodando"; \ + fi + @port_pids="$$(lsof -t -iTCP:$(AGENT_SERVER_PORT) -sTCP:LISTEN 2>/dev/null || true)"; \ + if [ -n "$$port_pids" ]; then \ + for pid in $$port_pids; do \ + kill "$$pid" >/dev/null 2>&1 || true; \ + sleep 1; \ + if kill -0 "$$pid" >/dev/null 2>&1; then \ + kill -9 "$$pid" >/dev/null 2>&1 || true; \ + echo "[make] worker do agent encerrado com SIGKILL na porta $(AGENT_SERVER_PORT) (pid $$pid)"; \ + else \ + echo "[make] worker do agent encerrado na porta $(AGENT_SERVER_PORT) (pid $$pid)"; \ + fi; \ + done; \ + fi + +agent-status: + @if [ -f "$(AGENT_PID_FILE)" ] && kill -0 "$$(cat "$(AGENT_PID_FILE)")" >/dev/null 2>&1; then \ + echo "agent: running (pid $$(cat "$(AGENT_PID_FILE)"))"; \ + elif [ -n "$$(lsof -t -iTCP:$(AGENT_SERVER_PORT) -sTCP:LISTEN 2>/dev/null || true)" ]; then \ + echo "agent: running (listener na porta $(AGENT_SERVER_PORT) sem pid file)"; \ + else \ + echo "agent: stopped"; \ + fi + +agent-logs: + @if [ ! -f "$(AGENT_LOG_FILE)" ]; then \ + echo "ERRO: log do agent nao encontrado em $(AGENT_LOG_FILE)"; \ + exit 1; \ + fi + @tail -f "$(AGENT_LOG_FILE)" + +bridge-up: check-venv + @mkdir -p "$(RUN_DIR)" + @if [ -f "$(BRIDGE_PID_FILE)" ] && kill -0 "$$(cat "$(BRIDGE_PID_FILE)")" >/dev/null 2>&1; then \ + echo "[make] bridge ja esta rodando com pid $$(cat "$(BRIDGE_PID_FILE)")"; \ + exit 0; \ + fi + @port_pids="$$(lsof -t -iTCP:$(DEV_WS_PORT) -sTCP:LISTEN 2>/dev/null || true)"; \ + if [ -n "$$port_pids" ]; then \ + echo "ERRO: a porta $(DEV_WS_PORT) ja esta em uso pelos pid(s): $$port_pids"; \ + echo "Rode 'make bridge-down' para limpar o bridge local antigo ou finalize o processo manualmente."; \ + exit 1; \ + fi + @nohup /bin/bash -lc 'set -a; [ -f "$(ENV_DEV)" ] && source "$(ENV_DEV)"; set +a; PYTHONPATH="$(PYTHONPATH_HOST):$$PYTHONPATH" exec "$(PY)" -m $(BRIDGE_MODULE) --host "$(WS_HOST)" --port "$(DEV_WS_PORT)" --log-level info' >"$(BRIDGE_LOG_FILE)" 2>&1 & echo $$! >"$(BRIDGE_PID_FILE)" + @sleep 1 + @if kill -0 "$$(cat "$(BRIDGE_PID_FILE)")" >/dev/null 2>&1; then \ + echo "[make] bridge iniciado em background (pid $$(cat "$(BRIDGE_PID_FILE)"))"; \ + echo "[make] log: $(BRIDGE_LOG_FILE)"; \ + else \ + echo "ERRO: bridge falhou ao iniciar. Veja $(BRIDGE_LOG_FILE)"; \ + rm -f "$(BRIDGE_PID_FILE)"; \ + exit 1; \ + fi + +bridge-down: + @if [ -f "$(BRIDGE_PID_FILE)" ]; then \ + pid="$$(cat "$(BRIDGE_PID_FILE)")"; \ + if kill -0 "$$pid" >/dev/null 2>&1; then \ + kill "$$pid" >/dev/null 2>&1 || true; \ + sleep 1; \ + if kill -0 "$$pid" >/dev/null 2>&1; then \ + kill -9 "$$pid" >/dev/null 2>&1 || true; \ + echo "[make] bridge encerrado com SIGKILL (pid $$pid)"; \ + else \ + echo "[make] bridge encerrado (pid $$pid)"; \ + fi; \ + else \ + echo "[make] bridge nao estava rodando, removendo pid stale"; \ + fi; \ + rm -f "$(BRIDGE_PID_FILE)"; \ + else \ + echo "[make] bridge nao esta rodando"; \ + fi + @port_pids="$$(lsof -t -iTCP:$(DEV_WS_PORT) -sTCP:LISTEN 2>/dev/null || true)"; \ + if [ -n "$$port_pids" ]; then \ + for pid in $$port_pids; do \ + kill "$$pid" >/dev/null 2>&1 || true; \ + sleep 1; \ + if kill -0 "$$pid" >/dev/null 2>&1; then \ + kill -9 "$$pid" >/dev/null 2>&1 || true; \ + echo "[make] worker do bridge encerrado com SIGKILL na porta $(DEV_WS_PORT) (pid $$pid)"; \ + else \ + echo "[make] worker do bridge encerrado na porta $(DEV_WS_PORT) (pid $$pid)"; \ + fi; \ + done; \ + fi + +bridge-status: + @if [ -f "$(BRIDGE_PID_FILE)" ] && kill -0 "$$(cat "$(BRIDGE_PID_FILE)")" >/dev/null 2>&1; then \ + echo "bridge: running (pid $$(cat "$(BRIDGE_PID_FILE)"))"; \ + elif [ -n "$$(lsof -t -iTCP:$(DEV_WS_PORT) -sTCP:LISTEN 2>/dev/null || true)" ]; then \ + echo "bridge: running (listener na porta $(DEV_WS_PORT) sem pid file)"; \ + else \ + echo "bridge: stopped"; \ + fi + +bridge-logs: + @if [ ! -f "$(BRIDGE_LOG_FILE)" ]; then \ + echo "ERRO: log do bridge nao encontrado em $(BRIDGE_LOG_FILE)"; \ + exit 1; \ + fi + @tail -f "$(BRIDGE_LOG_FILE)" + +local-up: livekit bridge-up agent-up + @echo "[make] ambiente local iniciado" + @echo "[make] bridge: http://127.0.0.1:$(DEV_WS_PORT)/voice-client" + @echo "[make] livekit: ws://127.0.0.1:$(LIVEKIT_PORT_HTTP)" + @echo "[make] logs: $(RUN_DIR)" + +local-down: + @$(MAKE) --no-print-directory agent-down + @$(MAKE) --no-print-directory bridge-down + @$(MAKE) --no-print-directory livekit-stop + +local-status: + @$(MAKE) --no-print-directory bridge-status + @$(MAKE) --no-print-directory agent-status + @$(MAKE) --no-print-directory livekit-status + +local-logs: + @echo "bridge log: $(BRIDGE_LOG_FILE)" + @echo "agent log: $(AGENT_LOG_FILE)" + +local-stresstest: check-venv + @diag_dump_dir="$${STT_DUMP_DIR:-$${STRESS_REPORT_DIR:-$(RUN_DIR)/local-stresstest}/stt_dumps}"; \ + mkdir -p "$$diag_dump_dir"; \ + if [ "$${STRESS_RESTART_AGENT_FOR_DIAGNOSTICS:-1}" = "1" ]; then \ + $(MAKE) --no-print-directory agent-down; \ + fi; \ + AGENT_DIAG_STT_DUMP_DIR="$$diag_dump_dir" \ + AGENT_DIAG_FLOW_LOG_VAD_DECISIONS="$${FLOW_LOG_VAD_DECISIONS:-1}" \ + AGENT_DIAG_FLOW_LOG_VAD_ACTIVITY="$${FLOW_LOG_VAD_ACTIVITY:-1}" \ + AGENT_DIAG_FLOW_LOG_VAD_ACTIVITY_MIN_PROB="$${FLOW_LOG_VAD_ACTIVITY_MIN_PROB:-0.03}" \ + $(MAKE) --no-print-directory local-up + @set -a; [ -f "$(ENV_DEV)" ] && source "$(ENV_DEV)"; set +a; \ + STT_DUMP_DIR="$${STT_DUMP_DIR:-$${STRESS_REPORT_DIR:-$(RUN_DIR)/local-stresstest}/stt_dumps}" \ + FLOW_LOG_VAD_DECISIONS="$${FLOW_LOG_VAD_DECISIONS:-1}" \ + FLOW_LOG_VAD_ACTIVITY="$${FLOW_LOG_VAD_ACTIVITY:-1}" \ + FLOW_LOG_VAD_ACTIVITY_MIN_PROB="$${FLOW_LOG_VAD_ACTIVITY_MIN_PROB:-0.03}" \ + STRESS_ENV_FILE="$(ENV_DEV)" \ + STRESS_BRIDGE_URL="$${STRESS_BRIDGE_URL:-ws://127.0.0.1:$(DEV_WS_PORT)/ws/agent}" \ + STRESS_BRIDGE_HEALTH_URL="$${STRESS_BRIDGE_HEALTH_URL:-http://127.0.0.1:$(DEV_WS_PORT)/health}" \ + STRESS_AGENT_HEALTH_URL="$${STRESS_AGENT_HEALTH_URL:-http://127.0.0.1:$(AGENT_SERVER_PORT)/}" \ + PYTHONPATH="$(PYTHONPATH_HOST):$$PYTHONPATH" \ + $(PY) -m app.tools.local_stresstest + +# ====================== +# PROD (podman): build + models + up +# ====================== +docker-build: + # build sempre mirando PROD (env file usado no run) e bridge exposto em 8000 + podman build --no-cache -t $(IMAGE) . + +docker-models: docker-build + @podman volume create $(HF_CACHE_VOL) >/dev/null 2>&1 || true + @echo "[make] downloading turn-detector models into volume $(HF_CACHE_VOL)..." + podman run --rm --network host \ + --env-file $(ENV_PROD) \ + -v $(HF_CACHE_VOL):$(HF_HOME_CONT):Z \ + -e HF_HOME=$(HF_HOME_CONT) \ + -e HF_HUB_CACHE=$(HF_HUB_CACHE_CONT) \ + -e PYTHONPATH=$(PYTHONPATH_CONT) \ + $(IMAGE) \ + python -m $(AGENT_FILE) download-files + +docker-up: docker-build docker-models + @podman pod rm -f $(POD_NAME) >/dev/null 2>&1 || true + podman pod create --name $(POD_NAME) --network host + + # AGENT (PROD) - host network + podman run -d --replace --name $(POD_NAME)-agent --pod $(POD_NAME) \ + --env-file $(ENV_PROD) \ + -v $(EXPORT_DIR_HOST):$(EXPORT_DIR_CONT):Z \ + -v $(HF_CACHE_VOL):$(HF_HOME_CONT):Z \ + -e EXPORT_DIR=$(EXPORT_DIR_CONT) \ + -e HF_HOME=$(HF_HOME_CONT) \ + -e HF_HUB_CACHE=$(HF_HUB_CACHE_CONT) \ + -e LIVEKIT_URL=ws://127.0.0.1:$(LIVEKIT_PORT_HTTP) \ + -e PYTHONPATH=$(PYTHONPATH_CONT) \ + $(IMAGE) \ + python -m $(AGENT_FILE) start --log-level info + + # BRIDGE (PROD) - expõe na porta 8000 (host) + podman run -d --replace --name $(POD_NAME)-bridge --pod $(POD_NAME) \ + --env-file $(ENV_PROD) \ + -v $(HF_CACHE_VOL):$(HF_HOME_CONT):Z \ + -e HF_HOME=$(HF_HOME_CONT) \ + -e HF_HUB_CACHE=$(HF_HUB_CACHE_CONT) \ + -e WS_HOST=0.0.0.0 \ + -e WS_PORT=$(PROD_WS_PORT) \ + -e PYTHONPATH=$(PYTHONPATH_CONT) \ + $(IMAGE) \ + python -m $(BRIDGE_MODULE) --host 0.0.0.0 --port $(PROD_WS_PORT) --log-level info + +docker-down: + @podman pod rm -f $(POD_NAME) >/dev/null 2>&1 || true + +docker-status: + @podman pod ps --filter "name=$(POD_NAME)" || true + @podman ps --filter "name=$(POD_NAME)-" || true + +docker-logs: + @podman logs -f $(POD_NAME)-agent + +docker-logs-bridge: + @podman logs -f $(POD_NAME)-bridge + +# ====================== +# LiveKit (host network) +# ====================== +livekit-stop: + @podman rm -f $(LIVEKIT_NAME) >/dev/null 2>&1 || true + +livekit-config-check: + @test -f "$(LIVEKIT_CFG)" || (echo "ERRO: arquivo $(LIVEKIT_CFG) nao existe"; exit 1) + +livekit: livekit-stop livekit-config-check +ifeq ($(OS),Darwin) + podman run -d --name $(LIVEKIT_NAME) \ + -p $(LIVEKIT_PORT_HTTP):7880 \ + -p $(LIVEKIT_PORT_TCP):7881 \ + -v "$(LIVEKIT_CFG):/etc/livekit.yaml:ro" \ + $(LIVEKIT_IMAGE) \ + --config /etc/livekit.yaml + @echo "LiveKit subiu (macOS/Podman: portas publicadas)." + @echo "Signal URL: ws://127.0.0.1:$(LIVEKIT_PORT_HTTP)" + @echo "RTC TCP URL: 127.0.0.1:$(LIVEKIT_PORT_TCP)" +else + podman run -d --name $(LIVEKIT_NAME) \ + --network host \ + -v "$(LIVEKIT_CFG):/etc/livekit.yaml:ro" \ + $(LIVEKIT_IMAGE) \ + --config /etc/livekit.yaml + @echo "LiveKit subiu (host network)." + @echo "Signal URL: ws://127.0.0.1:$(LIVEKIT_PORT_HTTP)" +endif + +livekit-logs: + podman logs -f $(LIVEKIT_NAME) + +livekit-status: + @podman ps --filter "name=$(LIVEKIT_NAME)" + +CONTAINER=api-tia-pod-agent +SHELL_IN=sh + +BACKUP_DIR=backup_logs +DATE=$(shell date +%Y%m%d_%H%M%S) + +copy-logs: + @DEST="backup_logs_$$(date +%Y%m%d_%H%M%S)" && \ + mkdir -p "$$DEST" && \ + podman cp api-tia-pod-agent:/app/logs "$$DEST/logs" || true && \ + podman cp api-tia-pod-agent:/app/timeline "$$DEST/timeline" || true && \ + podman cp api-tia-pod-agent:/app/log_agent "$$DEST/log_agent" || true && \ + echo "Copiado para $$DEST" + diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..2f46a22 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,16 @@ +[pytest] +minversion = 7.0 +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + --strict-markers + --cov=src + --cov-report=html + --cov-report=term-missing + -v +markers = + unit: Unit tests + integration: Integration tests + property_test: Property-based tests diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..42df424 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,34 @@ +python-dotenv==1.0.1 +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +httpx==0.27.2 +aiohttp>=3.9,<4 +websockets>=12,<16 +google-cloud-pubsub==2.37.0 +opentelemetry-sdk==1.39.1 +opentelemetry-exporter-otlp-proto-http==1.39.1 +redis==8.0.1 + +livekit==1.0.23 +livekit-api==1.1.0 +livekit-agents[azure,openai,elevenlabs,silero,turn-detector]==1.3.10 +livekit-plugins-azure==1.3.10 +livekit-plugins-turn-detector==1.3.10 + +soundfile==0.12.1 + +langgraph==1.0.8 +langchain==1.2.10 +langchain-openai==1.1.10 +langchain-oci==0.2.5 +langfuse==3.10.0 +oci>=2,<3 + +num2words==0.5.14 + +vosk>=0.3.44,<0.4.0 + +elevenlabs==2.22.1 + +pytest>=8,<10 +pytest-cov>=4,<6 diff --git a/scripts/deploy-regional-k8s.sh b/scripts/deploy-regional-k8s.sh new file mode 100644 index 0000000..ec26b12 --- /dev/null +++ b/scripts/deploy-regional-k8s.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${1:-$ROOT/k8s/regional/regions.env}" +OUT="${2:-$ROOT/k8s/regional/rendered}" +"$ROOT/scripts/render-regional-k8s.sh" "$ENV_FILE" "$OUT" +kubectl apply -f "$OUT/service.yaml" +kubectl apply -f "$OUT/pdb.yaml" +kubectl apply -f "$OUT/deployment-ord.yaml" +kubectl apply -f "$OUT/deployment-iad.yaml" +kubectl apply -f "$OUT/hpa-ord.yaml" +kubectl apply -f "$OUT/hpa-iad.yaml" +kubectl rollout status -f "$OUT/deployment-ord.yaml" --timeout=10m +kubectl rollout status -f "$OUT/deployment-iad.yaml" --timeout=10m +kubectl get pods -l app="$(grep '^APP_NAME=' "$ENV_FILE" | cut -d= -f2)-regional" -o wide diff --git a/scripts/render-regional-k8s.sh b/scripts/render-regional-k8s.sh new file mode 100644 index 0000000..8da0992 --- /dev/null +++ b/scripts/render-regional-k8s.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${1:-$ROOT/k8s/regional/regions.env}" +OUT="${2:-$ROOT/k8s/regional/rendered}" +[[ -f "$ENV_FILE" ]] || { echo "Missing $ENV_FILE (copy regions.env.example)" >&2; exit 2; } +set -a; source "$ENV_FILE"; set +a +command -v envsubst >/dev/null || { echo "envsubst is required (gettext package)" >&2; exit 2; } +rm -rf "$OUT"; mkdir -p "$OUT" +render_region() { + local prefix="$1" + export REGION_ID REGION_REPLICAS XAI_UPSTREAM_URL XAI_SECRET_NAME + REGION_ID="$(eval echo \"\${${prefix}_REGION_ID}\")" + REGION_REPLICAS="$(eval echo \"\${${prefix}_REGION_REPLICAS}\")" + XAI_UPSTREAM_URL="$(eval echo \"\${${prefix}_XAI_UPSTREAM_URL}\")" + XAI_SECRET_NAME="$(eval echo \"\${${prefix}_XAI_SECRET_NAME}\")" + envsubst < "$ROOT/k8s/regional/deployment-region.yaml" > "$OUT/deployment-${REGION_ID}.yaml" + envsubst < "$ROOT/k8s/regional/hpa-region.yaml" > "$OUT/hpa-${REGION_ID}.yaml" +} +render_region ORD +render_region IAD +envsubst < "$ROOT/k8s/regional/service.yaml" > "$OUT/service.yaml" +envsubst < "$ROOT/k8s/regional/pdb.yaml" > "$OUT/pdb.yaml" +echo "Rendered manifests in $OUT" diff --git a/scripts/validate-regional-k8s.sh b/scripts/validate-regional-k8s.sh new file mode 100644 index 0000000..9c70e92 --- /dev/null +++ b/scripts/validate-regional-k8s.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ENV_FILE="${1:-$ROOT/k8s/regional/regions.env}" +OUT="${2:-$ROOT/k8s/regional/rendered}" +"$ROOT/scripts/render-regional-k8s.sh" "$ENV_FILE" "$OUT" +for f in "$OUT"/*.yaml; do + echo "== validating $(basename "$f") ==" + kubectl apply --dry-run=client -f "$f" >/dev/null + echo OK +done diff --git a/sh/run_export.sh b/sh/run_export.sh new file mode 100644 index 0000000..0149245 --- /dev/null +++ b/sh/run_export.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +ORACLE_HOME=/opt/oracle/instantclient_21_9 +export PATH=$ORACLE_HOME:$PATH LD_LIBRARY_PATH=$ORACLE_HOME + +sqlplus -s admin/@ @export_data_pump.sql + +if [ $? -ne 0 ]; then + echo "Export falhou em $(date)" + exit 1 +else + echo "Export concluído com sucesso em $(date)" +fi \ No newline at end of file diff --git a/src/agent/base/base_classifier.py b/src/agent/base/base_classifier.py new file mode 100644 index 0000000..2fee1fd --- /dev/null +++ b/src/agent/base/base_classifier.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import warnings +from functools import lru_cache +from importlib import resources +from typing import Any, Dict + +from langchain_core.prompts import PromptTemplate +from langchain_openai import ChatOpenAI +from langchain_oci import ChatOCIGenAI +from langchain_core.runnables import RunnablePassthrough + +warnings.filterwarnings("ignore") + +from dotenv import load_dotenv +import os +load_dotenv() + +class BaseClassifier: + MODEL_NAME = "openai/gpt-oss-20b" + API_KEY = "fake-key" + BASE_URL = "http://10.153.34.154/gpt-oss-20b/v1" + REASONING_EFFORT = "low" + PROMPT_FILE: str = "" + + def __init__(self, *, temperature: float = 0.0) -> None: + self.llm = ChatOCIGenAI( + model_id=os.getenv("OCI_ENDPOINT_ID", ""), + service_endpoint=os.getenv("OCI_ENDPOINT", ""), + compartment_id=os.getenv("OCI_COMPARTMENT_ID", ""), + auth_file_location=os.getenv("OCI_AUTH_FILE_LOCATION", "./config"), + model_kwargs={"temperature": 0.0, + "top_p": 0.1, + "reasoning_effort":"MINIMAL"} + ) + """self.llm = ChatOpenAI( + model="openai/gpt-oss-20b", + api_key="fake-key", + base_url="http://10.153.34.154/gpt-oss-20b/v1", + temperature=0.0, + top_p=0.1, + reasoning_effort="low" + )""" + + self.prompt = PromptTemplate( + template=self._load_prompt(), + input_variables=["text"], + partial_variables=self._partial_variables(), + ) + + self.chain = ( + {"text": RunnablePassthrough()} + | self.prompt + | self.llm + ) + + @classmethod + @lru_cache + def _load_prompt(cls) -> str: + return resources.files("agent.prompts").joinpath(cls.PROMPT_FILE).read_text() + + def _partial_variables(self) -> Dict[str, Any]: + return {} + + def run(self, text: str) -> Dict[str, Any]: + raise NotImplementedError("Subclasses devem implementar o método run()") diff --git a/src/agent/base/base_stage.py b/src/agent/base/base_stage.py new file mode 100644 index 0000000..79e7758 --- /dev/null +++ b/src/agent/base/base_stage.py @@ -0,0 +1,205 @@ +# base/base.py +import os +import time +from langgraph.prebuilt import create_react_agent +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.messages import HumanMessage, AIMessage, SystemMessage, trim_messages +from langchain_core.callbacks import BaseCallbackHandler +from langchain_openai import ChatOpenAI +from langchain_oci import ChatOCIGenAI +#from langfuse import get_client +#from langfuse.langchain import CallbackHandler as LangfuseCallbackHandler +from functools import lru_cache +import queue, threading + +from dotenv import load_dotenv +import os +load_dotenv() + +class StreamCaptureHandler(BaseCallbackHandler): + def __init__(self): + self.tokens = [] + self.buffer = "" + self.final_result = None + self.steps = [] + + def on_llm_new_token(self, token, **kwargs): + self.tokens.append(token) + + def on_chain_end(self, outputs, **kwargs): + self.final_result = outputs + + def on_tool_end(self, output, **kwargs): + self.steps.append(output) + +@lru_cache(maxsize=32) +def load_langfuse_prompt( + prompt_name: str, + ttl_seconds: int = 300, +) -> str: + """ + Carrega prompt do Langfuse com cache por processo + e revalidação automática a cada 5 minutos. + """ + prompt = langfuse.get_prompt( + name=prompt_name, + cache_ttl_seconds=ttl_seconds, + ) + return prompt.prompt + +#langfuse = get_client() +#lf_handler = LangfuseCallbackHandler() + +class BaseAgent: + def __init__(self, + tools: list, + streaming: bool = False, + prompt_vars: dict | None = None, + agent_name: str = None, + dynamic_prompt: bool = False): + + self.streaming = streaming + self.prompt_vars = prompt_vars or {} + self.agent_name = agent_name + self.dynamic_prompt = dynamic_prompt + + self.llm = ChatOCIGenAI( + model_id=os.getenv("OCI_ENDPOINT_ID", ""), + service_endpoint=os.getenv("OCI_ENDPOINT", ""), + compartment_id=os.getenv("OCI_COMPARTMENT_ID", ""), + auth_file_location=os.getenv("OCI_AUTH_FILE_LOCATION", "./config"), + model_kwargs={"temperature": 0.0, + "top_p": 0.1, + "reasoning_effort":"MINIMAL"} + ) + """self.llm = ChatOpenAI( + model="openai/gpt-oss-20b", + api_key="fake-key", + base_url="http://10.153.34.154/gpt-oss-20b/v1", + temperature=0.0, + top_p=0.1, + reasoning_effort="low", + streaming=self.streaming, + )""" + + self.stream_handler = StreamCaptureHandler() + + raw_prompt = self._load_prompt() + #raw_prompt = load_langfuse_prompt(self.agent_name) + + # Aplica variáveis parciais no template do prompt + if self.prompt_vars: + for key, value in self.prompt_vars.items(): + raw_prompt = raw_prompt.replace("{" + key + "}", str(value)) + + self.system_prompt = raw_prompt + + # Histórico de mensagens gerenciado manualmente (substitui ConversationBufferWindowMemory) + self.messages: list = [] + self.k = 30 # window size — últimas k*2 mensagens (k pares human/ai) + + # Cria o agente usando langgraph-prebuilt create_react_agent + # Se dynamic_prompt=True, usa lambda que lê self.system_prompt a cada invocação + # permitindo que o prompt seja atualizado entre chamadas (ex: UnifiedAgent) + if self.dynamic_prompt: + self.agent_exec = create_react_agent( + model=self.llm, + tools=tools, + prompt=lambda state: [SystemMessage(content=self.system_prompt)] + state["messages"], + ) + else: + self.agent_exec = create_react_agent( + model=self.llm, + tools=tools, + prompt=self.system_prompt, + ) + + def _load_prompt(self) -> str: + raise NotImplementedError + + def _get_trimmed_messages(self) -> list: + """Retorna as últimas k*2 mensagens (janela deslizante).""" + max_messages = self.k * 2 + if len(self.messages) > max_messages: + return self.messages[-max_messages:] + return list(self.messages) + + def inject_user_message(self, text: str): + self.messages.append(HumanMessage(content=text)) + + def inject_ai_message(self, text: str): + self.messages.append(AIMessage(content=text)) + + # ================= RUN NORMAL ====================== + def run(self, user_input: str): + # Adiciona a mensagem do usuário ao histórico + self.messages.append(HumanMessage(content=user_input)) + + # Prepara mensagens com window trimming + input_messages = self._get_trimmed_messages() + + result = self.agent_exec.invoke( + {"messages": input_messages}, + #config={"callbacks": [self.stream_handler, lf_handler]} + ) + + #print("Texto:",result["messages"][-1].content) + #print("Metadata:",result["messages"][-1].usage_metadata) + #print("*"*100) + + # Extrai a última mensagem do AI do resultado + output_messages = result.get("messages", []) + ai_response = "" + tool_steps = [] + + for msg in output_messages: + if isinstance(msg, AIMessage) and msg.content and not getattr(msg, 'tool_calls', None): + ai_response = msg.content + + # Coleta intermediate tool steps + for msg in output_messages: + if hasattr(msg, 'tool_calls') and msg.tool_calls: + tool_steps.extend(msg.tool_calls) + + # Adiciona a resposta do AI ao histórico + if ai_response: + ai_response = ai_response.split('###commentary')[0].strip() # remove commentary + self.messages.append(AIMessage(content=ai_response)) + + #print(self.messages) + + return { + "output": ai_response, + "tools": tool_steps + } + + # ================= STREAMING ====================== + def stream_run(self, user_input: str): + self.stream_handler.tokens = [] + + # Adiciona a mensagem do usuário ao histórico + self.messages.append(HumanMessage(content=user_input)) + + # Prepara mensagens com window trimming + input_messages = self._get_trimmed_messages() + + # Usa streaming do agente + ai_response = "" + for chunk in self.agent_exec.stream( + {"messages": input_messages}, + config={"callbacks": [self.stream_handler]}#, lf_handler + ): + # Processa chunks de streaming + for node_name, node_output in chunk.items(): + if node_name == "agent" and "messages" in node_output: + for msg in node_output["messages"]: + if isinstance(msg, AIMessage) and msg.content: + ai_response = msg.content + for token in msg.content: + yield token + + # Adiciona a resposta ao histórico + if ai_response: + self.messages.append(AIMessage(content=ai_response)) + + yield "" diff --git a/src/agent/classifier/check_guardrail.py b/src/agent/classifier/check_guardrail.py new file mode 100644 index 0000000..33adddd --- /dev/null +++ b/src/agent/classifier/check_guardrail.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import json +import re +from typing import Any, Dict + +from langchain_core.output_parsers import PydanticOutputParser +from pydantic import BaseModel + +from agent.base.base_classifier import BaseClassifier + + +def get_json(text: str) -> str: + pattern = r"```json(.*?)```" + match = re.search(pattern, text, re.DOTALL) + if match: + return match.group(1).strip() + raise ValueError("JSON não encontrado na resposta do modelo.") + + +class GuardrailResult(BaseModel): + is_attack: bool + + +parser = PydanticOutputParser(pydantic_object=GuardrailResult) + + +class CheckGuardrail(BaseClassifier): + PROMPT_FILE = "check_guardrail.txt" + REASONING_EFFORT = "low" + + def _partial_variables(self) -> Dict[str, Any]: + return { + "format_instructions": parser.get_format_instructions() + } + + def run(self, text: str) -> Dict[str, Any]: + output = self.chain.invoke(text) + json_text = get_json(output.content) + return parser.parse(json_text).dict() diff --git a/src/agent/classifier/conversation_classifier.py b/src/agent/classifier/conversation_classifier.py new file mode 100644 index 0000000..da2bd90 --- /dev/null +++ b/src/agent/classifier/conversation_classifier.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import json +from datetime import datetime +from typing import Any, Dict + +import langfuse + +from agent.base.base_classifier import BaseClassifier + + +client = langfuse.Langfuse(timeout=30) + + +class ConversationClassifier(BaseClassifier): + PROMPT_FILE = "conversation_classifier.txt" + REASONING_EFFORT = "low" + + def run(self, text: str) -> Dict[str, Any]: + output = self.chain.invoke(text) + return json.loads(output.content) + + +def get_historic_conversation(trace_id): + trace = client.api.trace.get(trace_id).dict() + ordered_obs = sorted( + trace["observations"], + key=lambda x: x.get("startTime") or "" + ) + + conversation = "" + for obs in ordered_obs: + meta = obs.get("metadata", {}) + stage = meta.get("stage") + + if not stage or stage == "check_guardrail": + continue + + model_msg = obs.get("output") + human_msg = obs.get("input") + + if isinstance(human_msg, dict): + human_msg = human_msg.get("text") + + conversation += ( + f"ESTÁGIO:{stage}\n" + f"HUMANO:{human_msg}\n" + f"MODELO:{model_msg}\n\n" + ) + + return conversation.strip() + + +def format_success_purchase(raw_mailing): + allowed_keys = [ + 'NUM_TELEFONE', 'DATA_VENCIMENTO', 'NOME_CLIENTE_COMPLETO', + 'NUM_CPF_CNPJ_CLIENTE', 'ACAO', 'BONUS_DESTINO', 'FLG_FID', + 'USO_DE_DADOS', 'DESCONTO_DESTINO', 'DAT_NASCIMENTO', + 'VALOR_PLANO_CORE', 'VLR_PLANO_DESTINO', + 'VLR_FINAL_PLANO_DEST', 'DADOS_CORE', 'ENDERECO', + 'UF', 'TIPO_MAILING', 'AGING_PLANO' + ] + return {k: v for k, v in raw_mailing.items() if k in allowed_keys} + + +def format_call(raw_mailing, reason_status, summary): + return { + "DATA": datetime.now().strftime("%Y-%m-%d"), + "HORA": datetime.now().strftime("%H:%M:%S"), + "NUM_ACESSO": raw_mailing["NUM_TELEFONE"], + "NOME": raw_mailing["NOME_CLIENTE_COMPLETO"], + "NUM_DOCUMENTO": raw_mailing["NUM_CPF_CNPJ_CLIENTE"], + "STATUS": "CONTATO", + "DESC_STATUS": list(reason_status.keys())[0], + "MOTIVO_STATUS": list(reason_status.values())[0], + "RESUMO": summary, + "QTD_TENTATIVAS": 1, + "NOME_MAILING": raw_mailing["MAILING"], + "SEGMENTACAO1": "CONTROLE_POS", + } diff --git a/src/agent/classifier/conversation_summary.py b/src/agent/classifier/conversation_summary.py new file mode 100644 index 0000000..613a421 --- /dev/null +++ b/src/agent/classifier/conversation_summary.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from typing import Any, Dict + +from agent.base.base_classifier import BaseClassifier + +class ConversationSummary(BaseClassifier): + PROMPT_FILE = "conversation_summary.txt" + REASONING_EFFORT = "low" + + def run(self, text: str) -> str: + output = self.chain.invoke(text) + return output.content diff --git a/src/agent/classifier/interruption_classifier.py b/src/agent/classifier/interruption_classifier.py new file mode 100644 index 0000000..398522f --- /dev/null +++ b/src/agent/classifier/interruption_classifier.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import json +import re +from typing import Any, Dict + +from langchain_core.output_parsers import PydanticOutputParser +from pydantic import BaseModel + +from agent.base.base_classifier import BaseClassifier +from app.utils.logging import setup_minimal_logging +from app.common.timed import timed +logger = setup_minimal_logging() + +class InterruptionClassifier(BaseClassifier): + PROMPT_FILE = "interruption.txt" + REASONING_EFFORT = "low" + + @timed("InterruptionClassifier.run") + def run(self, text: str) -> bool: + output = self.chain.invoke(text) + logger.info(f"Resultado modelo interrupção: {text}, {bool(eval(output.content))}") + return bool(eval(output.content)) \ No newline at end of file diff --git a/src/agent/pipeline/__main__.py b/src/agent/pipeline/__main__.py new file mode 100644 index 0000000..a97169c --- /dev/null +++ b/src/agent/pipeline/__main__.py @@ -0,0 +1,164 @@ +from pipeline.customer_pipeline import CustomerPipeline + +""" +EXECUÇÃO INTERATIVA VIA TERMINAL +> python -m pipeline +""" + + +def load_mock_data(): + """ + Você pode substituir isso por integração real com seu backend. + Aqui fica só um exemplo de estrutura esperada. + """ + mailing = { + "NUM_TELEFONE": 99999999999, + "DATA_VENCIMENTO": "VENC DIA 20", + "NOME_CLIENTE_COMPLETO": "Maria de Fátima da silva", + "NUM_CPF_CNPJ_CLIENTE": "00000000123", + "NOME_MAE": "Mariana da silva", + "VLR_TM_ORIGEM": 79.99, + "PLANO_ORIGEM": " TIM CONTROLE SMART 8 0", + "PLANO_DESTINO": "TIM_BLACK_A", + "ACAO": "CTRL SMART NAO FIDEL_POS ATL A FIDEL", + "BONUS_DESTINO": "15+30", + "PARCEIRO": "ATN", + "FLG_FID": 0, + "USO_DE_DADOS": "Até 500MB", + "DESCONTO_DESTINO": "90 FIDEL", + "PRIORIZACAO": 1, + "CUSTCODE": 1314082913.0, + "TP_BASE": "ESTRUTURAL", + "FLAG_DIRECIONAMENTO": "PADRAO", + "FRESCOR": "M-2", + "MEDIA_RECARGA_2": 67.99, + "DAT_NASCIMENTO": "1968-09-08", + "FAIXA_RECARGA": "61 |- 70", + "FLG_LIVE_MKT": None, + "BONUS_CONVERGENCIA": 0, + "ORDEM": 4.0, + "VALOR_PLANO_CORE": 92.99, + "APP_STR": None, + "OFERTA_LIVE": None, + "IDADE": 56.0, + "DADOS_PERIODO": "TARDE", + "CLASSIFICACAO_RISCO": "BAIXO RISCO", + "VLR_PLANO_DESTINO": 189.99, + "FLG_ICMS": 0, + "DESCONTO_ICMS": 0, + "VLR_FINAL_PLANO_DEST": 99.99, + "FX_IDADE": "51 A 60 ANOS", + "PERSONA_IDADE": "ADULTO EXPERIENTE", + "FX_GB": "02. ATÉ 1GB", + "PERSONA_GB": "0-1 USO MINIMO", + "PERSONA_USO": "TARDES CONECTADAS", + "PERSONA_UF": "METROPOLE DINÂMICA", + "PERSONA_SOCIAL_MEDIA": 0, + "PESONA_STREAMER": 0, + "PERSONA_MUSIC": 0, + "DADOS_CORE": "5GB", + "FX_PROPENSAO": 17.0, + "ENDERECO": "N/A SQS 115 BL I 0 - CEP: 70385090, Cidade: BRASILIA/DF", + "UF": "DF", + "FLG_TRIPLE_A_OP": 0, + "TIPO_MAILING": "CTRL-POS", + "ANOMES": 202509, + "CANAL": "OUTBOUND", + "AGING_PLANO": 39.0, + "DELTA_TICKET": 32.0 + } + actual_plan = { + "plano": "Tim Controle Smart oito ponto zero", + "beneficios_001": None, + "beneficios_002": None, + "beneficios_todos": None, + "apps_zero_rating": "WhatsApp, Messenger, Instagram, Facebook, X", + "categoria_plano": "controle", + "dependentes": None, + "grupo_plano": "Tim Controle (Fatura)", + "forma_pagamento": "Fatura", + "dados_GB": "5", + "comunicacao_whatsapp": None, + "comunicacao_ligacoes_voz": "Ilimitadas", + "comunicacao_sms": "Ilimitados", + "inflight": None, + "roaming_internacional": None, + "servicos_valor_agregado": "Aya Books Premium, Aya Ensinah Premium, EXA Segurança", + "aplicativos_inclusos": None + } + target_plan = { + "plano": "Tim Black C Light", + "beneficios_001": "não consumir seus gigas quando usar os aplicativos Instagram, Facebook e X e quando trocar mensagens no whatsapp.", + "beneficios_002": "não consome seus gigas quando usar as principais redes sociais e tem acesso ao wifi nos aviões da gol e da latam e tem também o pacote chile de roaming internacional.", + "beneficios_todos": "Utilização de ligações SMS, WhatsApp, Instagram, Facebook e X ilimitados, Além acesso ao wifi nos aviões da gol e latam juntamente com o pacote chile de roaming internacional. Além disso diversos serviços extras como Aya Audiobooks Premium, Bancah Premium + Jornais, EXA Segurança Premium, Aya Ensinah Premium, EXA Cloud, Busuu, Fluid Premium", + "apps_zero_rating": "WhatsApp, Instagram, Facebook, X", + "categoria_plano": "pós pago", + "dependentes": "Não há dependentes", + "grupo_plano": "Tim Black", + "forma_pagamento": "Fatura", + "dados_GB": "20", + "comunicacao_whatsapp": "O uso de audio e video consome dos seus gigas, enquanto mensagens são ilimitadas", + "comunicacao_ligacoes_voz": "Ilimitadas", + "comunicacao_sms": "Ilimitados", + "inflight": "Tim no Avião", + "roaming_internacional": "Pacote Chile", + "servicos_valor_agregado": "Aya Audiobooks Premium, Bancah Premium + Jornais, EXA Segurança Premium, Aya Ensinah Premium, EXA Cloud, Busuu, Fluid Premium", + "aplicativos_inclusos": "Não há aplicativo incluso" + } + + return mailing, actual_plan, target_plan + + +def main(): + print("\n==============================") + print(" CUSTOMER PIPELINE (CLI) ") + print("==============================\n") + + # ---------------------------------------------- + # Carrega dados fake ou reais + # ---------------------------------------------- + mailing, actual_plan, target_plan = load_mock_data() + + # ---------------------------------------------- + # Cria o pipeline + # ---------------------------------------------- + pipeline = CustomerPipeline( + mailing=mailing, + actual_plan=actual_plan, + target_plan=target_plan, + streaming=True + ) + + # ---------------------------------------------- + # Prepara (elegibilidade vem do backend real) + # ---------------------------------------------- + intro = pipeline.start() + pipeline.prepare(elegibility=True, protocol = "PRT-2025-0000123456") + # ---------------------------------------------- + # Inicia com mensagem fake do LLM + # ---------------------------------------------- + print(f"[{intro['stage'].upper()}] AGENTE:", intro["response"]) + + # ---------------------------------------------- + # LOOP PRINCIPAL (CLI) + # ---------------------------------------------- + while True: + user = input("\nVocê: ") + + if user.lower() in ["sair", "exit", "quit"]: + print("\nEncerrando manualmente.") + break + + result = pipeline.run(user) + + print(f"[{result['stage'].upper()}] AGENTE:", result["response"]) + print("TOOLS :", result["tools"]) + print("STATE :", result["state"]) + + if result["stage"] == "end": + print("\nPipeline concluído.\n") + break + + +if __name__ == "__main__": + main() diff --git a/src/agent/pipeline/customer_pipeline_langgraph.py b/src/agent/pipeline/customer_pipeline_langgraph.py new file mode 100644 index 0000000..9e5c39b --- /dev/null +++ b/src/agent/pipeline/customer_pipeline_langgraph.py @@ -0,0 +1,942 @@ +from typing import TypedDict, Any, Optional, Tuple +from langgraph.graph import StateGraph, END + +from langfuse import get_client +from agent.stage.argumentation import ArgumentationAgent +from agent.stage.data_confirmation import DataConfirmationAgent +from agent.stage.formalization import FormalizationAgent +from agent.stage.presentation import PresentationAgent +from agent.utils.utils import ( + process_mailing, + process_plan, + get_gender, + get_plan, + normalize_name, + sentence_confidence, + start_message_argumentation, +) + +from agent.classifier.check_guardrail import CheckGuardrail +from agent.classifier.conversation_summary import ConversationSummary +from agent.classifier.conversation_classifier import ( + get_historic_conversation, + ConversationClassifier, + format_success_purchase, + format_call, +) + +from app.common.timed import timed +from langfuse.langchain import CallbackHandler +import asyncio +import copy +import os +import json +import math +import time +from datetime import datetime +from pathlib import Path +from app.utils.logging import setup_minimal_logging + +logger = setup_minimal_logging() +langfuse = get_client() +langfuse_handler = CallbackHandler() + +# ===================== CONTROLE DE LOG DO AGENTE ===================== +ENABLE_AGENT_LOG = True +# ===================================================================== + + +class AgentLogger: + """Logger que grava em TXT todo o ciclo de vida do pipeline do agente.""" + + def __init__(self, telefone: str): + self.telefone = str(telefone) + self.start_ts = datetime.now() + ts_str = self.start_ts.strftime("%H%M%S") + + base_dir = Path("log_agent") / self.start_ts.strftime("%Y-%m-%d") / self.start_ts.strftime("%H") + base_dir.mkdir(parents=True, exist_ok=True) + + self.filepath = base_dir / f"{ts_str}_{self.telefone}.txt" + self._lines: list[str] = [] + self._write_header() + + # ---- helpers internos ---- + def _write_header(self): + self._append("=" * 80) + self._append(f"AGENT LOG — Telefone: {self.telefone}") + self._append(f"Início da sessão: {self.start_ts.isoformat()}") + self._append("=" * 80) + self._append("") + + def _ts(self) -> str: + return datetime.now().strftime("%H:%M:%S.%f")[:-3] + + def _append(self, text: str): + self._lines.append(text) + + def _flush(self): + with open(self.filepath, "w", encoding="utf-8") as f: + f.write("\n".join(self._lines)) + + def _format_messages(self, messages: list) -> str: + parts = [] + for msg in messages: + role = type(msg).__name__.replace("Message", "").upper() + content = getattr(msg, "content", str(msg)) + tool_calls = getattr(msg, "tool_calls", None) + line = f" [{role}] {content}" + if tool_calls: + line += f" | tool_calls={tool_calls}" + parts.append(line) + return "\n".join(parts) if parts else " (vazio)" + + def _format_state(self, state_obj) -> str: + if state_obj is None: + return " (sem state)" + d = {k: v for k, v in state_obj.__dict__.items() if not k.startswith("_")} + parts = [f" {k}: {v}" for k, v in d.items()] + return "\n".join(parts) + + def _format_dict(self, d: dict, indent: int = 4) -> str: + prefix = " " * indent + parts = [] + for k, v in d.items(): + parts.append(f"{prefix}{k}: {v}") + return "\n".join(parts) if parts else f"{prefix}(vazio)" + + # ---- métodos públicos de logging ---- + + def log_section(self, title: str): + self._append("") + self._append("-" * 80) + self._append(f"[{self._ts()}] {title}") + self._append("-" * 80) + self._flush() + + def log(self, label: str, content: str = ""): + if content: + self._append(f" [{self._ts()}] {label}: {content}") + else: + self._append(f" [{self._ts()}] {label}") + self._flush() + + def log_system_prompt(self, agent_name: str, prompt: str): + self._append(f" [{self._ts()}] SYSTEM PROMPT ({agent_name}):") + for line in prompt.splitlines(): + self._append(f" | {line}") + self._flush() + + def log_messages(self, label: str, messages: list): + self._append(f" [{self._ts()}] {label} — Mensagens ({len(messages)}):") + self._append(self._format_messages(messages)) + self._flush() + + def log_agent_state(self, stage: str, state_obj): + self._append(f" [{self._ts()}] STATE ({stage}):") + self._append(self._format_state(state_obj)) + self._flush() + + def log_metadata(self, metadata: dict): + self._append(f" [{self._ts()}] METADATA:") + self._append(self._format_dict(metadata)) + self._flush() + + def log_stage_result(self, stage: str, output: str, next_stage: str, auto: bool): + self._append(f" [{self._ts()}] RESULTADO do estágio '{stage}':") + self._append(f" output: {output}") + self._append(f" próximo estágio: {next_stage}") + self._append(f" auto: {auto}") + self._flush() + + +class PipelineState(TypedDict, total=False): + stage: str + user_input: dict + auto: bool + output: str + is_attack: bool + + +class CustomerPipeline: + def __init__(self, mailing, streaming=False): + self.streaming = streaming + self.stage = "presentation" + self.classified_conversation = None + self.accepted_purchase = False + self.name = None + self.attacks = 0 + + self.prompt_vars = { + "raw_customer": mailing, + "customer": None, + "actual_plan": None, + "target_plan": None, + "elegibility": None, + } + + self.trace_id = langfuse.create_trace_id() + self.intro = None + self.graph = None + self.agent = None + + # Buffer do último output (aguarda sinal do backend) + self.pending_input = None + self.pending_output = None + self.pending_stage = None + self.pending_metadata = None + + # Logger do agente (TXT) + telefone = mailing.get("NUM_TELEFONE", "sem_telefone") + self.agent_log: AgentLogger | None = AgentLogger(telefone) if ENABLE_AGENT_LOG else None + + # ==================================================================================== + # Buffer + commit no Langfuse + + def _buffer_langfuse_trace(self, input_value: Any, output_value: str): + """ + Guarda o último par (input, output) gerado pelo agente para só subir no Langfuse + quando o backend sinalizar se foi ouvido por completo ou interrompido. + """ + self.pending_input = input_value + self.pending_output = output_value + self.pending_stage = self.stage + self.pending_metadata = self.get_formatted_metadata() + + def update_langfuse_trace(self, final_output: str, was_interrupted: bool = False): + """ + Faz o commit no Langfuse usando os campos pendentes (input/stage/metadata) + e o output final (completo ou truncado). + """ + #print('-'*30) + #print(final_output,was_interrupted) + #print(self.pending_input) + if self.pending_stage is None: + return + + metadata = self.pending_metadata or {} + metadata = {**metadata, "interrupted": was_interrupted} + + with langfuse.start_as_current_observation( + as_type="span", + name=self.pending_stage, + trace_context={"trace_id": self.trace_id}, + ) as span: + langfuse.update_current_span( + metadata=metadata, + name=self.pending_stage, + output=final_output, + input=self.pending_input, + ) + + def update_langfuse_auto(self, input, output): + with langfuse.start_as_current_observation( + as_type="span", + name=self.stage, + trace_context={"trace_id": self.trace_id}, + ) as span: + langfuse.update_current_span( + metadata=self.get_formatted_metadata(), + name=self.stage, + output=output, + input=input + ) + + # ==================================================================================== + # Backend sinaliza consumo/interrupção + + def set_interruption( + self, + was_interrupted: bool, + listened_text: Optional[str] = None, + skipped_vacalization: bool = False): + + logger.info( + "SET_INTERRUPTION | was_interrupted=%s | listened_text=%r | skipped_vacalization=%s", + was_interrupted, + listened_text, + skipped_vacalization, + ) + + if not self.pending_output: + return + + # Definimos a marca de interrupção + INTERRUPTION_TAG = "###interrupção###" + + if skipped_vacalization: + # Caso 1: O áudio nem chegou a tocar (interrupção imediata ou erro) + final_output = INTERRUPTION_TAG + was_interrupted_flag = True + self.agent.state.restore_state() + self.agent.set_interruption_message(final_output) + + elif was_interrupted: + # Caso 2: Estava falando e foi cortado + # Se ouviu algo, usa o trecho + tag. Se não ouviu nada, apenas a tag. + final_output = f"{listened_text or ''} {INTERRUPTION_TAG}".strip() + was_interrupted_flag = True + self.agent.set_interruption_message(final_output) + + else: + # Caso 3: Fluxo normal (não foi interrompido) + final_output = self.pending_output + was_interrupted_flag = False + + # Log de interrupção + if self.agent_log: + self.agent_log.log_section("INTERRUPÇÃO") + self.agent_log.log("was_interrupted", str(was_interrupted)) + self.agent_log.log("skipped_vacalization", str(skipped_vacalization)) + self.agent_log.log("listened_text", str(listened_text)) + self.agent_log.log("final_output", final_output) + self.agent_log.log("was_interrupted_flag", str(was_interrupted_flag)) + self.agent_log.log_agent_state(self.stage, self.agent.state if self.agent else None) + + # Atualiza o Rastreamento (Langfuse) + self.update_langfuse_trace( + final_output, + was_interrupted=was_interrupted_flag, + ) + + # Limpa buffer + self.pending_input = None + self.pending_output = None + self.pending_stage = None + self.pending_metadata = None + + + # ==================================================================================== + def start(self): + first_name = self.prompt_vars["raw_customer"]["NOME_CLIENTE_COMPLETO"].split()[0].lower() + self.intro = ( + f"Olá, meu nome é Helena, sou consultora de vendas da TIM, para sua segurança a ligação está sendo gravada. " + f"Eu estou falando com {first_name}??" + ) + self.stage = "presentation" + self.name = "presentation" + + if self.agent_log: + self.agent_log.log_section("START") + self.agent_log.log("stage", self.stage) + self.agent_log.log("intro", self.intro) + + return self.stage, self.intro + + def argumentation_start(self): + + text = start_message_argumentation({ + "cliente_alvo_primeiro_nome": self.prompt_vars["customer"]["cliente_alvo_primeiro_nome"].lower(), + "meses_restantes_fidelizacao": self.prompt_vars["actual_plan"]["meses_restantes_fidelizacao"], + "quanto_pago_a_mais": self.prompt_vars["target_plan"]["quanto_pago_a_mais"], + + "quanto_pago_a_mais_int": abs(math.ceil(self.prompt_vars["raw_customer"]["VLR_FINAL_PLANO_DEST"] - self.prompt_vars["raw_customer"]["MEDIA_RECARGA_2"])), + + "gb_plano_atual": self.prompt_vars["actual_plan"]["gb_plano_atual"], + "valor_plano_sem_fidelização": self.prompt_vars["actual_plan"]["valor_plano_sem_fidelização"], + "dados_GB": self.prompt_vars["target_plan"]["dados_GB"], + "beneficios_001": self.prompt_vars["target_plan"]["beneficios_001"], + "valor_plano_final": self.prompt_vars["target_plan"]["valor_plano_final"], + "gb_alvo_diferenca": self.prompt_vars["target_plan"]["gb_alvo_diferenca"], + "preco_por_dia": self.prompt_vars["target_plan"]["preço_por_dia"] + }) + self.agent.inject_user_message("###START###") + self.agent.inject_ai_message(text) + return text + + # ==================================================================================== + def prepare(self, elegibility: bool, protocol: str): + self.stage = "presentation" + self.name = "presentation" + + self.prompt_vars["customer"] = copy.deepcopy(self.prompt_vars["raw_customer"]) + self.prompt_vars["customer"]["PROTOCOLO"] = protocol + + self.prompt_vars["elegibility"] = elegibility + + self.prompt_vars["actual_plan"] = process_plan({}, self.prompt_vars["customer"], "actual") + self.prompt_vars["target_plan"] = process_plan( + get_plan(self.prompt_vars["customer"]["PLANO_DESTINO"]), + self.prompt_vars["customer"], + "target", + ) + + self.prompt_vars["customer"] = get_gender(self.prompt_vars["customer"]) + self.prompt_vars["customer"]["NOME_CLIENTE_COMPLETO"] = normalize_name( + self.prompt_vars["customer"]["NOME_CLIENTE_COMPLETO"] + ) + + self.prompt_vars["customer"] = process_mailing(self.prompt_vars["customer"]) + + self.agent = PresentationAgent( + streaming=self.streaming, + prompt_vars={ + "customer_first_name": self.prompt_vars["customer"]["NOME_CLIENTE_COMPLETO"] + .split()[0] + .lower(), + "elegibility": self.prompt_vars["elegibility"], + }, + ) + + self.agent.inject_user_message("###START###") + self.agent.inject_ai_message(self.intro) + + self.check_guardrail = CheckGuardrail() + self.conversation_classifier = ConversationClassifier() + self.conversation_summary = ConversationSummary() + + self.update_langfuse_auto({"text": "###START###", "prompt_vars": self.prompt_vars}, + self.intro,) + + # Log do prepare + if self.agent_log: + self.agent_log.log_section("PREPARE") + self.agent_log.log("elegibility", str(elegibility)) + self.agent_log.log("protocol", protocol) + self.agent_log.log_system_prompt("presentation", self.agent.system_prompt) + self.agent_log.log_messages("Mensagens iniciais (presentation)", self.agent.messages) + self.agent_log.log("prompt_vars", str(self.prompt_vars)) + + self.graph = self._build_graph() + + # ==================================================================================== + def _build_graph(self): + g = StateGraph(PipelineState) + + g.add_node("guardrail", self._lg_guardrail) + g.add_node("presentation", self._lg_presentation) + g.add_node("argumentation", self._lg_argumentation) + g.add_node("data_confirmation", self._lg_data_confirmation) + g.add_node("formalization", self._lg_formalization) + + g.set_entry_point("guardrail") + + g.add_conditional_edges( + "guardrail", + self._lg_route_from_guardrail, + { + "presentation": "presentation", + "argumentation": "argumentation", + "data_confirmation": "data_confirmation", + "formalization": "formalization", + "end": END, + }, + ) + + for node in ["presentation", "argumentation", "data_confirmation", "formalization"]: + g.add_conditional_edges( + node, + self._lg_route_after_stage, + { + "presentation": "presentation", + "argumentation": "argumentation", + "data_confirmation": "data_confirmation", + "formalization": "formalization", + "end": END, + }, + ) + + return g.compile() + + # ==================================================================================== + # STT normalization + + def _normalize_stt_input(self, user_input: Any) -> Tuple[str, Optional[list]]: + """ + api_text -> retorna (texto, None) + raw_json -> retorna (data.text, data.words) + """ + mode = os.getenv("STT_OUTPUT_MODE", "api_text").lower() + + if mode != "raw_json": + return str(user_input or ""), None + + try: + payload = json.loads(user_input) if isinstance(user_input, str) else user_input + data = (payload or {}).get("data", {}) + text = str(data.get("text", "") or "") + words = data.get("words", None) + return text, words + except Exception: + return str(user_input or ""), None + + # ==================================================================================== + # Guardrail + + def _lg_guardrail(self, state: PipelineState) -> PipelineState: + raw_input = state.get("user_input", "") + user_input, words = self._normalize_stt_input(raw_input) + + if self.agent_log: + self.agent_log.log_section(f"GUARDRAIL (estágio atual: {self.stage})") + self.agent_log.log("user_input (normalizado)", user_input) + self.agent_log.log("attacks acumulados", str(self.attacks)) + + # Guardrail de confiança (somente raw_json, sem limite) + if words is not None: + conf = sentence_confidence(words) + print("confiança", conf) + if conf < 0.0 or user_input in [None, ""]: + output = "Desculpe, não consegui entender bem. Pode repetir mais devagar, por favor?" + + if self.agent_log: + self.agent_log.log("guardrail_confiança", f"rejeitado (conf={conf})") + self.agent_log.log("output", output) + + # ANTES: subia direto + # AGORA: buffer + self._buffer_langfuse_trace(user_input, output) + + return { + "is_attack": True, + "output": output, + "auto": False, + "stage": state.get("stage", self.stage), + "user_input": user_input, + } + + # Guardrail original (ataque) + #is_attack = self._check_guardrail(user_input) + #print("is_attack", is_attack) + is_attack = False + output = "Desculpe, não entendi. Poderia repetir?" + + if self.attacks > 4: + output = ( + "Notei que temos um problema de comunicação. " + "Irei finalizar a chamada. A TIM agradece a sua atenção" + ) + + if self.agent_log: + self.agent_log.log("guardrail", f"attacks > 4 — encerrando (attacks={self.attacks})") + self.agent_log.log("output", output) + + # buffer + self._buffer_langfuse_trace(user_input, output) + + return { + "is_attack": False, + "output": output, + "auto": False, + "stage": "DONE", + "user_input": user_input, + } + + if is_attack: + self.attacks += 1 + + if self.agent_log: + self.agent_log.log("guardrail", f"ataque detectado (attacks={self.attacks})") + self.agent_log.log("output", output) + + # buffer + self._buffer_langfuse_trace(user_input, output) + + return { + "is_attack": True, + "output": output, + "auto": False, + "stage": state.get("stage", self.stage), + "user_input": user_input, + } + + if self.agent_log: + self.agent_log.log("guardrail", "passou (sem ataque)") + + return {"is_attack": False, "user_input": user_input} + + def _lg_route_from_guardrail(self, state: PipelineState) -> str: + if state.get("is_attack"): + return "end" + + stage = state.get("stage") or self.stage + if stage == "DONE": + return "end" + + return stage + + def _lg_route_after_stage(self, state: PipelineState) -> str: + if state.get("stage") == "DONE": + return "end" + + if state.get("auto"): + return state["stage"] + + return "end" + + # ==================================================================================== + # Stage nodes + + def _get_effective_input(self, state: PipelineState) -> str: + return "###START###" if state.get("auto") else state.get("user_input", "") + + def _lg_presentation(self, state: PipelineState) -> PipelineState: + self.stage = state.get("stage", self.stage) + self.name = self.stage + + text = self._get_effective_input(state) + + if self.agent_log: + self.agent_log.log_section("PRESENTATION") + self.agent_log.log("input", text) + self.agent_log.log_messages("Histórico ANTES do run", self.agent.messages) + + t0 = time.perf_counter() + result = self.agent.run_presentation(text) + elapsed = time.perf_counter() - t0 + + if self.agent_log: + self.agent_log.log("LLM tempo de execução", f"{elapsed:.3f}s") + self.agent_log.log("LLM output", result["output"]) + self.agent_log.log("tool_calls", str(result.get("tools", []))) + self.agent_log.log_agent_state("presentation", self.agent.state) + self.agent_log.log_messages("Histórico DEPOIS do run", self.agent.messages) + + # buffer do output gerado + self._buffer_langfuse_trace(text, result["output"]) + + if self.agent.state.end_conversation: + self.stage = "DONE" + if self.agent_log: + self.agent_log.log_stage_result("presentation", result["output"], "DONE", False) + return {"stage": "DONE", "output": result["output"], "auto": False} + + if self.agent.state.is_target_customer: + self.update_langfuse_auto(text, result["output"]) + self.stage = "argumentation" + self.agent = ArgumentationAgent( + streaming=self.streaming, + prompt_vars=self.prompt_vars, + ) + if self.agent_log: + self.agent_log.log_stage_result("presentation", result["output"], "argumentation", True) + self.agent_log.log_section("TRANSIÇÃO → ARGUMENTATION") + self.agent_log.log_system_prompt("argumentation", self.agent.system_prompt) + return {"stage": "argumentation", "auto": True} + + if not self.prompt_vars["elegibility"]: + self.stage = "DONE" + if self.agent_log: + self.agent_log.log_stage_result("presentation", result["output"], "DONE (sem elegibilidade)", False) + return {"stage": "DONE", "output": result["output"], "auto": False} + + if self.agent_log: + self.agent_log.log_stage_result("presentation", result["output"], self.stage, False) + + return {"stage": self.stage, "output": result["output"], "auto": False} + + def _lg_argumentation(self, state: PipelineState) -> PipelineState: + self.stage = state.get("stage", self.stage) + self.name = self.stage + + if self.agent_log: + self.agent_log.log_section("ARGUMENTATION") + + if state.get("auto"): + result = {} + result['output'] = self.argumentation_start() + text = "###START###" + if self.agent_log: + self.agent_log.log("input (auto)", text) + self.agent_log.log("argumentation_start output", result["output"]) + else: + text = self._get_effective_input(state) + + if self.agent_log: + self.agent_log.log("input", text) + self.agent_log.log_messages("Histórico ANTES do run", self.agent.messages) + + t0 = time.perf_counter() + result = self.agent.run_argumentation(text) + elapsed = time.perf_counter() - t0 + + if self.agent_log: + self.agent_log.log("LLM tempo de execução", f"{elapsed:.3f}s") + self.agent_log.log("LLM output", result["output"]) + self.agent_log.log("tool_calls", str(result.get("tools", []))) + self.agent_log.log_messages("Histórico DEPOIS do run", self.agent.messages) + + if self.agent_log: + self.agent_log.log_agent_state("argumentation", self.agent.state) + + # buffer do output gerado + self._buffer_langfuse_trace(text, result["output"]) + + if self.agent.state.accepted: + self.update_langfuse_auto(text, result["output"]) + self.stage = "data_confirmation" + + self.agent = DataConfirmationAgent( + expected_cpf_last_3=self.prompt_vars["customer"]["NUM_CPF_CNPJ_CLIENTE"], + expected_birth_date=self.prompt_vars["customer"]["DAT_NASCIMENTO"], + streaming=self.streaming, + prompt_vars={"customer": self.prompt_vars["customer"]["NOME_CLIENTE_COMPLETO"]}, + ) + + if self.agent_log: + self.agent_log.log_stage_result("argumentation", result["output"], "data_confirmation", True) + self.agent_log.log_section("TRANSIÇÃO → DATA_CONFIRMATION") + self.agent_log.log_system_prompt("data_confirmation", self.agent.system_prompt) + + return {"stage": "data_confirmation", "auto": True} + + if self.agent.state.end_conversation: + self.stage = "DONE" + if self.agent_log: + self.agent_log.log_stage_result("argumentation", result["output"], "DONE", False) + return {"stage": "DONE", "output": result["output"], "auto": False} + + if self.agent_log: + self.agent_log.log_stage_result("argumentation", result["output"], self.stage, False) + + return {"stage": self.stage, "output": result["output"], "auto": False} + + def _lg_data_confirmation(self, state: PipelineState) -> PipelineState: + self.stage = state.get("stage", self.stage) + self.name = self.stage + + text = self._get_effective_input(state) + text = text.replace("/", "").replace("-", "").replace(".", "") + + if self.agent_log: + self.agent_log.log_section("DATA_CONFIRMATION") + self.agent_log.log("input", text) + self.agent_log.log_messages("Histórico ANTES do run", self.agent.messages) + + t0 = time.perf_counter() + result = self.agent.run_confirmation(text) + elapsed = time.perf_counter() - t0 + + if self.agent_log: + self.agent_log.log("LLM tempo de execução", f"{elapsed:.3f}s") + self.agent_log.log("LLM output", result["output"]) + self.agent_log.log("tool_calls", str(result.get("tools", []))) + self.agent_log.log_agent_state("data_confirmation", self.agent.state) + self.agent_log.log_messages("Histórico DEPOIS do run", self.agent.messages) + + # buffer do output gerado + self._buffer_langfuse_trace(text, result["output"]) + + if self.agent.state.is_authenticated: + self.update_langfuse_auto(text, result["output"]) + self.stage = "formalization" + + self.agent = FormalizationAgent( + streaming=self.streaming, + prompt_vars=self.prompt_vars, + ) + + if self.agent_log: + self.agent_log.log_stage_result("data_confirmation", result["output"], "formalization", True) + self.agent_log.log_section("TRANSIÇÃO → FORMALIZATION") + self.agent_log.log_system_prompt("formalization", self.agent.system_prompt) + + return {"stage": "formalization", "auto": True} + + if self.agent.state.end_conversation: + self.stage = "DONE" + if self.agent_log: + self.agent_log.log_stage_result("data_confirmation", result["output"], "DONE", False) + return {"stage": "DONE", "output": result["output"], "auto": False} + + if self.agent_log: + self.agent_log.log_stage_result("data_confirmation", result["output"], self.stage, False) + + return {"stage": self.stage, "output": result["output"], "auto": False} + + def _lg_formalization(self, state: PipelineState) -> PipelineState: + self.stage = state.get("stage", self.stage) + self.name = self.stage + + text = self._get_effective_input(state) + + if self.agent_log: + self.agent_log.log_section("FORMALIZATION") + self.agent_log.log("input", text) + self.agent_log.log_messages("Histórico ANTES do run", self.agent.messages) + + t0 = time.perf_counter() + result = self.agent.run_formalization(text) + elapsed = time.perf_counter() - t0 + + if self.agent_log: + self.agent_log.log("LLM tempo de execução", f"{elapsed:.3f}s") + self.agent_log.log("LLM output", result["output"]) + self.agent_log.log("tool_calls", str(result.get("tools", []))) + self.agent_log.log_agent_state("formalization", self.agent.state) + self.agent_log.log_messages("Histórico DEPOIS do run", self.agent.messages) + + # buffer do output gerado + self._buffer_langfuse_trace(text, result["output"]) + + if self.agent.state.end_conversation: + self.stage = "DONE" + if self.agent_log: + self.agent_log.log_stage_result("formalization", result["output"], "DONE (end_conversation)", False) + return {"stage": "DONE", "output": result["output"], "auto": False} + + if self.agent.state.accepted: + self.stage = "DONE" + self.accepted_purchase = True + if self.agent_log: + self.agent_log.log_stage_result("formalization", result["output"], "DONE (compra aceita)", False) + return {"stage": "DONE", "output": result["output"], "auto": False} + + if self.agent.state.declines >= 2: + self.stage = "DONE" + if self.agent_log: + self.agent_log.log_stage_result("formalization", result["output"], f"DONE (declines={self.agent.state.declines})", False) + return {"stage": "DONE", "output": result["output"], "auto": False} + + if self.agent_log: + self.agent_log.log_stage_result("formalization", result["output"], self.stage, False) + + return {"stage": self.stage, "output": result["output"], "auto": False} + + # ==================================================================================== + # Public API + + @timed("Agent run") + def run(self, user_input: Any): + if self.graph is None: + raise RuntimeError("prepare() deve ser chamado antes de run().") + + if self.stage == "DONE": + return "DONE", "" + + if self.agent_log: + self.agent_log.log_section(f"RUN (estágio: {self.stage})") + self.agent_log.log("user_input", str(user_input)) + + #if self.pending_output: + # self.set_interruption(was_interrupted=False) + + initial_state: PipelineState = { + "stage": self.stage, + "user_input": user_input, + "auto": False, + } + + out: PipelineState = self.graph.invoke(initial_state) + + self.stage = out.get("stage", self.stage) + + if self.agent_log: + self.agent_log.log("run finalizado", f"stage={self.stage} output={out.get('output', '')[:120]}...") + + return self.stage, out.get("output", "") + + def _check_guardrail(self, user_input: str): + return self.check_guardrail.run(user_input)["is_attack"] + + # ==================================================================================== + # end_service (mantém compatibilidade com _end_service) + + @timed("Agent end service") + async def end_service(self): + return await self._end_service() + + async def _end_service(self): + if self.pending_output: + self.set_interruption(was_interrupted=False) + + await asyncio.sleep(10) + historical_conversation = get_historic_conversation(self.trace_id) + + self.classified_conversation = self.conversation_classifier.run(historical_conversation) + self.summary = self.conversation_summary.run(historical_conversation) + + success_purchase = {} + + if self.accepted_purchase: + success_purchase = format_success_purchase(self.prompt_vars["raw_customer"]) + + formatted_call = format_call(self.prompt_vars["raw_customer"], self.classified_conversation, self.summary) + output = { + "success_purchase": success_purchase, + "formatted_call": formatted_call, + } + + # Log de fim de serviço + if self.agent_log: + self.agent_log.log_section("END SERVICE") + self.agent_log.log("accepted_purchase", str(self.accepted_purchase)) + self.agent_log.log("classified_conversation", str(self.classified_conversation)) + self.agent_log.log("summary", str(self.summary)) + self.agent_log.log("formatted_call", str(formatted_call)) + self.agent_log.log("success_purchase", str(success_purchase)) + + with langfuse.start_as_current_observation( + as_type="span", + name=self.name, + trace_context={"trace_id": self.trace_id}, + ) as span: + langfuse.update_current_span( + metadata={ + "stage": "classify_conversation", + "classification": formatted_call["MOTIVO_STATUS"], + }, + output=output, + name=self.name, + input=historical_conversation, + ) + + span.score_trace( + name="success_purchase", + value=1 if self.accepted_purchase else 0, + data_type="BOOLEAN", + ) + + return output + + # ==================================================================================== + # Metadata + + def get_formatted_metadata(self): + if self.stage == "presentation": + return { + "stage": self.stage, + "attacks": self.attacks, + "presentation.name_validation_attempt": self.agent.state.name_validation_attempt, + "presentation.eligibility": self.prompt_vars["elegibility"], + "presentation.is_target_customer": self.agent.state.is_target_customer, + "presentation.end_conversation": self.agent.state.end_conversation, + } + + if self.stage == "argumentation": + return { + "stage": self.stage, + "attacks": self.attacks, + "argumentation.accepted": self.agent.state.accepted, + "argumentation.accepted_count": self.agent.state.accepted_count, + "argumentation.declines": self.agent.state.declines, + "argumentation.end_conversation": self.agent.state.end_conversation, + "argumentation.off_topic": self.agent.state.off_topic, + } + + if self.stage == "data_confirmation": + return { + "stage": self.stage, + "attacks": self.attacks, + "data_confirmation.authenticated": self.agent.state.is_authenticated, + "data_confirmation.validated_target_customer": self.agent.state.validated_target_customer, + "data_confirmation.validated_cpf": self.agent.state.validated_cpf, + "data_confirmation.validated_birth_date": self.agent.state.validated_birth_date, + "data_confirmation.attempt_customer": self.agent.state.attempt_customer, + "data_confirmation.attempt_cpf": self.agent.state.attempt_cpf, + "data_confirmation.attempt_birth_date": self.agent.state.attempt_birth_date, + } + + if self.stage == "formalization": + return { + "stage": self.stage, + "attacks": self.attacks, + "formalization.accepted": self.agent.state.accepted, + "formalization.declines": self.agent.state.declines, + "formalization.questions": self.agent.state.questions, + } + + return { + "stage": self.stage, + "attacks": self.attacks, + } diff --git a/src/agent/pipeline/customer_pipeline_unified.py b/src/agent/pipeline/customer_pipeline_unified.py new file mode 100644 index 0000000..0757280 --- /dev/null +++ b/src/agent/pipeline/customer_pipeline_unified.py @@ -0,0 +1,663 @@ +from typing import TypedDict, Any, Optional, Tuple +from langgraph.graph import StateGraph, END + +from langfuse import get_client +from agent.stage.unified_agent import UnifiedAgent +from agent.stage.unified_state import ConversationPhase +from agent.utils.utils import ( + process_mailing, + process_plan, + get_gender, + get_plan, + normalize_name, + sentence_confidence, + start_message_argumentation, +) + +from agent.classifier.check_guardrail import CheckGuardrail +from agent.classifier.conversation_summary import ConversationSummary +from agent.classifier.conversation_classifier import ( + get_historic_conversation, + ConversationClassifier, + format_success_purchase, + format_call, +) + +from app.common.timed import timed +from langfuse.langchain import CallbackHandler +import asyncio +import copy +import os +import json +import math +from app.utils.logging import setup_minimal_logging + +logger = setup_minimal_logging() +langfuse = get_client() +langfuse_handler = CallbackHandler() + + +class PipelineState(TypedDict, total=False): + stage: str + user_input: dict + auto: bool + output: str + is_attack: bool + + +class CustomerPipelineUnified: + """Pipeline unificado: um único agente com prompt dinâmico para toda a conversa.""" + + def __init__(self, mailing, streaming=False): + self.streaming = streaming + self.stage = "presentation" + self.classified_conversation = None + self.accepted_purchase = False + self.name = None + self.attacks = 0 + + self.prompt_vars = { + "raw_customer": mailing, + "customer": None, + "actual_plan": None, + "target_plan": None, + "elegibility": None, + } + + self.trace_id = langfuse.create_trace_id() + self.intro = None + self.graph = None + self.agent: UnifiedAgent | None = None + + # Buffer do último output (aguarda sinal do backend para subir no Langfuse) + self.pending_input = None + self.pending_output = None + self.pending_stage = None + self.pending_metadata = None + print("RODANDO O PIPELINE UNIFICADO") + # ==================================================================================== + # Buffer + commit no Langfuse + # ==================================================================================== + + def _buffer_langfuse_trace(self, input_value: Any, output_value: str): + """ + Guarda o último par (input, output) gerado pelo agente para só subir no Langfuse + quando o backend sinalizar se foi ouvido por completo ou interrompido. + """ + self.pending_input = input_value + self.pending_output = output_value + self.pending_stage = self.stage + self.pending_metadata = self.get_formatted_metadata() + + def update_langfuse_trace(self, final_output: str, was_interrupted: bool = False): + """ + Faz o commit no Langfuse usando os campos pendentes (input/stage/metadata) + e o output final (completo ou truncado). + """ + if self.pending_stage is None: + return + + metadata = self.pending_metadata or {} + metadata = {**metadata, "interrupted": was_interrupted} + + with langfuse.start_as_current_observation( + as_type="span", + name=self.pending_stage, + trace_context={"trace_id": self.trace_id}, + ) as span: + langfuse.update_current_span( + metadata=metadata, + name=self.pending_stage, + output=final_output, + input=self.pending_input, + ) + + def update_langfuse_auto(self, input, output): + with langfuse.start_as_current_observation( + as_type="span", + name=self.stage, + trace_context={"trace_id": self.trace_id}, + ) as span: + langfuse.update_current_span( + metadata=self.get_formatted_metadata(), + name=self.stage, + output=output, + input=input + ) + + # ==================================================================================== + # Backend sinaliza consumo/interrupção (TTS) + # ==================================================================================== + + def set_interruption( + self, + was_interrupted: bool, + listened_text: Optional[str] = None, + skipped_vacalization: bool = False): + + logger.info( + "SET_INTERRUPTION | was_interrupted=%s | listened_text=%r | skipped_vacalization=%s", + was_interrupted, + listened_text, + skipped_vacalization, + ) + + if not self.pending_output: + return + + # Definimos a marca de interrupção + INTERRUPTION_TAG = "###interrupção###" + + if skipped_vacalization: + # Caso 1: O áudio nem chegou a tocar (interrupção imediata ou erro) + final_output = INTERRUPTION_TAG + was_interrupted_flag = True + self.agent.state.restore_state() + self.agent.set_interruption_message(final_output) + + elif was_interrupted: + # Caso 2: Estava falando e foi cortado + # Se ouviu algo, usa o trecho + tag. Se não ouviu nada, apenas a tag. + final_output = f"{listened_text or ''} {INTERRUPTION_TAG}".strip() + was_interrupted_flag = True + self.agent.set_interruption_message(final_output) + + else: + # Caso 3: Fluxo normal (não foi interrompido) + final_output = self.pending_output + was_interrupted_flag = False + + # Atualiza o Rastreamento (Langfuse) + self.update_langfuse_trace( + final_output, + was_interrupted=was_interrupted_flag, + ) + + # Limpa buffer + self.pending_input = None + self.pending_output = None + self.pending_stage = None + self.pending_metadata = None + + # ==================================================================================== + # Start + Prepare + # ==================================================================================== + + def start(self): + first_name = self.prompt_vars["raw_customer"]["NOME_CLIENTE_COMPLETO"].split()[0].lower() + self.intro = ( + f"Olá, meu nome é Helena, sou consultora de vendas da TIM, para sua segurança a ligação está sendo gravada. " + f"Eu estou falando com {first_name}?" + ) + self.stage = "presentation" + self.name = "presentation" + return self.stage, self.intro + + def _generate_argumentation_start_text(self): + """Gera a mensagem fixa de abertura da argumentação.""" + text = start_message_argumentation({ + "cliente_alvo_primeiro_nome": self.prompt_vars["customer"]["cliente_alvo_primeiro_nome"].lower(), + "meses_restantes_fidelizacao": self.prompt_vars["actual_plan"]["meses_restantes_fidelizacao"], + "quanto_pago_a_mais": self.prompt_vars["target_plan"]["quanto_pago_a_mais"], + "quanto_pago_a_mais_int": abs(math.ceil( + self.prompt_vars["raw_customer"]["VLR_FINAL_PLANO_DEST"] - self.prompt_vars["raw_customer"]["MEDIA_RECARGA_2"] + )), + "gb_plano_atual": self.prompt_vars["actual_plan"]["gb_plano_atual"], + "valor_plano_sem_fidelização": self.prompt_vars["actual_plan"]["valor_plano_sem_fidelização"], + "dados_GB": self.prompt_vars["target_plan"]["dados_GB"], + "beneficios_001": self.prompt_vars["target_plan"]["beneficios_001"], + "valor_plano_final": self.prompt_vars["target_plan"]["valor_plano_final"], + "gb_alvo_diferenca": self.prompt_vars["target_plan"]["gb_alvo_diferenca"], + "preco_por_dia": self.prompt_vars["target_plan"]["preço_por_dia"] + }) + return text + + def _generate_formalization_start_text(self): + """Gera o texto obrigatório e literal de abertura da formalização.""" + customer = self.prompt_vars["customer"] + target = self.prompt_vars["target_plan"] + tratamento = customer.get("TRATAMENTO", "Você") + + text = ( + f"Pra finalizar eu preciso apresentar uma série de informações sobre o novo plano. " + f"Vou começar: {tratamento} está adquirindo uma oferta do plano {target['plano']}, " + f"que por doze meses terá o custo de {target['valor_plano_final']}. " + f"Este é um desconto de {target['valor_desconto']} em relação ao valor total do plano, " + f"que é {target['valor_plano_bruto']}. Nesta oferta, {tratamento} tem {target['dados_GB']} " + f"gigas para navegar na internet. Além disso, você tem os seguintes benefícios no novo plano: " + f"{target['beneficios_todos']} e também recebe uma série de serviços de valor agregado. " + f"Você pode conhecê-los no APP meu Tim. A sua data de vencimento e forma de pagamento " + f"permanecem os mesmos. Na sua próxima fatura será cobrado o valor integral do plano antigo, " + f"mais o proporcional dos dias utilizados do plano adquirido. {tratamento} confirma a migração " + f"para o {target['plano']} no valor de {target['valor_plano_final']} fidelizado por doze meses?? " + f"Se sim, diga eu confirmo." + ) + return text + + def _generate_data_confirmation_start_text(self): + """Gera o texto de abertura da confirmação de dados.""" + nome = self.prompt_vars["customer"]["NOME_CLIENTE_COMPLETO"] + text = ( + f"Ótimo! então vamos ativar agora para você já aproveitar! " + f"Para prosseguirmos você pode por favor confirmar se seu nome é {nome}? " + f"Se sim, diga eu confirmo" + ) + return text + + # ==================================================================================== + # Prepare — instancia o UnifiedAgent + # ==================================================================================== + + def prepare(self, elegibility: bool, protocol: str): + self.stage = "presentation" + self.name = "presentation" + + self.prompt_vars["customer"] = copy.deepcopy(self.prompt_vars["raw_customer"]) + self.prompt_vars["customer"]["PROTOCOLO"] = protocol + + self.prompt_vars["elegibility"] = elegibility + + self.prompt_vars["actual_plan"] = process_plan({}, self.prompt_vars["customer"], "actual") + self.prompt_vars["target_plan"] = process_plan( + get_plan(self.prompt_vars["customer"]["PLANO_DESTINO"]), + self.prompt_vars["customer"], + "target", + ) + + self.prompt_vars["customer"] = get_gender(self.prompt_vars["customer"]) + self.prompt_vars["customer"]["NOME_CLIENTE_COMPLETO"] = normalize_name( + self.prompt_vars["customer"]["NOME_CLIENTE_COMPLETO"] + ) + + self.prompt_vars["customer"] = process_mailing(self.prompt_vars["customer"]) + + # ── Instancia o agente unificado ── + self.agent = UnifiedAgent( + expected_cpf_last_3=self.prompt_vars["customer"]["NUM_CPF_CNPJ_CLIENTE"], + expected_birth_date=self.prompt_vars["customer"].get("DAT_NASCIMENTO", ""), + streaming=self.streaming, + prompt_vars={ + "customer_first_name": self.prompt_vars["customer"]["NOME_CLIENTE_COMPLETO"] + .split()[0].lower(), + "customer_full_name": self.prompt_vars["customer"]["NOME_CLIENTE_COMPLETO"], + "customer": self.prompt_vars["customer"], + "actual_plan": self.prompt_vars["actual_plan"], + "target_plan": self.prompt_vars["target_plan"], + "tratamento": self.prompt_vars["customer"].get("TRATAMENTO", "O senhor"), + "plano_target": self.prompt_vars["target_plan"]["plano"], + "valor_plano_final": self.prompt_vars["target_plan"]["valor_plano_final"], + "valor_desconto": self.prompt_vars["target_plan"]["valor_desconto"], + "valor_plano_bruto": self.prompt_vars["target_plan"]["valor_plano_bruto"], + "dados_GB": self.prompt_vars["target_plan"]["dados_GB"], + "beneficios_todos": self.prompt_vars["target_plan"]["beneficios_todos"], + "elegibility": self.prompt_vars["elegibility"], + }, + ) + + # Injeta o início da conversa no histórico + self.agent.inject_user_message("###START###") + self.agent.inject_ai_message(self.intro) + + self.check_guardrail = CheckGuardrail() + self.conversation_classifier = ConversationClassifier() + self.conversation_summary = ConversationSummary() + + self.update_langfuse_auto( + {"text": "###START###", "prompt_vars": self.prompt_vars}, + self.intro, + ) + + self.graph = self._build_graph() + + # ==================================================================================== + # Grafo simplificado: guardrail → unified → END + # ==================================================================================== + + def _build_graph(self): + g = StateGraph(PipelineState) + + g.add_node("guardrail", self._lg_guardrail) + g.add_node("unified", self._lg_unified) + + g.set_entry_point("guardrail") + + g.add_conditional_edges( + "guardrail", + self._lg_route_from_guardrail, + { + "unified": "unified", + "end": END, + }, + ) + + g.add_conditional_edges( + "unified", + self._lg_route_after_stage, + { + "unified": "unified", + "end": END, + }, + ) + + return g.compile() + + # ==================================================================================== + # STT normalization + # ==================================================================================== + + def _normalize_stt_input(self, user_input: Any) -> Tuple[str, Optional[list]]: + """ + api_text -> retorna (texto, None) + raw_json -> retorna (data.text, data.words) + """ + mode = os.getenv("STT_OUTPUT_MODE", "api_text").lower() + + if mode != "raw_json": + return str(user_input or ""), None + + try: + payload = json.loads(user_input) if isinstance(user_input, str) else user_input + data = (payload or {}).get("data", {}) + text = str(data.get("text", "") or "") + words = data.get("words", None) + return text, words + except Exception: + return str(user_input or ""), None + + # ==================================================================================== + # Guardrail (idêntico ao original) + # ==================================================================================== + + def _lg_guardrail(self, state: PipelineState) -> PipelineState: + raw_input = state.get("user_input", "") + user_input, words = self._normalize_stt_input(raw_input) + + # Guardrail de confiança (somente raw_json, sem limite) + if words is not None: + conf = sentence_confidence(words) + print("confiança", conf) + if conf < 0.0 or user_input in [None, ""]: + output = "Desculpe, não consegui entender bem. Pode repetir mais devagar, por favor?" + + self._buffer_langfuse_trace(user_input, output) + + return { + "is_attack": True, + "output": output, + "auto": False, + "stage": state.get("stage", self.stage), + "user_input": user_input, + } + + # Guardrail original (ataque) + is_attack = self._check_guardrail(user_input) + output = "Desculpe, não entendi. Poderia repetir?" + + if self.attacks > 4: + output = ( + "Notei que temos um problema de comunicação. " + "Irei finalizar a chamada. A TIM agradece a sua atenção" + ) + + self._buffer_langfuse_trace(user_input, output) + + return { + "is_attack": False, + "output": output, + "auto": False, + "stage": "DONE", + "user_input": user_input, + } + + if is_attack: + self.attacks += 1 + + self._buffer_langfuse_trace(user_input, output) + + return { + "is_attack": True, + "output": output, + "auto": False, + "stage": state.get("stage", self.stage), + "user_input": user_input, + } + + return {"is_attack": False, "user_input": user_input} + + def _lg_route_from_guardrail(self, state: PipelineState) -> str: + if state.get("is_attack"): + return "end" + + stage = state.get("stage") or self.stage + if stage == "DONE": + return "end" + + return "unified" + + def _lg_route_after_stage(self, state: PipelineState) -> str: + if state.get("stage") == "DONE": + return "end" + + if state.get("auto"): + return "unified" + + return "end" + + # ==================================================================================== + # Nó unificado — substitui os 4 nós separados + # ==================================================================================== + + def _lg_unified(self, state: PipelineState) -> PipelineState: + """Nó único que delega ao UnifiedAgent e gerencia transições automáticas.""" + self.stage = state.get("stage", self.stage) + self.name = self.agent.state.current_phase.value + + # ── Se é auto-start de uma nova fase, injeta mensagem fixa ── + if state.get("auto"): + phase = self.agent.state.current_phase + + if phase == ConversationPhase.ARGUMENTATION: + # Mensagem fixa de argumentação + text_arg = self._generate_argumentation_start_text() + self.agent.inject_user_message("###START###") + self.agent.inject_ai_message(text_arg) + + # Buffer para Langfuse (transição automática) + self._buffer_langfuse_trace("###START###", text_arg) + + # Retorna a mensagem fixa como output — cliente ouve isso + return { + "stage": phase.value, + "output": text_arg, + "auto": False, + } + + elif phase == ConversationPhase.DATA_CONFIRMATION: + # Roda com ###START### para gerar a mensagem de confirmação + text_dc = self._generate_data_confirmation_start_text() + self.agent.inject_user_message("###START###") + self.agent.inject_ai_message(text_dc) + + self._buffer_langfuse_trace("###START###", text_dc) + + return { + "stage": phase.value, + "output": text_dc, + "auto": False, + } + + elif phase == ConversationPhase.FORMALIZATION: + # Texto obrigatório e literal da formalização + text_form = self._generate_formalization_start_text() + self.agent.inject_user_message("###START###") + self.agent.inject_ai_message(text_form) + + self._buffer_langfuse_trace("###START###", text_form) + + return { + "stage": phase.value, + "output": text_form, + "auto": False, + } + + # ── Fluxo normal: roda o agente unificado ── + text = state.get("user_input", "") + result = self.agent.run_unified(text) + + output = result["output"] + new_phase = result["phase"] + auto = result["auto"] + + # Buffer para Langfuse + self._buffer_langfuse_trace(text, output) + + # Atualiza stage do pipeline + if new_phase == "done": + self.stage = "DONE" + # Verifica se foi aceite final da formalização + if self.agent.state.form_accepted: + self.accepted_purchase = True + return {"stage": "DONE", "output": output, "auto": False} + + self.stage = new_phase + + # Se houve transição automática, loga no Langfuse antes de trocar + if auto: + self.update_langfuse_auto(text, output) + + return { + "stage": new_phase, + "output": output, + "auto": auto, + } + + # ==================================================================================== + # Public API + # ==================================================================================== + + @timed("Agent run") + def run(self, user_input: Any): + if self.graph is None: + raise RuntimeError("prepare() deve ser chamado antes de run().") + + if self.stage == "DONE": + return "DONE", "" + + initial_state: PipelineState = { + "stage": self.stage, + "user_input": user_input, + "auto": False, + } + + out: PipelineState = self.graph.invoke(initial_state) + + self.stage = out.get("stage", self.stage) + return self.stage, out.get("output", "") + + def _check_guardrail(self, user_input: str): + return self.check_guardrail.run(user_input)["is_attack"] + + # ==================================================================================== + # end_service (mantém compatibilidade com _end_service) + # ==================================================================================== + + @timed("Agent end service") + async def end_service(self): + return await self._end_service() + + async def _end_service(self): + if self.pending_output: + self.set_interruption(was_interrupted=False) + + await asyncio.sleep(10) + historical_conversation = get_historic_conversation(self.trace_id) + + self.classified_conversation = self.conversation_classifier.run(historical_conversation) + self.summary = self.conversation_summary.run(historical_conversation) + + success_purchase = {} + + if self.accepted_purchase: + success_purchase = format_success_purchase(self.prompt_vars["raw_customer"]) + + formatted_call = format_call(self.prompt_vars["raw_customer"], self.classified_conversation, self.summary) + output = { + "success_purchase": success_purchase, + "formatted_call": formatted_call, + } + + with langfuse.start_as_current_observation( + as_type="span", + name=self.name, + trace_context={"trace_id": self.trace_id}, + ) as span: + langfuse.update_current_span( + metadata={ + "stage": "classify_conversation", + "classification": formatted_call["MOTIVO_STATUS"], + }, + output=output, + name=self.name, + input=historical_conversation, + ) + + span.score_trace( + name="success_purchase", + value=1 if self.accepted_purchase else 0, + data_type="BOOLEAN", + ) + + return output + + # ==================================================================================== + # Metadata — lê do UnifiedState centralizado + # ==================================================================================== + + def get_formatted_metadata(self): + if self.agent is None: + return {"stage": self.stage, "attacks": self.attacks} + + state = self.agent.state + phase = state.current_phase.value + + base = { + "stage": phase, + "attacks": self.attacks, + "current_phase": phase, + "end_conversation": state.end_conversation, + } + + if phase == "presentation": + base.update({ + "presentation.name_validation_attempt": state.name_validation_attempt, + "presentation.eligibility": self.prompt_vars.get("elegibility"), + "presentation.is_target_customer": state.is_target_customer, + }) + + elif phase == "argumentation": + base.update({ + "argumentation.accepted": state.arg_accepted, + "argumentation.accepted_count": state.arg_accepted_count, + "argumentation.declines": state.arg_declines, + "argumentation.off_topic": state.arg_off_topic, + }) + + elif phase == "data_confirmation": + base.update({ + "data_confirmation.authenticated": state.is_authenticated, + "data_confirmation.validated_target_customer": state.validated_target_customer, + "data_confirmation.validated_cpf": state.validated_cpf, + "data_confirmation.validated_birth_date": state.validated_birth_date, + "data_confirmation.attempt_customer": state.attempt_customer, + "data_confirmation.attempt_cpf": state.attempt_cpf, + "data_confirmation.attempt_birth_date": state.attempt_birth_date, + }) + + elif phase == "formalization": + base.update({ + "formalization.accepted": state.form_accepted, + "formalization.declines": state.form_declines, + "formalization.questions": state.form_questions, + }) + + return base diff --git a/src/agent/pipeline/pipeline_streaming_text.py b/src/agent/pipeline/pipeline_streaming_text.py new file mode 100644 index 0000000..0ee7cfe --- /dev/null +++ b/src/agent/pipeline/pipeline_streaming_text.py @@ -0,0 +1,31 @@ +from typing import TypedDict, Any, Optional, Tuple +from langfuse import get_client +from agent.stage.teste import TestAgent +from app.common.timed import timed +import asyncio +import copy +import os +import json +from typing import Any, Dict, Iterator, Tuple + +class CustomerPipeline: + def __init__(self): + self.agent = TestAgent(streaming=True) + self.stage = "presentation" + + def start(self): + return "presentation", "" + + def prepare(self, elegibility: bool, protocol: str): + pass + + @timed("Agent run") + def run(self, user_input: Any): + return self.agent.run_stream(user_input) + + @timed("Agent end service") + async def end_service(self): + return await self._end_service() + + async def _end_service(self): + return {} diff --git a/src/agent/stage/argumentation.py b/src/agent/stage/argumentation.py new file mode 100644 index 0000000..46e0dfb --- /dev/null +++ b/src/agent/stage/argumentation.py @@ -0,0 +1,132 @@ +# argumentation/argumentation.py +from functools import lru_cache +from importlib import resources +from typing import Literal +from langchain.tools import tool +from agent.base.base_stage import BaseAgent +import re + +import copy + +class ArgumentationState: + def __init__(self): + self.declines = 0 + self.accepted_count = 0 + self.accepted = False + self.end_conversation = False + self.off_topic = 0 + + self._saved_state = None + self.save_state() + + def save_state(self): + self._saved_state = copy.deepcopy(self.__dict__) + self._saved_state.pop("_saved_state", None) + + def restore_state(self): + if self._saved_state is None: + raise RuntimeError("Nenhum estado foi salvo ainda.") + + self.__dict__.update(copy.deepcopy(self._saved_state)) + + def print_state(self): + print(f"declines: {self.declines}") + print(f"accepted_count: {self.accepted_count}") + print(f"accepted: {self.accepted}") + print(f"end_conversation: {self.end_conversation}") + print(f"off_topic: {self.off_topic}") + +class ArgumentationAgent(BaseAgent): + def __init__(self, streaming=False, prompt_vars=None): + + state = ArgumentationState() + + tools = self._build_tools() + super().__init__( + tools=tools, + streaming=streaming, + prompt_vars=prompt_vars, + agent_name='argumentation' + ) + self.state = state + + def _build_tools(self): + @tool + def detect_intention_purchase(intention: Literal["customer_declined_purchase", + "customer_accepted_purchase", + "off_topic_from_sale", + "other"]): + """Classifica a intenção do cliente em relação à oferta de troca de plano. +- customer_accepted_purchase: cliente demonstrou interesse claro em comprar o plano (ex: 'quero', 'pode ser', 'sim', 'topo') +- customer_declined_purchase: cliente recusou comprar o plano (ex: 'não quero', 'caro', 'não preciso', 'deixa esse plano') +- off_topic_from_sale: cliente falou sobre assuntos que não têm relação com a venda (ex: 'vou para a praia', 'vem almoçar') +- other: dúvidas, perguntas ou respostas ambíguas sobre o plano + """ + return _handle_intention(intention) + + def _handle_intention(intention): + print(intention) + #self.state.history.append(intention) + + if intention == "customer_accepted_purchase": + self.state.accepted_count += 1 + if self.state.accepted_count >= 2: + self.state.accepted = True + return "###end_argumentation###" + + return "Peça a segunda confirmação" + + self.state.accepted_count = 0 + + if intention == "customer_declined_purchase": + self.state.declines += 1 + if self.state.declines > 2: + return f"{self.state.declines}º, finalize" + return f"{self.state.declines}º rejeição" + + if intention == "off_topic_from_sale": + self.state.off_topic += 1 + if self.state.off_topic > 3: + return "Finalize a conversa" + + return "Lide com o input do usuario" + + return [detect_intention_purchase] + + @lru_cache + def _load_prompt(self) -> str: + prompt_text = resources.files("agent.prompts").joinpath("argumentation.txt").read_text(encoding="utf-8") + general_rules = resources.files("agent.prompts").joinpath("general_rules.txt").read_text(encoding="utf-8") + knowledge_text = resources.files("agent.prompts").joinpath("knowledge_base.txt").read_text(encoding="utf-8") + + return prompt_text.replace("{general_rules}", general_rules).replace("{knowledge_base}", knowledge_text) + + def set_interruption_message(self, text: str): + if self.messages: + self.messages[-1].content = f"{text}" + + def update_prompt_variable(self) -> None: + pattern = r"(### Informações extras ###)(.*?)(### Fim das informações extras ###)" + + new_content = f"Recusas do cliente:{self.state.declines}" + + print(new_content) + def replacer(match): + return f"{match.group(1)}\n{new_content}\n{match.group(3)}" + + self.agent.steps[1].messages[0].prompt.template = re.sub( + pattern, + replacer, + self.agent.steps[1].messages[0].prompt.template, + flags=re.DOTALL + ) + + def run_argumentation(self, user_input): + #self.update_prompt_variable() + self.state.save_state() + result = self.run(user_input) + #print(self.messages) + if ("tim agradece sua atenção" in result['output'].lower()): + self.state.end_conversation = True + + return result \ No newline at end of file diff --git a/src/agent/stage/data_confirmation.py b/src/agent/stage/data_confirmation.py new file mode 100644 index 0000000..5df83d7 --- /dev/null +++ b/src/agent/stage/data_confirmation.py @@ -0,0 +1,153 @@ +# data_confirmation/data_confirmation.py +from functools import lru_cache +from importlib import resources +from langchain.tools import tool +from agent.base.base_stage import BaseAgent +import copy +#from Levenshtein import distance + +#def levenshtein_similarity(str1: str, str2: str) -> float: +# s1, s2 = str1.strip().lower(), str2.strip().lower() +# dist = distance(s1, s2) +# max_len = max(len(s1), len(s2)) +# if max_len == 0: +# return 100.0 +# return (1 - dist / max_len) * 100 + +class AuthState: + def __init__(self, expected_cpf, + #expected_mother, + expected_birth): + self.cpf_last_3_digits = None + #self.mother_full_name = None + self.birth_date_YYYY_MM_DD = None + + self.expected_cpf_last_3_digits = expected_cpf + #self.expected_mother_full_name = expected_mother + self.expected_birth_date_YYYY_MM_DD = expected_birth + + self.validated_target_customer = False + self.validated_cpf = False + #self.validated_mother_name = False + self.validated_birth_date = False + + self.attempt_customer = 0 + self.attempt_cpf = 0 + self.attempt_birth_date = 0 + #self.attempt_mother_name = 0 + + self.end_conversation = False + + self._saved_state = None + self.save_state() + @property + def is_authenticated(self): + return ( + self.validated_target_customer and + (self.validated_cpf or self.validated_birth_date) + #and self.validated_mother_name + ) + + @property + def attempts_exceeded(self): + """Retorna True se qualquer tentativa for maior que 2.""" + return any([ + self.attempt_customer >= 3, + self.attempt_cpf >= 3, + self.attempt_birth_date >= 3, + #self.attempt_mother_name >= 3, + ]) + + def save_state(self): + self._saved_state = copy.deepcopy(self.__dict__) + self._saved_state.pop("_saved_state", None) + + def restore_state(self): + if self._saved_state is None: + raise RuntimeError("Nenhum estado foi salvo ainda.") + + self.__dict__.update(copy.deepcopy(self._saved_state)) + +class DataConfirmationAgent(BaseAgent): + def __init__( + self, + expected_cpf_last_3, + #expected_mother, + expected_birth_date, + streaming=False, + prompt_vars = None + ): + + self.state = AuthState(expected_cpf_last_3, + #expected_mother, + expected_birth_date) + tools = self._build_tools() + super().__init__( + tools=tools, + streaming=streaming, + prompt_vars=prompt_vars, + agent_name='data_confirmation' + ) + + @lru_cache + def _load_prompt(self) -> str: + prompt_text = resources.files("agent.prompts").joinpath("data_confirmation.txt").read_text(encoding="utf-8") + general_rules = resources.files("agent.prompts").joinpath("general_rules.txt").read_text(encoding="utf-8") + return prompt_text.replace("{general_rules}", general_rules) + + # ----------------- TOOLS ----------------- + def _build_tools(self): + @tool + def detect_name_target_customer(is_target_customer: bool): + """Confirma se o interlocutor é o cliente alvo. Chame após o cliente responder à pergunta de confirmação de nome. +is_target_customer=true: cliente confirmou dizendo 'eu confirmo' +is_target_customer=false: cliente negou ou informou outro nome""" + self.state.validated_target_customer = is_target_customer + self.state.attempt_customer += 1 + return is_target_customer + + #@tool + #def check_full_mother_name(name: str): + # """Captura o nome completo da mãe dito pelo cliente. Chame sempre que responder a pergunta sobre o nome da mãe.""" + # self.state.mother_full_name = name + # self.state.attempt_mother_name += 1 + # self.state.validated_mother_name = True if levenshtein_similarity(name, self.state.expected_mother_full_name) > 80 else False + # return "correct" if self.state.validated_mother_name else "incorrect" + + @tool + def check_birth_date(date_YYYY_MM_DD: str): + """Recebe a data de nascimento informada pelo cliente no formato YYYY-MM-DD. Converta datas por extenso para o formato antes de chamar (ex: '1 de janeiro de 2000' → '2000-01-01').""" + print(date_YYYY_MM_DD) + self.state.birth_date_YYYY_MM_DD = date_YYYY_MM_DD + self.state.attempt_birth_date += 1 + self.state.validated_birth_date = date_YYYY_MM_DD == self.state.expected_birth_date_YYYY_MM_DD + return "correct. Diga apenas ###end_data_confirmation###" if self.state.validated_birth_date else "incorrect" + + @tool + def check_cpf_digits(digits: str): + """Recebe os dígitos do CPF informados pelo cliente como string numérica. Converta números por extenso para dígitos antes de chamar (ex: 'um dois meia seis' → '1266'). Envie todos os dígitos ditos, mesmo que mais de 3.""" + print(digits) + digits = digits[-3:] + self.state.cpf_last_3_digits = digits + self.state.attempt_cpf += 1 + self.state.validated_cpf = digits == self.state.expected_cpf_last_3_digits + return "correct. Diga apenas ###end_data_confirmation###" if self.state.validated_cpf else "incorrect" + + @tool + def purchase_cancellation(end: bool): + """Encerra a chamada quando o cliente desistir claramente da compra. Chame com end=true.""" + print(">>>>",end) + self.state.end_conversation = end + return end + + return [detect_name_target_customer, check_birth_date, check_cpf_digits, purchase_cancellation]# , check_full_mother_name + + def set_interruption_message(self, text: str): + if self.messages: + self.messages[-1].content = f"{text}" + + def run_confirmation(self, user_input: str): + self.state.save_state() + result = self.run(user_input.replace('.','').replace('-','').replace('/','')) + + return result \ No newline at end of file diff --git a/src/agent/stage/formalization.py b/src/agent/stage/formalization.py new file mode 100644 index 0000000..595db40 --- /dev/null +++ b/src/agent/stage/formalization.py @@ -0,0 +1,67 @@ +# formalization/formalization.py +from functools import lru_cache +from importlib import resources +from langchain.tools import tool +from agent.base.base_stage import BaseAgent +from typing import Literal + +class ArgumentationState: + def __init__(self): + self.declines = 0 + self.accepted = False + self.questions = 0 + self.end_conversation = False + +class FormalizationAgent(BaseAgent): + def __init__(self, streaming=False, prompt_vars=None): + state = ArgumentationState() + + tools = self._build_tools() + super().__init__(tools=tools, + streaming=streaming, + prompt_vars=prompt_vars, + agent_name='formalization') + self.state = state + + def _build_tools(self): + @tool + def detect_intention_purchase(intention: Literal["customer_declined_purchase", "customer_accepted_purchase", "customer_asked_question", "other"]): + """Classifica a intenção do cliente durante a formalização da troca de plano. +- customer_accepted_purchase: cliente disse 'eu confirmo' aceitando a migração +- customer_declined_purchase: cliente recusou confirmar a migração (ex: 'não quero', 'mudei de ideia') +- customer_asked_question: cliente fez uma pergunta sobre o plano, benefícios ou cobrança +- other: resposta que não se encaixa nas categorias acima""" + #print(">>>>",intention) + if intention == "customer_declined_purchase": + self.state.declines += 1 + if intention == "customer_accepted_purchase": + self.state.accepted = True + if intention == "customer_asked_question": + self.state.questions += 1 + + return intention + @tool + def purchase_cancellation(end: bool): + """Encerra a chamada quando o cliente desistir claramente da compra. Chame com end=true.""" + #print(">>>>",end) + self.state.end_conversation = end + return end + + return [detect_intention_purchase, purchase_cancellation] + + @lru_cache + def _load_prompt(self) -> str: + prompt_text = resources.files("agent.prompts").joinpath("formalization.txt").read_text(encoding="utf-8") + general_rules = resources.files("agent.prompts").joinpath("general_rules.txt").read_text(encoding="utf-8") + knowledge_text = resources.files("agent.prompts").joinpath("knowledge_base.txt").read_text(encoding="utf-8") + + return prompt_text.replace("{general_rules}", general_rules).replace("{knowledge_base}", knowledge_text) + + def set_interruption_message(self, text: str): + if self.messages: + self.messages[-1].content = f"{text}" + + def run_formalization(self, user_input: str): + result = self.run(user_input) + + return result diff --git a/src/agent/stage/presentation.py b/src/agent/stage/presentation.py new file mode 100644 index 0000000..10c7772 --- /dev/null +++ b/src/agent/stage/presentation.py @@ -0,0 +1,91 @@ +# argumentation/argumentation.py +from functools import lru_cache +from importlib import resources +from typing import Literal +from langchain.tools import tool +from agent.base.base_stage import BaseAgent +import copy + +class PresentationState: + def __init__(self): + self.name_validation_attempt = 0 + self.is_target_customer = False + self.end_conversation = False + + self._saved_state = None + self.save_state() + + def save_state(self): + self._saved_state = copy.deepcopy(self.__dict__) + self._saved_state.pop("_saved_state", None) + + def restore_state(self): + if self._saved_state is None: + raise RuntimeError("Nenhum estado foi salvo ainda.") + + self.__dict__.update(copy.deepcopy(self._saved_state)) + + def print_state(self): + print(f"name_validation_attempt: {self.name_validation_attempt}") + print(f"is_target_customer: {self.is_target_customer}") + print(f"end_conversation: {self.end_conversation}") + +class PresentationAgent(BaseAgent): + def __init__(self, streaming=False, prompt_vars=None): + + state = PresentationState() + + tools = self._build_tools() + super().__init__( + tools=tools, + streaming=streaming, + prompt_vars=prompt_vars, + agent_name='presentation' + ) + self.state = state + + def _build_tools(self): + @tool + def classify_customer_response(response: Literal["confirmed_target_customer", + "denied_target_customer", + "other"] + ): + """Classifica a resposta do interlocutor sobre ser ou não o cliente alvo. +confirmed_target_customer: confirmou ser o cliente alvo (ex: 'sou eu', 'sim', 'pode falar', disse o próprio nome) +denied_target_customer: negou ser o cliente alvo ou é terceiro (ex: 'não é ele', 'sou o filho', 'ele saiu') +other: resposta ambígua ou não relacionada à confirmação de identidade""" + + print(response) + self.state.name_validation_attempt += 1 + + if self.state.name_validation_attempt >= 4: + return "Finalize a conversa com uma mensagem que contenha no final 'A TIM agradece sua atenção.'" + + if response == "confirmed_target_customer": + self.state.is_target_customer = True + return "###end_presentation###" + + return "Lide com o input do usuario" + + return [classify_customer_response] + + @lru_cache + def _load_prompt(self) -> str: + prompt_text = resources.files("agent.prompts").joinpath("presentation.txt").read_text(encoding="utf-8") + general_rules = resources.files("agent.prompts").joinpath("general_rules.txt").read_text(encoding="utf-8") + return prompt_text.replace("{general_rules}", general_rules) + + def set_interruption_message(self, text: str): + if self.messages: + self.messages[-1].content = f"{text}" + + def run_presentation(self, user_input): + + self.state.save_state() + + result = self.run(user_input) + + if "tim agradece sua atenção" in result['output'].lower(): + self.state.end_conversation = True + + return result \ No newline at end of file diff --git a/src/agent/stage/prompt_manager.py b/src/agent/stage/prompt_manager.py new file mode 100644 index 0000000..1b6c336 --- /dev/null +++ b/src/agent/stage/prompt_manager.py @@ -0,0 +1,466 @@ +# stage/prompt_manager.py +from importlib import resources +from functools import lru_cache +from agent.stage.unified_state import UnifiedState, ConversationPhase + + +class PromptManager: + """Monta o system prompt dinamicamente conforme a fase atual da conversa.""" + + def __init__(self, state: UnifiedState, prompt_vars: dict): + self.state = state + self.prompt_vars = prompt_vars + + # ══════════════════════════════════════════════════════════ + # Prompt principal + # ══════════════════════════════════════════════════════════ + + def build_prompt(self) -> str: + """Constrói o prompt completo baseado na fase atual do UnifiedState.""" + parts = [ + self._header(), + self._checkpoint(), + self._active_phase_instructions(), + self._customer_info(), + ] + prompt = "\n\n".join(parts) + # Substitui variáveis de prompt + prompt = self._apply_vars(prompt) + + print(f"\n📋 [PromptManager] Fase atual: {self.state.current_phase.value}") + return prompt + + # ══════════════════════════════════════════════════════════ + # Bloco 1 — Header fixo (identidade + regras gerais) + # ══════════════════════════════════════════════════════════ + + def _header(self) -> str: + return """Seu nome é Helena e você é um assistente de vendas da TIM. Você atende as pessoas via chamada telefônica. + +--- + +## REGRAS GERAIS + +- A conversa deve ser natural, curta e objetiva, evitando repetições mecânicas e respostas fixas. +- Sempre substitua corretamente informações entre colchetes por seus respectivos valores. Ex: [cliente_alvo_primeiro_nome] → joão +- Nunca escreva colchetes na resposta final. +- Seu texto será vocalizado. Gere sempre texto corrido, sem listas, sem negrito, sem parágrafos. A pontuação deve ser clara e natural. +- Escreva todos os números por extenso. +- Caso o cliente traga temas fora de troca de plano da TIM, oriente ligar para asterisco um quatro quatro ou usar o aplicativo Meu TIM. +- NUNCA responda diretamente com JSON. Sempre com texto. +- Mensagens que indicam para repetir ou não entendeu não é necessário chamar tool. +- NÃO gere mensagens do tipo "se precisa de algo mais só chamar". +- Só é necessário chamar a tool uma vez por input. PROIBIDO chamar tool mais de uma vez por input. +- Caso o cliente peça para esperar dizendo: "espera", "aguarde", "vou pegar", "só um minuto", "calma ai" ou algo que indique espera, adapte uma resposta conforme o contexto. + +--- + +## CONTORNO DE ROBOTIZAÇÃO + +É estritamente proibido utilizar tom mecânico, artificial ou que indique comportamento de robô. A fala deve soar como uma pessoa real em uma ligação telefônica. + +- É PROIBIDO iniciar mensagens com palavras automáticas ou previsíveis como "entendo", "claro", "perfeito", "certo", "ok". Mas pode usar precedido, por exemplo: "ah entendi", "hmm". +- Essas expressões podem ser usadas apenas no meio da frase, nunca no início. +- É obrigatório, de forma moderada e natural, inserir marcadores de oralidade como: "tá?", "né?", "hmm", "olha", "então", "ah sim" para simular fala humana. +- Essas expressões nunca devem ser usadas em excesso ou repetidas na mesma frase. +- Evite frases excessivamente formais, simétricas ou com estrutura publicitária. +- Sempre que possível, substitua frases duras, diretas ou artificiais por construções mais humanas, mantendo o mesmo significado e objetivo comercial. +- Usar linguagem de benefícios emocionais: "navegar sem preocupação", "postar à vontade com a família", "viajar tranquilo". + +Exemplos de substituição obrigatória: +"Você possui direito a este plano." → "Esse plano fica disponível pra você agora, tá?" +"O valor do plano é [valor_plano_final]." → "O valor fica [valor_plano_final], tranquilo?" +"Esse plano oferece mais benefícios." → "Ele acaba entregando mais benefícios no dia a dia, né?" +"Vamos prosseguir com a troca de plano?" → "A gente segue com a troca então, tá?" +"Esse serviço está incluso." → "Sim, esse serviço já vem incluso, sem custo a mais, tá?" + +Dicionário de marcadores de oralidade: "ah sim", "sabe", "olha", "então" — use diferentes no começo de frase. + +--- + +## INTERRUPÇÃO DE FALA + +Se a última mensagem do agente terminou com ###interrupção###, isso significa que você foi interrompido durante a fala. + +São duas situações: +1. Continuar o que dizia. +2. Lidar com o assunto da interrupção. + +Exemplo 1: +Agente: Judity, para sua segurança a ligação está sendo gravada tá? Tenho uma novidade ótima pra você. Mais internet ###interrupção### +Cliente: Alô? +Agente: Então, Mais internet no total de sessenta gigas para você navegar sem preocupação pagando apenas trinta e três reais a mais. Interessante para você? + +Exemplo 2: +Agente: Judity, para sua segurança a ligação está sendo gravada tá? Tenho uma novidade ótima pra você. Mais internet ###interrupção### +Cliente: oi eu não quero tá +Agente: não quer ouvir mais? + +- NUNCA escreva ###interrupção### no texto.""" + + # ══════════════════════════════════════════════════════════ + # Bloco 2 — Checkpoint dinâmico + # ══════════════════════════════════════════════════════════ + + def _checkpoint(self) -> str: + phase = self.state.current_phase + lines = ["## STATUS DA CONVERSA"] + + # 1. Apresentação + if phase == ConversationPhase.PRESENTATION: + lines.append(f"[→] 1. Apresentação — Validando nome do cliente (tentativa {self.state.name_validation_attempt}/4)") + elif phase.value in ("argumentation", "data_confirmation", "formalization", "done"): + lines.append("[x] 1. Apresentação — Cliente alvo confirmado ✓") + else: + lines.append("[ ] 1. Apresentação") + + # 2. Argumentação + if phase == ConversationPhase.ARGUMENTATION: + lines.append(f"[→] 2. Argumentação — Convencer o cliente (recusas: {self.state.arg_declines}/3, aceites: {self.state.arg_accepted_count}/2)") + elif phase.value in ("data_confirmation", "formalization", "done"): + lines.append("[x] 2. Argumentação — Cliente aceitou a oferta ✓") + else: + lines.append("[ ] 2. Argumentação") + + # 3. Confirmação de dados + if phase == ConversationPhase.DATA_CONFIRMATION: + name_status = "✓" if self.state.validated_target_customer else "✗" + cpf_status = "✓" if self.state.validated_cpf else "✗" + birth_status = "✓" if self.state.validated_birth_date else "✗" + lines.append(f"[→] 3. Confirmação de dados — Nome: {name_status}, CPF: {cpf_status}, Nascimento: {birth_status}") + elif phase.value in ("formalization", "done"): + lines.append("[x] 3. Confirmação de dados — Dados validados ✓") + else: + lines.append("[ ] 3. Confirmação de dados") + + # 4. Formalização + if phase == ConversationPhase.FORMALIZATION: + lines.append(f"[→] 4. Formalização — Aguardando confirmação final (recusas: {self.state.form_declines}/2)") + elif phase == ConversationPhase.DONE: + if self.state.form_accepted: + lines.append("[x] 4. Formalização — Compra finalizada ✓") + else: + lines.append("[x] 4. Formalização — Conversa encerrada") + else: + lines.append("[ ] 4. Formalização") + + return "\n".join(lines) + + # ══════════════════════════════════════════════════════════ + # Bloco 3 — Instruções ativas por fase + # ══════════════════════════════════════════════════════════ + + def _active_phase_instructions(self) -> str: + phase = self.state.current_phase + + if phase == ConversationPhase.PRESENTATION: + return self._instructions_presentation() + elif phase == ConversationPhase.ARGUMENTATION: + return self._instructions_argumentation() + elif phase == ConversationPhase.DATA_CONFIRMATION: + return self._instructions_data_confirmation() + elif phase == ConversationPhase.FORMALIZATION: + return self._instructions_formalization() + else: + return "## CONVERSA ENCERRADA\nNão responda mais. A conversa acabou." + + # ── Presentation ── + + def _instructions_presentation(self) -> str: + return """## ETAPA ATUAL: APRESENTAÇÃO + +Seu objetivo único nesta etapa é confirmar se o primeiro nome do interlocutor corresponde ao primeiro nome do cliente alvo antes de apresentar qualquer oferta. + +- Nunca ofereça a oferta nesta etapa. +- Nunca peça nome completo ou CPF. +- Não diga ou sugira que ligará novamente ou registrará contato. +- Se o cliente der indicios que vai chamar o dono, apenas aguarde. +- Seja dinâmico, mas tente sempre validar. + +### INÍCIO DA CONVERSA +Após receber ###START### diga: +"Olá, meu nome é Helena, sou consultora de vendas da TIM, para sua segurança a ligação está sendo gravada. Eu estou falando com {customer_first_name}?" + +### INTERPRETAÇÃO DAS RESPOSTAS + +#### CONFIRMAÇÃO +Se o interlocutor confirmar de forma direta ou implícita que é o cliente (frases como: "isso", "sou eu", "pode falar", "pois não", "tá", "sim", "é ele"/"é ela", "falando", ou disser o próprio nome): +- Invoque a tool classify_customer_response com confirmed_target_customer. +- Gere uma resposta curta e natural de transição, como: "Que bom! Então..." + +#### NÃO É O CLIENTE ALVO +Se o interlocutor indicar que não é o cliente ou que é familiar, terceiro ou que o cliente não está disponível: +- Invoque a tool classify_customer_response com denied_target_customer. +- Finalize imediatamente a chamada com uma mensagem de despedida adequada. +Nunca tente validar novamente nesses casos. + +#### DÚVIDAS, PERGUNTAS OU SITUAÇÕES INTERMEDIÁRIAS +- Se perguntarem o motivo da ligação: responda de forma breve "É sobre uma oferta da TIM, mas antes preciso confirmar se estou falando com {customer_first_name}." +- Se o cliente demonstrar que não ouviu ou não entendeu: repita a última frase. +- Se o cliente pedir para aguardar: responda "Claro, estou aguardando." +- Se o cliente falar de assunto fora do contexto 3x sem avanço, finalize a chamada. +- Para qualquer outra resposta, tente conduzir naturalmente para a confirmação do nome. + +### FINALIZAÇÃO +Finalize a chamada quando: +- O interlocutor não é o cliente alvo. +- Pedido para ligar em outro momento. +- Cliente não está disponível. +- Mensagens automáticas ou caixa postal. +- 4 tentativas sem confirmação. + +A mensagem final deve conter obrigatoriamente: "TIM agradece sua atenção." + +### EXEMPLOS + +Exemplo 1: +Agent: Olá, meu nome é Helena, sou consultora de vendas da Tim. Eu estou falando com jose? +User: Não, ele foi para o hospital +Agent: ok, irei fechar a conversa por não conseguir contactar o titular. A TIM agradece sua atenção. + +Exemplo 2: +Agent: Olá, meu nome é Helena, sou consultora de vendas da Tim. Eu estou falando com brenda? +User: O que você quer? +Agent: hmm, eu gostaria de apresentar uma oferta mas antes preciso confirmar se falo com a brenda. +User: pois não +(confirmed_target_customer) +Agent: Que bom! Então tenho uma novidade ótima pra você...""" + + # ── Argumentation ── + + def _instructions_argumentation(self) -> str: + return """## ETAPA ATUAL: ARGUMENTAÇÃO + +Seu objetivo é convencer o cliente a trocar de plano. A primeira mensagem desta etapa já foi enviada pelo sistema (a apresentação da oferta). Você deve continuar a conversa a partir da resposta do cliente. + +### REGRAS DA ARGUMENTAÇÃO +- Sempre tente conduzir o cliente à aceitação da oferta. +- Sempre que citar plano ou "servicos_valor_agregado", mantenha aspas exatamente como fornecidas. +- Não invente benefícios, características ou condições que não estejam explicitamente informadas. +- Quando o cliente perguntar sobre um serviço, ele quer saber se está incluso no preço da oferta. +- A oferta apresentada é sempre mais cara que o plano atual, porém com mais benefícios. +- Se precisar reapresentar a oferta, varie a fraseologia e o foco dos argumentos para evitar repetição. +- Você não pode falar com o cliente em outro momento. Se ele pedir para ligar depois, informe que a oferta só pode ser tratada agora. +- Sua função se limita exclusivamente à troca de plano. +- Você pode apenas oferecer o plano alvo apresentado, nunca invente outro plano. Não pode oferecer descontos, modificar oferta ou citar opções mais baratas. +- Mantenha o tom respeitoso e confiante, sem pressionar com insistência longa. +- MÁXIMO de 2 frases por turno. +- SEMPRE termine seu turno passando a vez pro cliente com uma pergunta curta. +- Adicionar social proof sutil: "Muitos clientes como você já estão aproveitando". + +### ROTEIRO DE ARGUMENTAÇÃO (Turn-taking) +O cliente vai responder ao gancho. Seu objetivo é apresentar os detalhes EM PARTES (micro-interações), sempre terminando com uma pergunta de validação curta (ex: "Faz sentido para você?", "Vamos fechar?", "tem interesse?", "Vamos trocar?" NÃO use o mesmo em sequência). + +Ordem de benefícios (obrigatória): +1° Apps e redes sociais que não consomem gigas +2° Serviços ilimitados (ligações, SMS, WhatsApp) +3° Outros benefícios (Deezer, TIM no Avião, roaming, etc.) + +- 1ª rejeição: Perguntar o motivo do cliente não ter interesse. +- 2ª rejeição: Contra-argumentar o ponto que ele disse. Se ainda negar, finalize. +- 3ª rejeição: Finalizar com "Tudo bem... A TIM agradece sua atenção." + +### CONFIRMAÇÃO DE ACEITE +Se o cliente demonstrar interesse ("quero", "pode ser", "topo", "sim"): +- Invoque detect_intention_purchase com customer_accepted_purchase. +- Pergunte novamente: "vamos fechar?" ou similar. +- Se confirmar de novo: + - Invoque detect_intention_purchase com customer_accepted_purchase. + - Gere uma mensagem de transição natural. +- São necessárias exatamente DUAS confirmações. + +### NEGATIVA +Se o cliente recusar ("não", "não quero", "caro", "tá bom do jeito que tá"): +- Invoque detect_intention_purchase com customer_declined_purchase. + +### OFF-TOPIC +- Se o cliente falar coisas aleatórias: off_topic_from_sale. +- 3 mensagens off-topic insistindo: finalizar. + +### FINALIZAÇÃO +- 3 recusas: finalizar com "Tudo bem... A TIM agradece sua atenção." +- 3 off-topics: finalizar com "Tudo bem... A TIM agradece sua atenção." +- Mensagens de finalização devem vir sozinhas. + +### EXEMPLO +Agente: "hmm Legal. No TIM Black você passa a ter [dados_GB] gigas. É bastante internet pra navegar à vontade. Vamos fechar?" +Cliente: "não sei" (other) +Agente: "sabe, e tem mais: Redes sociais não descontam dessa franquia, e o valor fica [valor_plano_final]. O que acha?" +Cliente: "Não quero" (customer_declined_purchase) +Agente: "E tem o Deezer incluso que muitos clientes já aproveitam, o que acha?" +Cliente: "Gostei" (customer_accepted_purchase) +Agente: "Então, Vamos fechar?" +Cliente: "Sim" (customer_accepted_purchase) +Agente: "Ótimo! Vamos prosseguir então..." + +### INFORMAÇÕES DO PLANO +Plano atual: {actual_plan} +Plano alvo: {target_plan} + +### BASE DE CONHECIMENTO +{knowledge_base}""" + + # ── Data Confirmation ── + + def _instructions_data_confirmation(self) -> str: + return """## ETAPA ATUAL: CONFIRMAÇÃO DE DADOS + +Seu objetivo é verificar a identidade do cliente confirmando nome completo e CPF ou data de nascimento. +- Chame apenas uma tool por turno, nunca duas ao mesmo tempo. +- Você NUNCA deve inventar um input que não foi dito pelo cliente. +- Não chame a tool se não sabe o que enviar ou se não entendeu e pediu para repetir. +- "meia" ou "seis" = 6 + +### 1. VALIDAÇÃO DE NOME + +Ao receber ###START### SEMPRE diga: +"Ótimo! então vamos ativar agora para você já aproveitar! Para prosseguirmos você pode por favor confirmar se seu nome é {customer_full_name}? Se sim, diga eu confirmo" + +#### 1.1 Confirmação +Se o cliente dizer "eu confirmo": +- Invoque detect_name_target_customer com is_target_customer=true + +#### 1.2 Negação +Se o cliente negar: +- Invoque detect_name_target_customer com is_target_customer=false e pergunte novamente + +#### 1.3 Confirmação diferente +Se não for dito "eu confirmo", pergunte se ele confirma. + +#### 1.4 Esperar +Se pedir para esperar, gere resposta educada dizendo que irá aguardar. Quando retornar, volte à confirmação. + +### 2. VALIDAÇÃO DO CPF (APÓS confirmar nome) + +Diga: "Poderia informar os últimos três dígitos do seu CPF?" + +- Se fornecer, chame check_cpf_digits. +- NUNCA INVENTE. +- Se negar, siga para data de nascimento. +- Por mais que seja pedido os últimos três dígitos, certifique de passar todos dígitos que foram ditos. Mas NUNCA peça ele completo. + +Formatação do CPF: +- 123.123.123.45 → dígitos = 12312312345 +- um dois meia oito → dígitos = 1268 +- meia sete oito dois vinte e quatro → dígitos = 678224 +- zero vinte e quatro sete sete sete oito quarenta e sete vinte e três → dígitos = 02477784723 + +#### 2.1 CPF correto → avançar para conclusão +#### 2.2 CPF incorreto → pedir data de nascimento +#### 2.3 Esperar → aguardar e retomar + +### 2B. DATA DE NASCIMENTO (se CPF negado ou errado) + +Diga: "Poderia confirmar sua data de nascimento?" + +- Chame check_birth_date com formato YYYY-MM-DD. +- Em português, "70" = 1970. + +#### Data correta → avançar +#### Data incorreta → pedir novamente + +### 3. CONCLUSÃO +Após tudo validado (nome + CPF ou nascimento), retorne uma mensagem vazia "". + +### 4. CANCELAMENTO +Se o cliente desistir de comprar: +- Invoque purchase_cancellation com end=true. +- Diga: "Tudo bem, não vamos prosseguir com a venda. A TIM agradece sua atenção!" + +### REGRAS CRÍTICAS +- Todos itens precisam ser validados: 1. Nome 2. CPF ou data de nascimento +- NUNCA finalize sem completar a verificação. +- NUNCA peça novamente algo já verificado. +- APENAS chame tool referente ao que foi dito, não ao passado. + +### EXEMPLO +Agent: "Ótimo! Para prosseguirmos, confirme se seu nome é fulano da silva? Se sim, diga eu confirmo" +Humano: "eu confirmo" +Agent: "Joia, poderia informar os últimos três dígitos do seu cpf?" +Humano: "123" +Agent: "" + +### INFORMAÇÕES DO CLIENTE ALVO +Informações do cliente: {customer}""" + + # ── Formalization ── + + def _instructions_formalization(self) -> str: + return """## ETAPA ATUAL: FORMALIZAÇÃO + +Seu objetivo é apresentar as informações do plano e obter a confirmação final do cliente. + +### 1.0 INÍCIO OBRIGATÓRIO + +Ao receber ###START### diga EXATAMENTE (substituindo apenas as variáveis entre colchetes): + +"Pra finalizar eu preciso apresentar uma série de informações sobre o novo plano. Vou começar: {tratamento} está adquirindo uma oferta do plano {plano_target}, que por doze meses terá o custo de {valor_plano_final}. Este é um desconto de {valor_desconto} em relação ao valor total do plano, que é {valor_plano_bruto}. Nesta oferta, {tratamento} tem {dados_GB} gigas para navegar na internet. Além disso, você tem os seguintes benefícios no novo plano: {beneficios_todos} e também recebe uma série de serviços de valor agregado. Você pode conhecê-los no APP meu Tim. A sua data de vencimento e forma de pagamento permanecem os mesmos. Na sua próxima fatura será cobrado o valor integral do plano antigo, mais o proporcional dos dias utilizados do plano adquirido. {tratamento} confirma a migração para o {plano_target} no valor de {valor_plano_final} fidelizado por doze meses?? Se sim, diga eu confirmo." + +### 1.1 Repetir +Só repita se o cliente pedir ou for necessário. + +### 2.0 CONFIRMAÇÃO DE VENDA + +#### 2.1 Se o cliente recusar: +- Diga: "Tem alguma dúvida que eu possa esclarecer??" +- Invoque detect_intention_purchase com customer_declined_purchase. + +#### 2.2 Se o cliente fizer uma pergunta: +- Responda brevemente, sem citar todos benefícios de uma vez. +- Se possível, termine com: "Se confirma em mudar de plano, diga: Eu confirmo". +- Invoque detect_intention_purchase com customer_asked_question. + +##### 2.2.1 Recusa após pergunta: +- Diga: "Tudo bem, {customer_first_name}. A Tim agradece sua atenção. Obrigado." +- Invoque detect_intention_purchase com customer_declined_purchase. + +#### 2.3 Se a pessoa aceitar ("eu confirmo"): +- Diga: "Obrigado, {customer_first_name}! Seu pedido de troca foi iniciado e em até vinte e quatro horas sua migração para o novo plano estará concluída. Entre três a cinco dias {tratamento} receberá uma pesquisa de satisfação. Para Tim cliente satisfeito é nota 9 ou 10. A Tim agradece a sua atenção." +- Invoque detect_intention_purchase com customer_accepted_purchase. + +#### 2.4 Se pedir para repetir: +- Diga de forma resumida. + +### 3. CANCELAMENTO +Se desistir de comprar: +- Invoque purchase_cancellation com end=true. +- Diga: "Tudo bem, não vamos prosseguir com a venda. A TIM agradece sua atenção!" + +### REGRAS +- 2 recusas: finalizar com "Tudo bem. A Tim agradece sua atenção." +- Se perguntas fora de contexto: responda e tente terminar com mensagem positiva sugerindo trocar. +- Você não pode fornecer descontos, a oferta é fixa. + +### INFORMAÇÕES DO PLANO +Informações do cliente: {customer} +Plano atual: {actual_plan} +Plano alvo: {target_plan} + +### BASE DE CONHECIMENTO +{knowledge_base}""" + + # ══════════════════════════════════════════════════════════ + # Bloco 4 — Informações do cliente + # ══════════════════════════════════════════════════════════ + + def _customer_info(self) -> str: + return "## FIM DAS INSTRUÇÕES" + + # ══════════════════════════════════════════════════════════ + # Substituição de variáveis + # ══════════════════════════════════════════════════════════ + + def _apply_vars(self, prompt: str) -> str: + """Substitui placeholders {key} pelos valores de prompt_vars.""" + for key, value in self.prompt_vars.items(): + prompt = prompt.replace("{" + key + "}", str(value)) + return prompt + + # ══════════════════════════════════════════════════════════ + # Carregar knowledge base + # ══════════════════════════════════════════════════════════ + + @staticmethod + @lru_cache + def load_knowledge_base() -> str: + return resources.files("agent.prompts").joinpath("knowledge_base.txt").read_text(encoding="utf-8") diff --git a/src/agent/stage/teste.py b/src/agent/stage/teste.py new file mode 100644 index 0000000..ff1521d --- /dev/null +++ b/src/agent/stage/teste.py @@ -0,0 +1,48 @@ +# teste/teste.py +from functools import lru_cache +from importlib import resources +from typing import Literal +from langchain.tools import tool +from agent.base.base_stage import BaseAgent + +class TestState: + def __init__(self): + self.count = 0 + +class TestAgent(BaseAgent): + def __init__(self, streaming=False, prompt_vars=None): + + state = TestState() + + tools = self._build_tools() + super().__init__( + tools=tools, + streaming=streaming, + prompt_vars=prompt_vars, + agent_name='test' + ) + self.state = state + + def _build_tools(self): + @tool + def classify_customer_response(response: Literal["positive","negative", "other"] + ): + """Classifica a resposta do cliente""" + + print(response) + self.state.count += 1 + + return "Lide com o input do usuario" + + return [classify_customer_response] + + @lru_cache + def _load_prompt(self) -> str: + prompt_text = resources.files("agent.prompts").joinpath("test.txt").read_text(encoding="utf-8") + return prompt_text + + def run(self, user_input): + return super().run(user_input) + + def run_stream(self, user_input): + return super().stream_run(user_input) \ No newline at end of file diff --git a/src/agent/stage/unified_agent.py b/src/agent/stage/unified_agent.py new file mode 100644 index 0000000..eadb8ae --- /dev/null +++ b/src/agent/stage/unified_agent.py @@ -0,0 +1,313 @@ +# stage/unified_agent.py +from functools import lru_cache +from importlib import resources +from typing import Literal +from langchain.tools import tool +from agent.base.base_stage import BaseAgent +from agent.stage.unified_state import UnifiedState, ConversationPhase +from agent.stage.prompt_manager import PromptManager + + +class UnifiedAgent(BaseAgent): + """Agente unificado que gerencia toda a conversa com prompt dinâmico.""" + + def __init__( + self, + expected_cpf_last_3: str = "", + expected_birth_date: str = "", + streaming: bool = False, + prompt_vars: dict = None, + ): + self.state = UnifiedState( + expected_cpf_last_3=expected_cpf_last_3, + expected_birth_date=expected_birth_date, + ) + + self._prompt_vars = prompt_vars or {} + + # Carrega knowledge base e adiciona às variáveis + knowledge = PromptManager.load_knowledge_base() + self._prompt_vars["knowledge_base"] = knowledge + + self.prompt_manager = PromptManager(self.state, self._prompt_vars) + + tools = self._build_tools() + + super().__init__( + tools=tools, + streaming=streaming, + prompt_vars={}, # não usar substituição do BaseAgent — o PromptManager cuida + agent_name="unified", + dynamic_prompt=True, # permite atualizar system_prompt entre chamadas + ) + + # ══════════════════════════════════════════════════════════ + # Overrides do BaseAgent + # ══════════════════════════════════════════════════════════ + + def _load_prompt(self) -> str: + """Retorna o prompt dinâmico gerado pelo PromptManager. + + NOTA: este método é chamado uma vez no __init__ do BaseAgent, + mas o prompt real é atualizado dinamicamente via _get_dynamic_prompt(). + """ + return self.prompt_manager.build_prompt() + + def _get_dynamic_prompt(self) -> str: + """Gera o prompt atualizado a cada invocação do agente.""" + return self.prompt_manager.build_prompt() + + # ══════════════════════════════════════════════════════════ + # Tools — todas as 6 tools dos 4 estágios + # ══════════════════════════════════════════════════════════ + + def _build_tools(self): + + # ── Tool 1: Presentation ── + @tool + def classify_customer_response( + response: Literal["confirmed_target_customer", "denied_target_customer", "other"] + ): + """Classifica a resposta do cliente na etapa de apresentação: +confirmed_target_customer: interlocutor confirmou ser o cliente alvo +denied_target_customer: interlocutor negou ser o cliente alvo +other: outro""" + print(f"[Tool] classify_customer_response: {response}") + self.state.name_validation_attempt += 1 + + if self.state.name_validation_attempt >= 4: + return "Finalize a conversa com uma mensagem que contenha no final 'A TIM agradece sua atenção.'" + + if response == "confirmed_target_customer": + self.state.is_target_customer = True + return "Cliente confirmado. Aguarde a próxima etapa." + + if response == "denied_target_customer": + return "Finalize a conversa com uma mensagem de despedida que contenha 'A TIM agradece sua atenção.'" + + return "Lide com o input do usuario" + + # ── Tool 2: Argumentation + Formalization ── + @tool + def detect_intention_purchase( + intention: Literal[ + "customer_declined_purchase", + "customer_accepted_purchase", + "off_topic_from_sale", + "customer_asked_question", + "other", + ] + ): + """O agente deve chamar esta ferramenta sempre que o cliente falar algo. +- customer_declined_purchase: cliente negou a oferta +- customer_accepted_purchase: cliente aceitou comprar +- off_topic_from_sale: conversas aleatórias sem sentido +- customer_asked_question: cliente fez uma pergunta +- other: outro""" + print(f"[Tool] detect_intention_purchase ({self.state.current_phase.value}): {intention}") + + phase = self.state.current_phase + + # ── ARGUMENTATION ── + if phase == ConversationPhase.ARGUMENTATION: + if intention == "customer_accepted_purchase": + self.state.arg_accepted_count += 1 + if self.state.arg_accepted_count >= 2: + self.state.arg_accepted = True + return "Cliente confirmou a compra. Aguarde a próxima etapa." + return "Peça a segunda confirmação" + + # Reset accepted count se não for aceite + self.state.arg_accepted_count = 0 + + if intention == "customer_declined_purchase": + self.state.arg_declines += 1 + if self.state.arg_declines > 2: + return f"{self.state.arg_declines}º recusa. Finalize a conversa com 'Tudo bem... A TIM agradece sua atenção.'" + return f"{self.state.arg_declines}º rejeição" + + if intention == "off_topic_from_sale": + self.state.arg_off_topic += 1 + if self.state.arg_off_topic > 3: + return "Finalize a conversa com 'Tudo bem... A TIM agradece sua atenção.'" + + return "Lide com o input do usuario" + + # ── FORMALIZATION ── + elif phase == ConversationPhase.FORMALIZATION: + if intention == "customer_accepted_purchase": + self.state.form_accepted = True + return intention + + if intention == "customer_declined_purchase": + self.state.form_declines += 1 + return intention + + if intention == "customer_asked_question": + self.state.form_questions += 1 + return intention + + return intention + + return "Lide com o input do usuario" + + # ── Tool 3: Data Confirmation - Nome ── + @tool + def detect_name_target_customer(is_target_customer: bool): + """Detecta se está falando com o cliente alvo ou não. Chame sempre que responder a pergunta se está falando com o cliente.""" + print(f"[Tool] detect_name_target_customer: {is_target_customer}") + self.state.validated_target_customer = is_target_customer + self.state.attempt_customer += 1 + return is_target_customer + + # ── Tool 4: Data Confirmation - CPF ── + @tool + def check_cpf_digits(digits: str): + """Recebe exatamente os dígitos do CPF completo ditos pelo cliente""" + print(f"[Tool] check_cpf_digits: {digits}") + digits = digits[-3:] + self.state.cpf_last_3_digits = digits + self.state.attempt_cpf += 1 + self.state.validated_cpf = digits == self.state.expected_cpf_last_3_digits + return "correct" if self.state.validated_cpf else "incorrect" + + # ── Tool 5: Data Confirmation - Data Nascimento ── + @tool + def check_birth_date(date_YYYY_MM_DD: str): + """Captura a data de nascimento do cliente (YYYY-MM-DD).""" + print(f"[Tool] check_birth_date: {date_YYYY_MM_DD}") + self.state.birth_date_YYYY_MM_DD = date_YYYY_MM_DD + self.state.attempt_birth_date += 1 + self.state.validated_birth_date = date_YYYY_MM_DD == self.state.expected_birth_date_YYYY_MM_DD + return "correct" if self.state.validated_birth_date else "incorrect" + + # ── Tool 6: Cancelamento ── + @tool + def purchase_cancellation(end: bool): + """Encerra a chamada mediante mensagem clara de desistência da compra""" + print(f"[Tool] purchase_cancellation: {end}") + self.state.end_conversation = end + return end + + return [ + classify_customer_response, + detect_intention_purchase, + detect_name_target_customer, + check_cpf_digits, + check_birth_date, + purchase_cancellation, + ] + + # ══════════════════════════════════════════════════════════ + # Set interruption + # ══════════════════════════════════════════════════════════ + + def set_interruption_message(self, text: str): + if self.messages: + self.messages[-1].content = f"{text}" + + # ══════════════════════════════════════════════════════════ + # Run principal — com transição de fase + # ══════════════════════════════════════════════════════════ + + def run_unified(self, user_input: str): + """Executa um turno da conversa e verifica transições de fase. + + Retorna: + dict com keys: output, phase, auto, transition_text + - output: resposta do agente + - phase: fase atual após o turno + - auto: se houve transição automática (próxima fase precisa de START) + - transition_text: texto fixo a ser empurrado para o output quando há transição + """ + self.state.save_state() + + # Atualiza o system prompt dinamicamente antes de rodar + self.system_prompt = self._get_dynamic_prompt() + + # Limpa formatação de CPF/datas no input + if self.state.current_phase == ConversationPhase.DATA_CONFIRMATION: + user_input = user_input.replace(".", "").replace("-", "").replace("/", "") + + result = self.run(user_input) + output = result["output"] + + # Verifica se precisa finalizar + if "tim agradece sua atenção" in output.lower(): + self.state.end_conversation = True + + # ── Transições de fase ── + phase = self.state.current_phase + + # Presentation → Argumentation + if phase == ConversationPhase.PRESENTATION and self.state.is_target_customer: + self.state.current_phase = ConversationPhase.ARGUMENTATION + print(f"\n🔄 Transição: PRESENTATION → ARGUMENTATION") + return { + "output": output, + "phase": ConversationPhase.ARGUMENTATION.value, + "auto": True, + "transition_text": None, # será gerado pelo pipeline com start_message_argumentation + } + + # Argumentation → DataConfirmation + if phase == ConversationPhase.ARGUMENTATION and self.state.arg_accepted: + self.state.current_phase = ConversationPhase.DATA_CONFIRMATION + print(f"\n🔄 Transição: ARGUMENTATION → DATA_CONFIRMATION") + return { + "output": output, + "phase": ConversationPhase.DATA_CONFIRMATION.value, + "auto": True, + "transition_text": None, # será gerado pelo pipeline com ###START### + } + + # DataConfirmation → Formalization + if phase == ConversationPhase.DATA_CONFIRMATION and self.state.is_authenticated: + self.state.current_phase = ConversationPhase.FORMALIZATION + print(f"\n🔄 Transição: DATA_CONFIRMATION → FORMALIZATION") + return { + "output": output, + "phase": ConversationPhase.FORMALIZATION.value, + "auto": True, + "transition_text": None, # será gerado pelo pipeline com texto obrigatório + } + + # Formalization → DONE (aceite) + if phase == ConversationPhase.FORMALIZATION and self.state.form_accepted: + self.state.current_phase = ConversationPhase.DONE + print(f"\n✅ Transição: FORMALIZATION → DONE (aceite)") + return { + "output": output, + "phase": ConversationPhase.DONE.value, + "auto": False, + "transition_text": None, + } + + # Formalization → DONE (2 recusas) + if phase == ConversationPhase.FORMALIZATION and self.state.form_declines >= 2: + self.state.current_phase = ConversationPhase.DONE + print(f"\n❌ Transição: FORMALIZATION → DONE (recusas)") + return { + "output": output, + "phase": ConversationPhase.DONE.value, + "auto": False, + "transition_text": None, + } + + # End conversation (qualquer fase) + if self.state.end_conversation: + self.state.current_phase = ConversationPhase.DONE + return { + "output": output, + "phase": ConversationPhase.DONE.value, + "auto": False, + "transition_text": None, + } + + # Sem transição — continua na mesma fase + return { + "output": output, + "phase": phase.value, + "auto": False, + "transition_text": None, + } diff --git a/src/agent/stage/unified_state.py b/src/agent/stage/unified_state.py new file mode 100644 index 0000000..bd03a7b --- /dev/null +++ b/src/agent/stage/unified_state.py @@ -0,0 +1,107 @@ +# stage/unified_state.py +import copy +from enum import Enum + + +class ConversationPhase(str, Enum): + PRESENTATION = "presentation" + ARGUMENTATION = "argumentation" + DATA_CONFIRMATION = "data_confirmation" + FORMALIZATION = "formalization" + DONE = "done" + + +class UnifiedState: + """Estado unificado que consolida os 4 estágios da conversa.""" + + def __init__( + self, + expected_cpf_last_3: str = "", + expected_birth_date: str = "", + ): + # ── Global ── + self.current_phase = ConversationPhase.PRESENTATION + self.end_conversation = False + + # ── Presentation ── + self.name_validation_attempt = 0 + self.is_target_customer = False + + # ── Argumentation ── + self.arg_declines = 0 + self.arg_accepted_count = 0 + self.arg_accepted = False + self.arg_off_topic = 0 + + # ── Data Confirmation ── + self.expected_cpf_last_3_digits = expected_cpf_last_3 + self.expected_birth_date_YYYY_MM_DD = expected_birth_date + self.cpf_last_3_digits = None + self.birth_date_YYYY_MM_DD = None + self.validated_target_customer = False + self.validated_cpf = False + self.validated_birth_date = False + self.attempt_customer = 0 + self.attempt_cpf = 0 + self.attempt_birth_date = 0 + + # ── Formalization ── + self.form_declines = 0 + self.form_accepted = False + self.form_questions = 0 + + # ── Backup ── + self._saved_state = None + self.save_state() + + # ── Propriedades ── + + @property + def is_authenticated(self): + return ( + self.validated_target_customer + and (self.validated_cpf or self.validated_birth_date) + ) + + @property + def attempts_exceeded(self): + return any([ + self.attempt_customer >= 3, + self.attempt_cpf >= 3, + self.attempt_birth_date >= 3, + ]) + + # ── Save / Restore ── + + def save_state(self): + state = copy.deepcopy(self.__dict__) + state.pop("_saved_state", None) + self._saved_state = state + + def restore_state(self): + if self._saved_state is None: + raise RuntimeError("Nenhum estado foi salvo ainda.") + self.__dict__.update(copy.deepcopy(self._saved_state)) + + # ── Debug ── + + def print_state(self): + print(f"phase: {self.current_phase.value}") + print(f"end_conversation: {self.end_conversation}") + print(f"--- Presentation ---") + print(f" name_validation_attempt: {self.name_validation_attempt}") + print(f" is_target_customer: {self.is_target_customer}") + print(f"--- Argumentation ---") + print(f" arg_declines: {self.arg_declines}") + print(f" arg_accepted_count: {self.arg_accepted_count}") + print(f" arg_accepted: {self.arg_accepted}") + print(f" arg_off_topic: {self.arg_off_topic}") + print(f"--- Data Confirmation ---") + print(f" validated_target_customer: {self.validated_target_customer}") + print(f" validated_cpf: {self.validated_cpf}") + print(f" validated_birth_date: {self.validated_birth_date}") + print(f" is_authenticated: {self.is_authenticated}") + print(f"--- Formalization ---") + print(f" form_declines: {self.form_declines}") + print(f" form_accepted: {self.form_accepted}") + print(f" form_questions: {self.form_questions}") diff --git a/src/agent/utils/utils.py b/src/agent/utils/utils.py new file mode 100644 index 0000000..2988b91 --- /dev/null +++ b/src/agent/utils/utils.py @@ -0,0 +1,254 @@ +import re +import math +from dataclasses import dataclass +from num2words import num2words +from langchain_openai import ChatOpenAI +from langchain_oci import ChatOCIGenAI +#from langchain_google_genai import ChatGoogleGenerativeAI +import math + +from app.common.timed import timed +from dotenv import load_dotenv +import os +load_dotenv() + +def sentence_confidence(words): + probs = [w["probability"] for w in words if w["probability"] > 0] + if not probs: + return 0.0 + log_mean = sum(math.log(p) for p in probs) / len(probs) + return round(math.exp(log_mean), 4) + +"""llm = ChatOpenAI( + model="openai/gpt-oss-20b", + api_key="fake-key", + base_url="http://10.153.34.154/gpt-oss-20b/v1", + temperature=0.0, + top_p=0.1, + reasoning_effort="low", + )""" + +llm = ChatOCIGenAI( + model_id=os.getenv("OCI_ENDPOINT_ID", ""), + service_endpoint=os.getenv("OCI_ENDPOINT", ""), + compartment_id=os.getenv("OCI_COMPARTMENT_ID", ""), + auth_file_location=os.getenv("OCI_AUTH_FILE_LOCATION", "./config"), + model_kwargs={"temperature": 0.0, + "top_p": 0.1, + "reasoning_effort":"MINIMAL"} + ) + + +target_plans = {"TIM_BLACK_C_LIGHT":{ + "plano": "\"TIM Black Cê Light\"", + "beneficios_001": "tem as principais redes sociais sem consumir seus gigas, e ainda ganha o Deezer pra ouvir suas músicas", + "beneficios_002": "não consome seus gigas quando usar as principais redes sociais, tem acesso ao wifi nos aviões da gol e da latân e tem também o pacote chile de roaming internacional", + "beneficios_todos": """ligações e SMS ilimitados. Instagram, Facebook, X e mensagens de texto no Whatsapp sem consumir seus gigas. Acesso ao wifi nos aviões da gol e da latân. Pacote Chile de roaming internacional.""", + "dependentes": "Não há dependentes", + "forma_pagamento": "Fatura", + "comunicacao_whatsapp": "troca de mensagens é ilimitada, mas áudio e vídeo consomes seus Gigas", + "comunicacao_ligacoes_voz": "Ilimitadas", + "comunicacao_sms": "Ilimitados", + "wifi_no_aviao": "TIM no Avião", + "roaming_internacional": "Pacote Chile", + "servicos_valor_agregado": """ "Aya Audiobooks Premium", "Bancah Premium" mais Jornais", "EXA Segurança Premium", "Aya Ensinah Premium", "EXA Cloud", Busuu, "Fluid Premium" """, + "nao_consome_dos_gigas": "Instagram, Facebook, X", + "aplicativos_inclusos": "Não há", + "aplicativos_a_escolher": "Não há" +}, +"TIM_BLACK_A":{ + "plano": "\"TIM Black AH\"", + "beneficios_001": "tem as principais redes sociais sem consumir seus gigas, e ainda ganha o Deezer pra ouvir suas músicas", + "beneficios_002": "tem o aplicativo de músicas Deezer incluído na mensalidade, não consome seus gigas quando usar as principais redes sociais, tem acesso ao wifi nos aviões da gol e da latân e tem também o pacote américas de roaming internacional", + "beneficios_todos": """aplicativo Deezer. Ligações e SMS ilimitados. Instagram, Facebook e X ilimitados. Além disso, acesso ao wifi nos aviões da gol e latân, juntamente com o pacote Américas de roaming internacional.""", + "categoria_plano": "pós pago", + "dependentes": "Não há dependentes", + "forma_pagamento": "Fatura", + "comunicacao_whatsapp": "ilimitado para texto, audio, video", + "comunicacao_ligacoes_voz": "Ilimitadas", + "comunicacao_sms": "Ilimitados", + "wifi_no_aviao": "TIM no Avião", + "roaming_internacional": "Pacote Américas", + "servicos_valor_agregado": """ "Aya Audiobooks Premium", "Bancah Premium mais Jornais", "EXA Segurança Premium", "Aya Ensinah Premium", "EXA Cloud", Busuu, "Fluid Premium", "Fit Me App", "It Game" """, + "nao_consome_dos_gigas": "Instagram, Facebook, X", + "aplicativos_inclusos": "Deezer", + "aplicativos_a_escolher": "Não há" +}, +"TIM_BLACK_C_HERO":{ + "plano": "\"Tim Black Cê Hero\"", + "beneficios_001": "tem as principais redes sociais sem consumir seus gigas, podendo escolher um aplicativo de streaming entre: Deezer, Disney plus, Max, Prime video e Youtube premium", + "beneficios_002": "não consome seus gigas quando usar as principais redes sociais, tem acesso ao wifi nos aviões da gol e da latân e tem também o pacote américas de roaming internacional.", + "beneficios_todos": """Escolher um aplicativo entre: Deezer, Disney plus, Max, Prime video e Youtube premium. Ligações e SMS ilimitados. Instagram, Facebook e X ilimitados. Além disso, acesso ao wifi nos aviões da gol e latân, juntamente com o pacote Américas de roaming internacional.""", + "categoria_plano": "pós pago", + "dependentes": "Não há dependentes", + "forma_pagamento": "Fatura", + "comunicacao_whatsapp": "ilimitado para texto, audio, video", + "comunicacao_ligacoes_voz": "Ilimitadas", + "comunicacao_sms": "Ilimitados", + "wifi_no_aviao": "TIM no Avião", + "roaming_internacional": "Pacote Américas", + "servicos_valor_agregado": """ "Aya Audiobooks Premium", "Bancah Premium mais Jornais", "EXA Segurança Premium", "Aya Ensinah Premium", "EXA Cloud", Busuu, "Fluid Premium", "Fit Me App", "It Game" """, + "nao_consome_dos_gigas": "Instagram, Facebook, X", + "aplicativos_inclusos": "A escolher", + "aplicativos_a_escolher": "Deezer, Disney plus, Max, Prime video, Youtube premium" +} + +} +@timed("normalize_name") +def normalize_name(name): + x = f"""Normalize o nome abaixo: capitalize e acentue conforme o padrão brasileiro. +Nomes estrangeiros não devem ser acentuados. Retorne APENAS o nome, nada mais. +Exemplos: + +fabio assuncao muller → Fábio Assunção Muller +fatima schmidt → Fátima Schmidt +romulo aragao → Rômulo Aragão +angela maria → Ângela Maria +hortencia → Hortência +EMANUELA → Emanuela +jose dos santos → José dos Santos +lucio fernandez → Lúcio Fernandez +GONCALVES → Gonçalves +JOAO PAULO → João Paulo +--- +Nome: {name} +Resultado:""" + result = llm.invoke(x) + #print("Texto:",result.content) + #print("Metadata:",result.usage_metadata) + #print("*"*100) + return result.content + +def get_gender(mailing): + mailing['SEXO'] = llm.invoke("Retorne APENAS as letras M ou F de acordo com o nome se o sexo é masculino ou feminimo não use ' ou ` retorne apenas uma letra, sendo M ou F:" + mailing['NOME_CLIENTE_COMPLETO']).content + mailing['TRATAMENTO'] = "O senhor" if mailing['SEXO'] == "M" else "A senhora" + return mailing + +def money_to_words(value: float) -> str: + if value is None: + return "" + + reais = int(value) + centavos = int(round((value - reais) * 100)) + + partes = [] + if reais > 0: + partes.append(f"{num2words(reais, lang='pt_BR')} real" if reais == 1 else f"{num2words(reais, lang='pt_BR')} reais") + if centavos > 0: + partes.append(f"{num2words(centavos, lang='pt_BR')} centavo" if centavos == 1 else f"{num2words(centavos, lang='pt_BR')} centavos") + + return " e ".join(partes) if partes else "zero real" + +def calculate_data_bonus(mailing: dict) -> float | None: + gb = eval(mailing['BONUS_DESTINO']) + if mailing['PLANO_DESTINO'] == "TIM_BLACK_A": + gb += 15 + if mailing['PLANO_DESTINO'] == "TIM_BLACK_C_LIGHT": + gb += 20 + + return gb + +def process_plan(plan: dict, mailing: dict, plan_type: str = "actual") -> dict: + + plan.pop("preco_reais", None) + plan.pop("dados_GB", None) + if plan_type == "actual": + valor = mailing.get("MEDIA_RECARGA_2") + fidelization = mailing.get("FIDELIZACAO") or {} + if valor: + plan["valor_pago_ultimos_3_meses"] = money_to_words(valor) + else: + plan["valor_pago_ultimos_3_meses"] = "Não informado" + + plan["plano"] = f"\"{mailing["PLANO_ORIGEM"].strip().title()}\"" + + plan["data_expirar_fidelizacao"] = fidelization.get("final_date") + plan["meses_restantes_fidelizacao"] = fidelization.get("expiration") + plan["valor_plano_sem_fidelização"] = money_to_words(mailing["VALOR_PLANO_CORE"]) + + plan["gb_plano_atual"] = num2words(float(mailing.get("DADOS_CORE").replace(',','.').replace('GB','')), lang='pt_BR') + + elif plan_type == "target": + bruto = mailing.get("VLR_PLANO_DESTINO", 0) or 0 + final = mailing.get("VLR_FINAL_PLANO_DEST", 0) or 0 + desconto = round(bruto - final, 2) + plan['plano'] = plan['plano'].title() + + if mailing.get("MEDIA_RECARGA_2"): + plan['quanto_pago_a_mais'] = money_to_words(abs(math.ceil(final - mailing.get("MEDIA_RECARGA_2")))) + else: + plan['quanto_pago_a_mais'] = "Não informado" + + dados_gb = calculate_data_bonus(mailing) + + gb_atual = float(mailing.get("DADOS_CORE").replace(',','.').replace('GB','')) + gb_alvo_diferenca = dados_gb - gb_atual + + plan.update({ + "valor_plano_bruto": money_to_words(bruto), + "valor_desconto": money_to_words(desconto), + "valor_plano_final": money_to_words(final), + "diferenca_reajuste_fidelizacao": money_to_words(mailing.get("VLR_FINAL_PLANO_DEST", 0) - mailing.get("VALOR_PLANO_CORE", 0)), + "dados_GB": num2words(dados_gb, lang='pt_BR'), + "gb_alvo_diferenca": num2words(gb_alvo_diferenca, lang='pt_BR'), + "preço_por_dia": f"{money_to_words(round(final/30, 2))}" + }) + + else: + raise ValueError("Tipo de plano inválido. Use 'actual' ou 'target'.") + #print(plan) + return plan + +def process_mailing(mailing: dict, campos_desejados=None) -> dict: + """ + Filtra e padroniza os dados do mailing conforme as chaves relevantes. + """ + if campos_desejados is None: + campos_desejados = [ + "NOME_CLIENTE_COMPLETO", "NUM_CPF_CNPJ_CLIENTE", "NUM_TELEFONE", + "DT_NASCIMENTO", "PLANO_ORIGEM", "FLG_TRIPLE_A_OP", "TIPO_MAILING", + "DAT_NASCIMENTO", "NOME_MAE", "SEXO", "TRATAMENTO", "BONUS_DESTINO", + "ELEGIBILIDADE", "PROTOCOLO", "cliente_alvo_primeiro_nome" + ] + + mailing["cliente_alvo_primeiro_nome"] = mailing["NOME_CLIENTE_COMPLETO"].split(" ")[0] + mailing["NUM_CPF_CNPJ_CLIENTE"] = mailing.get("NUM_CPF_CNPJ_CLIENTE", "")[-3:] + return {k: v for k, v in mailing.items() if k in campos_desejados} + +def get_plan(plan: str): + return target_plans[plan] + +def start_message_argumentation(variaveis: dict) -> str: + meses_restantes = variaveis.get("meses_restantes_fidelizacao") + quanto_pago = variaveis.get("quanto_pago_a_mais_int") + + # Definição do texto base conforme regras + if meses_restantes is not None and meses_restantes <= 2: + + if quanto_pago is not None and quanto_pago <= 20: + template = """[cliente_alvo_primeiro_nome]! Você tem hoje um plano controle com [gb_plano_atual] gigas de internet. Em pouco tempo, mais exatamente em [um_mes_ou_dois_meses], acaba o período promocional que você possui e ele será reajustado para [valor_plano_sem_fidelização], que é o valor integral do seu plano sem os descontos. Para que você não tenha que pagar este reajuste sem receber novas vantagens, a TIM aprovou sua migração para o plano TIM Black, onde, ao invés dos [gb_plano_atual] gigas de hoje você terá [dados_GB] Gigas pra usar a internet à vontade e também [beneficios_001]. Este novo plano tem o valor de [valor_plano_final] por mês, sem aumento por doze meses. Uma pequena diferença de [quanto_pago_a_mais] comparando o valor reajustado do seu plano, com muito mais benefícios. Vamos mudar seu plano e aproveitar essa super promoção?""" + else: + template = """[cliente_alvo_primeiro_nome]! Você tem hoje um plano controle com [gb_plano_atual] gigas de internet. Em pouco tempo, mais exatamente em [um_mes_ou_dois_meses], acaba o período promocional que você possui e ele será reajustado para [valor_plano_sem_fidelização], que é o valor integral do seu plano sem os descontos. Para que você não tenha que pagar este reajuste sem receber novas vantagens, a TIM aprovou sua migração para o plano TIM Black, onde, ao invés dos [gb_plano_atual] gigas de hoje você terá [dados_GB] Gigas pra usar a internet à vontade e também [beneficios_001]. Este novo plano tem o valor de [valor_plano_final] por mês, sem aumento por doze meses. Vamos mudar seu plano e aproveitar essa super promoção?""" + + else: + if quanto_pago is not None and quanto_pago <= 20: + template = """[cliente_alvo_primeiro_nome], tenho uma novidade ótima pra você. O plano Black com [gb_alvo_diferenca] gigas a mais para você navegar sem preocupação pagando quase a mesma coisa, [quanto_pago_a_mais] a mais no total de [valor_plano_final] mensais. Vamos aproveitar essa condição?""" + else: + template = """[cliente_alvo_primeiro_nome], tenho uma novidade ótima pra você. O plano Black com [gb_alvo_diferenca] gigas a mais para você navegar sem preocupação juntamente com outros benefícios por cerca de [preco_por_dia] por dia, no total de [valor_plano_final] mensais. Vamos aproveitar essa condição?""" + + # Tratamento especial para "um mês / dois meses" + if meses_restantes in [0,1]: + variaveis["um_mes_ou_dois_meses"] = "um mês" + elif meses_restantes == 2: + variaveis["um_mes_ou_dois_meses"] = "dois meses" + else: + variaveis["um_mes_ou_dois_meses"] = "" + + # Função de substituição automática + def substituir(match): + chave = match.group(1) + return str(variaveis.get(chave, "")) + + mensagem_final = re.sub(r"\[([^\]]+)\]", substituir, template) + + return mensagem_final diff --git a/src/app/agent_entry.py b/src/app/agent_entry.py new file mode 100644 index 0000000..21ebf10 --- /dev/null +++ b/src/app/agent_entry.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from livekit.agents import cli + +from app.livekit.main import server + + +if __name__ == "__main__": + cli.run_app(server) diff --git a/src/app/bridge_entry.py b/src/app/bridge_entry.py new file mode 100644 index 0000000..b77ad43 --- /dev/null +++ b/src/app/bridge_entry.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import argparse +import os + +import uvicorn + +from app.ws_gateway.main import app + + +def main() -> None: + parser = argparse.ArgumentParser(description="LiveKit WebSocket Gateway (PCM16 16k <-> LiveKit)") + parser.add_argument("--port", type=int, default=8000) + parser.add_argument("--host", type=str, default="0.0.0.0") + parser.add_argument("--log-level", type=str, default="info") + parser.add_argument("--reload", action="store_true") + args = parser.parse_args() + + uvicorn.run( + app, + host=args.host, + port=args.port, + ws_ping_interval=float(os.getenv("UVICORN_WS_PING_INTERVAL_S", "20")), + ws_ping_timeout=float(os.getenv("UVICORN_WS_PING_TIMEOUT_S", "20")), + ws_max_size=50 * 1024 * 1024, + log_level="info" if args.log_level is None else args.log_level, + reload=args.reload, + ) + + +if __name__ == "__main__": + main() diff --git a/src/app/common/call_config.py b/src/app/common/call_config.py new file mode 100644 index 0000000..a871722 --- /dev/null +++ b/src/app/common/call_config.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +from typing import Any, Dict, Mapping + + +FAKE_AGENT_MIN_RESPONSES = 2 +FAKE_AGENT_MAX_RESPONSES = 10 +FAKE_AGENT_MIN_RESPONSE_CHARS = 40 +FAKE_AGENT_MAX_RESPONSE_CHARS = 180 +FAKE_AGENT_DEFAULT_DELAY_MS = 2500 +FAKE_AGENT_MAX_DELAY_MS = 180000 + + +def _as_str(value: Any) -> str: + if value is None: + return "" + return str(value).strip() + + +def _section(payload: Mapping[str, Any], *keys: str) -> Dict[str, Any]: + for key in keys: + value = payload.get(key) + if isinstance(value, Mapping): + return dict(value) + return {} + + +def _pick_str(payload: Mapping[str, Any], *keys: str) -> str: + for key in keys: + value = payload.get(key) + if value not in (None, ""): + return _as_str(value) + return "" + + +def normalize_call_config(payload: Mapping[str, Any] | None) -> Dict[str, Any]: + if not isinstance(payload, Mapping): + return { + "agent_backend": "", + "stt": {}, + "tts": {}, + "vad": {}, + "vad_logging": {}, + "ws": {}, + "agent_fake": {}, + } + + stt = _section(payload, "stt") + tts = _section(payload, "tts") + vad = _section(payload, "vad") + vad_logging = _section(payload, "vadLogging", "vad_logging") + ws = _section(payload, "ws") + agent_fake = _section(payload, "agentFake", "agent_fake") + return { + "agent_backend": _pick_str(payload, "agentBackend", "agent_backend"), + "stt": { + "provider": _pick_str(stt, "provider"), + "language": _pick_str(stt, "language"), + "api_key": _pick_str(stt, "apiKey", "api_key"), + "initial_prompt": _pick_str(stt, "initialPrompt", "initial_prompt"), + "config_override": _pick_str(stt, "configOverride", "config_override"), + "min_prob_single_word": _pick_str(stt, "minProbSingleWord", "min_prob_single_word"), + "disable_vosk": _pick_str(stt, "disableVosk", "disable_vosk"), + }, + "tts": { + "provider": _pick_str(tts, "provider"), + "voice_id": _pick_str(tts, "voiceId", "voice_id"), + "model_id": _pick_str(tts, "modelId", "model_id"), + "language": _pick_str(tts, "language"), + }, + "vad": { + "min_speech_duration": _pick_str(vad, "minSpeechDuration", "min_speech_duration"), + "activation_threshold": _pick_str(vad, "activationThreshold", "activation_threshold"), + "deactivation_threshold": _pick_str(vad, "deactivationThreshold", "deactivation_threshold"), + "min_silence_duration": _pick_str(vad, "minSilenceDuration", "min_silence_duration"), + "prefix_padding_duration": _pick_str(vad, "prefixPaddingDuration", "prefix_padding_duration"), + "pre_backend_wait_notice_fast_on_vad_pause": _pick_str( + vad, + "preBackendWaitNoticeFastOnVadPause", + "pre_backend_wait_notice_fast_on_vad_pause", + ), + "deferred_interruption_min_audio_ms": ( + _pick_str( + vad, + "deferredInterruptionMinAudioMs", + "deferred_interruption_min_audio_ms", + "DEFERRED_INTERRUPTION_MIN_AUDIO_MS", + ) + or _pick_str( + payload, + "deferredInterruptionMinAudioMs", + "deferred_interruption_min_audio_ms", + "DEFERRED_INTERRUPTION_MIN_AUDIO_MS", + ) + ), + "deferred_interruption_enabled": ( + _pick_str( + vad, + "deferredInterruptionEnabled", + "deferred_interruption_enabled", + "DEFERRED_INTERRUPTION_ENABLED", + ) + or _pick_str( + payload, + "deferredInterruptionEnabled", + "deferred_interruption_enabled", + "DEFERRED_INTERRUPTION_ENABLED", + ) + ), + }, + "vad_logging": { + "log_decisions": _pick_str(vad_logging, "logDecisions", "log_decisions"), + "log_activity": _pick_str(vad_logging, "logActivity", "log_activity"), + "activity_min_probability": _pick_str( + vad_logging, + "activityMinProbability", + "activity_min_probability", + ), + }, + "ws": { + "output_gain": _pick_str(ws, "outputGain", "output_gain"), + "audio_in_backlog_shed_enabled": _pick_str( + ws, + "audioInputBacklogShedEnabled", + "audio_in_backlog_shed_enabled", + ), + "audio_in_backlog_shed_threshold_ms": _pick_str( + ws, + "audioInputBacklogShedThresholdMs", + "audio_in_backlog_shed_threshold_ms", + ), + "audio_in_backlog_shed_keep_ms": _pick_str( + ws, + "audioInputBacklogShedKeepMs", + "audio_in_backlog_shed_keep_ms", + ), + "audio_in_latency_metrics_enabled": _pick_str( + ws, + "audioInputLatencyMetricsEnabled", + "audio_in_latency_metrics_enabled", + ), + "audio_in_latency_alert_ms": _pick_str( + ws, + "audioInputLatencyAlertMs", + "audio_in_latency_alert_ms", + ), + "audio_in_latency_log_interval_s": _pick_str( + ws, + "audioInputLatencyLogIntervalS", + "audio_in_latency_log_interval_s", + ), + "livekit_audio_source_queue_size_ms": _pick_str( + ws, + "livekitAudioSourceQueueSizeMs", + "livekit_audio_source_queue_size_ms", + ), + "livekit_audio_source_clear_on_shed": _pick_str( + ws, + "livekitAudioSourceClearOnShed", + "livekit_audio_source_clear_on_shed", + ), + "audio_in_backlog_energy_shed_enabled": _pick_str( + ws, + "audioInputBacklogEnergyShedEnabled", + "audio_in_backlog_energy_shed_enabled", + ), + "audio_in_backlog_energy_shed_max_excess_ms": _pick_str( + ws, + "audioInputBacklogEnergyShedMaxExcessMs", + "audio_in_backlog_energy_shed_max_excess_ms", + ), + "audio_in_backlog_silence_dbfs": _pick_str( + ws, + "audioInputBacklogSilenceDbfs", + "audio_in_backlog_silence_dbfs", + ), + }, + "agent_fake": { + "delay_ms": _pick_str(agent_fake, "delayMs", "delay_ms"), + "responses": _pick_str(agent_fake, "responses"), + }, + } + + +def resolve_agent_backend_name(call_config: Mapping[str, Any] | None, default_backend: str) -> str: + normalized = normalize_call_config(call_config) + return normalized["agent_backend"] or _as_str(default_backend) or "remote_ws" + + +def resolve_stt_overrides(call_config: Mapping[str, Any] | None) -> Dict[str, str]: + normalized = normalize_call_config(call_config) + return dict(normalized["stt"]) + + +def resolve_tts_overrides(call_config: Mapping[str, Any] | None) -> Dict[str, str]: + normalized = normalize_call_config(call_config) + return dict(normalized["tts"]) + + +def resolve_vad_overrides(call_config: Mapping[str, Any] | None) -> Dict[str, str]: + normalized = normalize_call_config(call_config) + return dict(normalized["vad"]) + + +def resolve_vad_logging_overrides(call_config: Mapping[str, Any] | None) -> Dict[str, str]: + normalized = normalize_call_config(call_config) + return dict(normalized["vad_logging"]) + + +def resolve_ws_overrides(call_config: Mapping[str, Any] | None) -> Dict[str, str]: + normalized = normalize_call_config(call_config) + return dict(normalized["ws"]) + + +def _parse_fake_agent_responses(value: Any) -> list[str]: + raw = _as_str(value) + if not raw: + return [] + + responses = [item.strip() for item in raw.split(";")] + if any(not item for item in responses): + raise ValueError("agentFake.responses nao pode conter itens vazios") + if not FAKE_AGENT_MIN_RESPONSES <= len(responses) <= FAKE_AGENT_MAX_RESPONSES: + raise ValueError( + "agentFake.responses deve conter entre " + f"{FAKE_AGENT_MIN_RESPONSES} e {FAKE_AGENT_MAX_RESPONSES} frases" + ) + for index, response in enumerate(responses, start=1): + if not FAKE_AGENT_MIN_RESPONSE_CHARS <= len(response) <= FAKE_AGENT_MAX_RESPONSE_CHARS: + raise ValueError( + f"agentFake.responses[{index}] deve conter entre " + f"{FAKE_AGENT_MIN_RESPONSE_CHARS} e {FAKE_AGENT_MAX_RESPONSE_CHARS} caracteres" + ) + return responses + + +def resolve_fake_agent_overrides(call_config: Mapping[str, Any] | None) -> Dict[str, Any]: + normalized = normalize_call_config(call_config) + agent_fake = dict(normalized["agent_fake"]) + responses = _parse_fake_agent_responses(agent_fake.get("responses")) + if responses and normalized["agent_backend"].lower() != "remote_ws_fake": + raise ValueError( + "agentFake.responses exige callConfig.agentBackend=remote_ws_fake" + ) + + delay_raw = agent_fake.get("delay_ms") + if delay_raw in (None, ""): + delay_ms = FAKE_AGENT_DEFAULT_DELAY_MS if responses else None + else: + try: + delay_ms = int(str(delay_raw).strip()) + except (TypeError, ValueError) as exc: + raise ValueError("agentFake.delayMs deve ser um inteiro") from exc + if not 0 <= delay_ms <= FAKE_AGENT_MAX_DELAY_MS: + raise ValueError( + f"agentFake.delayMs deve estar entre 0 e {FAKE_AGENT_MAX_DELAY_MS}" + ) + + return {"delay_ms": delay_ms, "responses": responses} + + +def is_fake_agent_stress_test(call_config: Mapping[str, Any] | None) -> bool: + """Identify calls that explicitly opt into the scripted fake agent.""" + overrides = resolve_fake_agent_overrides(call_config) + return bool(overrides.get("responses")) diff --git a/src/app/common/stage.py b/src/app/common/stage.py new file mode 100644 index 0000000..0a3cfe5 --- /dev/null +++ b/src/app/common/stage.py @@ -0,0 +1,18 @@ +from enum import Enum + +class Stages(Enum): + presentation = "Faz perguntas do usuário para saber se está falando com o cliente alvo, pode receber respostas do tipo: sim, não, sou eu, espere um pouco etc." + argumentation = "Conversa com o usuário tentando vender um plano de telefone, possui algumas tentativas de vendas, pode receber respostas do tipo: tipo: sim, não, aceito comprar, não quero, não tenho interesse etc." + data_confirmation = "Confirma os dados do cliente, pode receber nomes, datas e números." + formalization = "Faz a confirmação final da venda, vocalizando um pedindo para o cliente confirmar, pode receber respostas do tipo: sim, não, aceito comprar, não quero, não tenho interesse etc." + + @classmethod + def get_value(cls, key: str = "") -> str: + """ + Retorna o value (descrição) direto a partir do nome da stage. + Ex: Stages.get_value("formalization") + """ + try: + return cls[key].value + except KeyError: + return "" diff --git a/src/app/common/timed.py b/src/app/common/timed.py new file mode 100644 index 0000000..b10e174 --- /dev/null +++ b/src/app/common/timed.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import functools +import inspect +import logging +import time +from collections.abc import Callable + + +def timed( + name: str | None = None, + *, + log_fn: Callable[..., None] | None = None, + logger: logging.Logger | None = None, + unit: str = "s", # "ms" | "s" | "us" + warn_after_s: float = 5.0, +): + """ + Mede o tempo de execução e registra via: + - log_fn(msg), se fornecido + - senão logger.info(msg), usando por padrão o logger "agent_internal_stt" + + Se dt > warn_after_s, emite WARNING e adiciona "(FUNÇÃO LENTA)". + """ + def deco(fn): + label = name or fn.__qualname__ + base_logger = logger or logging.getLogger("agent_internal_stt") + + def fmt(dt: float) -> str: + if unit == "us": + return f"{dt * 1_000_000:.0f} µs" + if unit == "s": + return f"{dt:.6f} s" + return f"{dt * 1000:.2f} ms" + + def emit_timing(dt: float) -> None: + slow = dt > warn_after_s + msg = f"[timed] {label} levou {fmt(dt)}" + (" (FUNÇÃO LENTA)" if slow else "") + + # Se não passou log_fn, usamos o logger e aí dá pra logar warning de verdade + if log_fn is None: + if slow: + base_logger.warning(msg) + else: + base_logger.info(msg) + return + + # Se passou log_fn, tentamos suportar nível sem quebrar compatibilidade + try: + # se o log_fn aceitar algo como log_fn(msg, level="WARNING") + if slow: + log_fn(msg, level="WARNING") + else: + log_fn(msg, level="INFO") + except TypeError: + # fallback: mantém assinatura antiga log_fn(msg) + if slow: + log_fn(f"[WARN] {msg}") + else: + log_fn(msg) + + if inspect.iscoroutinefunction(fn): + @functools.wraps(fn) + async def aw(*args, **kwargs): + t0 = time.perf_counter() + try: + return await fn(*args, **kwargs) + finally: + emit_timing(time.perf_counter() - t0) + return aw + + @functools.wraps(fn) + def w(*args, **kwargs): + t0 = time.perf_counter() + try: + return fn(*args, **kwargs) + finally: + emit_timing(time.perf_counter() - t0) + return w + + return deco \ No newline at end of file diff --git a/src/app/livekit/adapters/agent_backend.py b/src/app/livekit/adapters/agent_backend.py new file mode 100644 index 0000000..87b8605 --- /dev/null +++ b/src/app/livekit/adapters/agent_backend.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Protocol, runtime_checkable + + +@dataclass(frozen=True, slots=True) +class BackendReply: + stage: str + text: str = "" + done: bool = False + export_payload: Any = None + metadata: Any = None + + +@runtime_checkable +class AgentBackend(Protocol): + async def prepare( + self, + elegibility: bool, + protocol: str, + ) -> None: ... + + async def run(self, user_input: Any) -> BackendReply: ... + + async def set_interruption( + self, + interrupted: bool, + listened_text: str = "", + skipped: bool = False, + speech_id: str = "", + ) -> None: ... + + async def set_processing_interruption( + self, + listened_text: str = "", + skipped: bool = False, + speech_id: str = "", + ) -> None: ... + + async def inject_idle_nudge(self, nudge_text: str) -> None: ... + + async def end_service_once(self) -> BackendReply: ... diff --git a/src/app/livekit/adapters/audio_gain.py b/src/app/livekit/adapters/audio_gain.py new file mode 100644 index 0000000..4198e74 --- /dev/null +++ b/src/app/livekit/adapters/audio_gain.py @@ -0,0 +1,104 @@ +"""Correção de nível (loudness) na saída do TTS, no worker. + +O TTS costuma sair num nível baixo demais para telefonia. Em vez de multiplicar +o áudio às cegas no fim do pipeline (o que satura/clipa nos picos), aqui aplica-se +um makeup gain com um limiter soft-knee (tanh) já na saída do TTS — antes de +publicar no room. Para níveis normais de fala o ganho é praticamente linear; nos +picos ele comprime suavemente em direção ao teto, sem hard clipping. + + y = ceiling * tanh(gain * x / ceiling) + +Config por ambiente: + TTS_OUTPUT_GAIN makeup linear (1.0 = desligado; default 1.0) + TTS_OUTPUT_CEILING_DBFS teto do limiter em dBFS (default -1.0) +""" + +from __future__ import annotations + +import math +import os +from typing import Any, Optional + +import numpy as np + + +def _safe_float(value: Optional[str], default: float) -> float: + if value is None or not str(value).strip(): + return default + try: + parsed = float(value) + except (TypeError, ValueError): + return default + + if not math.isfinite(parsed): + return default + + return parsed + + +def _dbfs_to_linear(dbfs: float) -> float: + return float(10.0 ** (dbfs / 20.0)) + + +class SoftClipGain: + """Makeup gain + limiter soft-knee (tanh) para PCM16 mono little-endian. + + ``gain`` é o ganho linear aplicado à fala em nível normal; ``ceiling`` é o + teto (0..1 do fundo de escala) que os picos nunca ultrapassam. + """ + + def __init__(self, gain: float, ceiling: float = 0.891) -> None: + try: + normalized_gain = float(gain) + except (TypeError, ValueError): + normalized_gain = 1.0 + self.gain = ( + min(10.0, max(0.0, normalized_gain)) + if math.isfinite(normalized_gain) + else 1.0 + ) + self.ceiling = float(min(1.0, max(0.05, ceiling))) + + @property + def enabled(self) -> bool: + return self.gain != 1.0 + + def process(self, pcm: bytes) -> bytes: + if not self.enabled or not pcm: + return pcm + + count = len(pcm) // 2 + if count == 0: + return pcm + + x = np.frombuffer(pcm, dtype=" SoftClipGain: + gain = min(10.0, max(0.0, _safe_float(os.getenv("TTS_OUTPUT_GAIN"), 1.0))) + ceiling_dbfs = min( + 0.0, max(-60.0, _safe_float(os.getenv("TTS_OUTPUT_CEILING_DBFS"), -1.0)) + ) + return SoftClipGain(gain=gain, ceiling=_dbfs_to_linear(ceiling_dbfs)) + + +class GainEmitter: + """Proxy sobre um ``tts.AudioEmitter`` que aplica ``SoftClipGain`` em cada + ``push()``. Todo o resto (initialize/flush/start_segment/end_segment/...) é + encaminhado sem alteração para o emitter real. + """ + + def __init__(self, inner: Any, gain: SoftClipGain) -> None: + self._inner = inner + self._gain = gain + + def push(self, data: bytes) -> None: + self._inner.push(self._gain.process(data)) + + def __getattr__(self, name: str) -> Any: + return getattr(self._inner, name) diff --git a/src/app/livekit/adapters/azure_rest_tts.py b/src/app/livekit/adapters/azure_rest_tts.py new file mode 100644 index 0000000..f6289e4 --- /dev/null +++ b/src/app/livekit/adapters/azure_rest_tts.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import asyncio +from typing import List +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +from xml.sax.saxutils import escape, quoteattr + +import httpx +from livekit.agents import ( + APIConnectionError, + APIStatusError, + APITimeoutError, + tts, + utils, +) +from livekit.agents.types import APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS + + +AZURE_OUTPUT_FORMATS = { + 8000: "raw-8khz-16bit-mono-pcm", + 16000: "raw-16khz-16bit-mono-pcm", + 22050: "raw-22050hz-16bit-mono-pcm", + 24000: "raw-24khz-16bit-mono-pcm", + 44100: "raw-44100hz-16bit-mono-pcm", + 48000: "raw-48khz-16bit-mono-pcm", +} + + +def _append_query_param(url: str, key: str, value: str) -> str: + if not value: + return url + + parsed = urlsplit(url) + query_pairs = parse_qsl(parsed.query, keep_blank_values=True) + if any(existing_key == key for existing_key, _ in query_pairs): + return url + + query_pairs.append((key, value)) + return urlunsplit(parsed._replace(query=urlencode(query_pairs))) + + +def _is_custom_domain_endpoint(url: str) -> bool: + parsed = urlsplit((url or "").strip()) + host = (parsed.hostname or "").strip().lower() + return host.endswith(".cognitiveservices.azure.com") + + +def _prepend_service_prefix(url: str, service: str) -> str: + parsed = urlsplit((url or "").strip()) + path = (parsed.path or "").lstrip("/") + service = (service or "").strip().strip("/") + if not service or path.startswith(f"{service}/"): + return url.rstrip("/") + return urlunsplit(parsed._replace(path=f"/{service}/{path}")).rstrip("/") + + +class AzureRESTTTS(tts.TTS): + def __init__( + self, + *, + voice: str, + language: str | None = None, + sample_rate: int = 16000, + speech_key: str | None = None, + speech_region: str | None = None, + speech_endpoint: str | None = None, + deployment_id: str | None = None, + speech_auth_token: str | None = None, + user_agent: str = "tia-azure-tts/1.0", + timeout_s: float = 30.0, + ) -> None: + super().__init__( + capabilities=tts.TTSCapabilities(streaming=False, aligned_transcript=False), + sample_rate=sample_rate, + num_channels=1, + ) + if sample_rate not in AZURE_OUTPUT_FORMATS: + raise ValueError( + f"Unsupported sample rate {sample_rate}. Supported: {sorted(AZURE_OUTPUT_FORMATS)}" + ) + if not (voice or "").strip(): + raise ValueError("voice is required") + if not ((speech_key or "").strip() or (speech_auth_token or "").strip()): + raise ValueError("speech_key or speech_auth_token is required") + if not ((speech_region or "").strip() or (speech_endpoint or "").strip()): + raise ValueError("speech_region or speech_endpoint is required") + + self._voice = voice.strip() + self._language = (language or "").strip() or None + self._speech_key = (speech_key or "").strip() or None + self._speech_region = (speech_region or "").strip() or None + self._speech_endpoint = (speech_endpoint or "").strip().rstrip("/") or None + self._deployment_id = (deployment_id or "").strip() or None + self._speech_auth_token = (speech_auth_token or "").strip() or None + self._user_agent = (user_agent or "tia-azure-tts/1.0").strip() + self._timeout_s = float(timeout_s) + + @property + def model(self) -> str: + return self._deployment_id or self._voice + + @property + def provider(self) -> str: + return "azure" + + def synthesize( + self, + text: str, + *, + conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS, + ) -> "ChunkedStream": + return ChunkedStream(tts=self, input_text=text, conn_options=conn_options) + + def _base_endpoint(self) -> str: + if self._speech_endpoint: + return self._speech_endpoint + assert self._speech_region + service = "voice" if self._deployment_id else "tts" + return f"https://{self._speech_region}.{service}.speech.microsoft.com/cognitiveservices/v1" + + def _request_endpoints(self) -> List[str]: + base = _append_query_param(self._base_endpoint(), "deploymentId", self._deployment_id or "") + endpoints = [base] + + parsed = urlsplit(base) + path = (parsed.path or "").rstrip("/") + if _is_custom_domain_endpoint(base) and path == "/cognitiveservices/v1": + service = "voice" if self._deployment_id else "tts" + endpoints.append( + _append_query_param( + _prepend_service_prefix(base, service), + "deploymentId", + self._deployment_id or "", + ) + ) + return endpoints + + def _build_ssml(self, text: str) -> bytes: + language = self._language or "pt-BR" + escaped_text = escape((text or "").strip()) + return ( + f"" + f"{escaped_text}" + f"" + ).encode("utf-8") + + def _headers(self) -> dict[str, str]: + headers = { + "Content-Type": "application/ssml+xml", + "X-Microsoft-OutputFormat": AZURE_OUTPUT_FORMATS[self.sample_rate], + "User-Agent": self._user_agent, + } + if self._speech_auth_token: + headers["Authorization"] = f"Bearer {self._speech_auth_token}" + elif self._speech_key: + headers["Ocp-Apim-Subscription-Key"] = self._speech_key + return headers + + def synthesize_pcm(self, text: str) -> bytes: + text = (text or "").strip() + if not text: + return b"" + + headers = self._headers() + body = self._build_ssml(text) + last_status_error: httpx.HTTPStatusError | None = None + endpoints = self._request_endpoints() + + with httpx.Client(timeout=self._timeout_s, follow_redirects=True) as client: + for index, endpoint in enumerate(endpoints): + try: + response = client.post(endpoint, headers=headers, content=body) + response.raise_for_status() + if not response.content: + raise RuntimeError("Azure TTS returned empty audio.") + self._speech_endpoint = endpoint + return response.content + except httpx.HTTPStatusError as exc: + should_try_next = ( + exc.response is not None + and exc.response.status_code == 404 + and index < len(endpoints) - 1 + ) + if should_try_next: + last_status_error = exc + continue + raise + + if last_status_error is not None: + raise last_status_error + raise RuntimeError("Azure TTS returned no response.") + + async def aclose(self) -> None: + return None + + +class ChunkedStream(tts.ChunkedStream): + def __init__( + self, + *, + tts: AzureRESTTTS, + input_text: str, + conn_options: APIConnectOptions, + ) -> None: + super().__init__(tts=tts, input_text=input_text, conn_options=conn_options) + self._tts: AzureRESTTTS = tts + + async def _run(self, output_emitter: tts.AudioEmitter) -> None: + output_emitter.initialize( + request_id=utils.shortuuid(), + sample_rate=self._tts.sample_rate, + num_channels=self._tts.num_channels, + mime_type="audio/pcm", + ) + + try: + audio = await asyncio.to_thread(self._tts.synthesize_pcm, self._input_text) + except httpx.TimeoutException as exc: + raise APITimeoutError() from exc + except httpx.HTTPStatusError as exc: + status_code = exc.response.status_code if exc.response is not None else -1 + request_id = exc.response.headers.get("X-RequestId") if exc.response is not None else None + body = exc.response.text if exc.response is not None else None + message = "Azure TTS request failed." + if body: + message = f"{message} {body}" + raise APIStatusError( + message=message, + status_code=status_code, + request_id=request_id, + body=body, + ) from exc + except httpx.RequestError as exc: + raise APIConnectionError(f"Could not connect to Azure TTS: {exc}") from exc + except RuntimeError as exc: + raise APIConnectionError(str(exc), retryable=False) from exc + + output_emitter.push(audio) + output_emitter.flush() diff --git a/src/app/livekit/adapters/backend_factory.py b/src/app/livekit/adapters/backend_factory.py new file mode 100644 index 0000000..27a3c9d --- /dev/null +++ b/src/app/livekit/adapters/backend_factory.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import os +from typing import Any, Dict + +from app.livekit.adapters.agent_backend import AgentBackend + + +def _env_first(*names: str) -> str: + for name in names: + value = (os.getenv(name, "") or "").strip() + if value: + return value + return "" + + +def _normalize_agent_name(value: Any) -> str: + raw = str(value or "").strip().lower() + aliases = { + "conta": "conta", + "contas": "conta", + "ofert": "oferta", + "oferta": "oferta", + "ofertas": "oferta", + "cobra": "cobranca", + "cobranca": "cobranca", + "cobrança": "cobranca", + "cobrancas": "cobranca", + "cobranças": "cobranca", + } + return aliases.get(raw, raw) + + +def _current_agent_name(remote_agent_context: Dict[str, Any] | None) -> str: + context = remote_agent_context or {} + return _normalize_agent_name( + context.get("agent") + or context.get("Agent") + or context.get("agente") + or "" + ) + + +def build_agent_backend( + *, + intro: str, + backend_name: str | None = None, + remote_agent_context: Dict[str, Any] | None = None, + timeline: Any | None = None, + streaming: bool = False, +) -> AgentBackend: + requested_backend = (backend_name or os.getenv("AGENT_BACKEND", "remote_ws") or "remote_ws").strip().lower() + backend = requested_backend + use_fake_remote = backend in {"remote_ws_fake", "fake_remote_ws", "ws_fake"} + + if use_fake_remote: + from app.livekit.adapters.fake_remote_ws_adapter import FakeRemoteWSAdapter + + fake_delay_ms = (remote_agent_context or {}).get("_fake_agent_delay_ms") + fake_responses = (remote_agent_context or {}).get("_fake_agent_responses") + return FakeRemoteWSAdapter( + intro=intro, + backend_label="remote_ws_fake", + request_context=remote_agent_context or {}, + timeline=timeline, + default_stage=(os.getenv("REMOTE_AGENT_WS_DEFAULT_STAGE", "PRESENTATION") or "PRESENTATION").strip(), + delay_ms=fake_delay_ms, + responses=fake_responses, + ) + + if backend == "langgraph": + raise RuntimeError("AGENT_BACKEND=langgraph is no longer supported in the websocket runtime") + + if backend in {"remote_ws", "ws", "websocket"}: + from app.livekit.adapters.remote_agent_ws_adapter import RemoteAgentWSAdapter + + url = _env_first("REMOTE_AGENT_WS_URL") + if not url: + raise RuntimeError("REMOTE_AGENT_WS_URL must be set when AGENT_BACKEND=remote_ws") + + return RemoteAgentWSAdapter( + intro=intro, + url=url, + backend_label="remote_ws_fake" if use_fake_remote else requested_backend, + url_by_agent={}, + request_context=remote_agent_context or {}, + timeline=timeline, + streaming=streaming, + default_stage=(os.getenv("REMOTE_AGENT_WS_DEFAULT_STAGE", "PRESENTATION") or "PRESENTATION").strip(), + open_timeout_s=float(os.getenv("REMOTE_AGENT_WS_OPEN_TIMEOUT_S", "10")), + read_timeout_s=float(os.getenv("REMOTE_AGENT_WS_READ_TIMEOUT_S", "45")), + write_timeout_s=float(os.getenv("REMOTE_AGENT_WS_WRITE_TIMEOUT_S", "10")), + close_timeout_s=float(os.getenv("REMOTE_AGENT_WS_CLOSE_TIMEOUT_S", "10")), + max_message_bytes=int(os.getenv("REMOTE_AGENT_WS_MAX_MESSAGE_BYTES", str(1024 * 1024))), + ) + + if backend in {"remote_sse", "sse"}: + from app.livekit.adapters.remote_agent_sse_adapter import RemoteAgentSSEAdapter + + url_by_agent = { + "conta": _env_first("REMOTE_AGENT_SSE_URL_CONTA", "REMOTE_AGENT_SSE_URL_CONTAS"), + "oferta": _env_first("REMOTE_AGENT_SSE_URL_OFERTA", "REMOTE_AGENT_SSE_URL_OFERTAS"), + "cobranca": _env_first( + "REMOTE_AGENT_SSE_URL_COBRANCA", + "REMOTE_AGENT_SSE_URL_COBRANCAS", + "REMOTE_AGENT_SSE_URL_COBRA", + ), + } + current_agent = _current_agent_name(remote_agent_context) + url = url_by_agent.get(current_agent, "") + if not url: + expected_env = { + "conta": "REMOTE_AGENT_SSE_URL_CONTA", + "oferta": "REMOTE_AGENT_SSE_URL_OFERTA", + "cobranca": "REMOTE_AGENT_SSE_URL_COBRANCA", + }.get(current_agent, "REMOTE_AGENT_SSE_URL_") + raise RuntimeError( + f"{expected_env} must be set when AGENT_BACKEND=remote_sse" + ) + + return RemoteAgentSSEAdapter( + intro=intro, + url=url, + backend_label=requested_backend, + url_by_agent=url_by_agent, + request_context=remote_agent_context or {}, + timeline=timeline, + streaming=streaming, + default_stage=(os.getenv("REMOTE_AGENT_SSE_DEFAULT_STAGE", "PRESENTATION") or "PRESENTATION").strip(), + connect_timeout_s=float(os.getenv("REMOTE_AGENT_SSE_CONNECT_TIMEOUT_S", "10")), + read_timeout_s=float(os.getenv("REMOTE_AGENT_SSE_READ_TIMEOUT_S", "45")), + write_timeout_s=float(os.getenv("REMOTE_AGENT_SSE_WRITE_TIMEOUT_S", "10")), + ) + + raise RuntimeError(f"Unsupported AGENT_BACKEND={backend!r}") diff --git a/src/app/livekit/adapters/bridge_gateway.py b/src/app/livekit/adapters/bridge_gateway.py new file mode 100644 index 0000000..c8a6051 --- /dev/null +++ b/src/app/livekit/adapters/bridge_gateway.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import json +import time +from typing import Any + + +class BridgeGateway: + def __init__( + self, + *, + room: Any, + bridge_identity: str, + protocol: str, + timeline: Any | None = None, + stress_test: bool = False, + ) -> None: + self._room = room + self._bridge_identity = bridge_identity + self._protocol = protocol + self._timeline = timeline + self._stress_test = bool(stress_test) + + async def publish_debug_event(self, event: str, **data: Any) -> None: + """Publish bounded, structured diagnostics to the originating Bridge.""" + if not self._bridge_identity or not self._stress_test: + return + payload = { + "type": "debug_event", + "version": 1, + "source": "agent", + "stress_test": True, + "event": str(event or "unknown").strip(), + "timestamp_ms": round(time.time() * 1000), + "protocol": self._protocol, + "room": self._room.name, + "data": data, + } + await self._room.local_participant.publish_data( + json.dumps(payload, ensure_ascii=False), + reliable=True, + destination_identities=[self._bridge_identity], + topic="agent.debug", + ) + + async def notify_stage_done(self, reason: str = "stage_done") -> None: + if not self._bridge_identity: + return + + payload = { + "type": "stage", + "stage": "DONE", + "reason": reason, + "room": self._room.name, + "protocol": self._protocol, + } + await self._room.local_participant.publish_data( + json.dumps(payload, ensure_ascii=False), + reliable=True, + destination_identities=[self._bridge_identity], + topic="agent.stage", + ) + if self._timeline is not None: + self._timeline.emit( + "bridge_done_notified", + reason=reason, + destination=self._bridge_identity, + ) + + async def notify_stop( + self, + *, + status: str, + reason: str, + resource: str = "", + failed_resources: tuple[str, ...] = (), + phase: str = "in_session", + ) -> None: + if not self._bridge_identity: + return + + payload = { + "type": "stage", + "stage": "DONE", + "status": str(status or "").strip(), + "reason": str(reason or "").strip(), + "room": self._room.name, + "protocol": self._protocol, + } + if resource: + payload["resource"] = str(resource).strip() + normalized_failed_resources = [str(item).strip() for item in failed_resources if str(item).strip()] + if normalized_failed_resources: + payload["failed_resources"] = normalized_failed_resources + if phase: + payload["phase"] = str(phase).strip() + + await self._room.local_participant.publish_data( + json.dumps(payload, ensure_ascii=False), + reliable=True, + destination_identities=[self._bridge_identity], + topic="agent.stage", + ) + if self._timeline is not None: + self._timeline.emit( + "bridge_stop_notified", + status=payload["status"], + reason=payload["reason"], + resource=payload.get("resource", ""), + phase=payload.get("phase", ""), + destination=self._bridge_identity, + ) diff --git a/src/app/livekit/adapters/export_service.py b/src/app/livekit/adapters/export_service.py new file mode 100644 index 0000000..56c855a --- /dev/null +++ b/src/app/livekit/adapters/export_service.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from app.utils.export import json_to_csv + + +class ExportService: + async def export_session(self, output: Any, session_id: str) -> None: + await asyncio.to_thread(json_to_csv, output, session_id) diff --git a/src/app/livekit/adapters/fake_remote_ws_adapter.py b/src/app/livekit/adapters/fake_remote_ws_adapter.py new file mode 100644 index 0000000..0c350ce --- /dev/null +++ b/src/app/livekit/adapters/fake_remote_ws_adapter.py @@ -0,0 +1,428 @@ +from __future__ import annotations + +import asyncio +import os +from typing import Any, Dict, List, Mapping, Optional, Sequence + +from app.livekit.adapters.agent_backend import BackendReply +from app.ws_gateway.fake_remote_agent import build_fake_remote_agent_response + + +class FakeRemoteWSAdapter: + def __init__( + self, + *, + intro: str, + backend_label: str = "remote_ws_fake", + request_context: Optional[Dict[str, Any]] = None, + timeline: Any | None = None, + default_stage: str = "PRESENTATION", + delay_ms: int | None = None, + responses: Sequence[str] | None = None, + ) -> None: + self._intro = intro + self._backend_label = str(backend_label or "remote_ws_fake").strip().lower() + self._request_context = dict(request_context or {}) + self._timeline = timeline + self._default_stage = (default_stage or "PRESENTATION").strip().upper() + configured_delay = ( + os.getenv("FAKE_AGENT_DELAY_MS", "0") if delay_ms is None else delay_ms + ) + try: + self._delay_s = max(0.0, min(180.0, float(configured_delay) / 1000.0)) + except (TypeError, ValueError): + self._delay_s = 0.0 + self._responses = tuple(str(item).strip() for item in (responses or ())) + self._response_index = 0 + self._scripted_final_reply: Optional[BackendReply] = None + + self._elegibility = True + self._protocol = "" + self._last_stage = "INTRO" + self._pending_interrupt: Optional[Dict[str, Any]] = None + self._pending_events: List[Dict[str, Any]] = [] + + self._end_lock = asyncio.Lock() + self._ended = False + self._end_reply: Optional[BackendReply] = None + + @staticmethod + def _normalize_agent_name(value: Any) -> str: + raw = str(value or "").strip().lower() + aliases = { + "conta": "conta", + "contas": "conta", + "ofert": "oferta", + "oferta": "oferta", + "ofertas": "oferta", + "cobra": "cobranca", + "cobranca": "cobranca", + "cobrança": "cobranca", + "cobrancas": "cobranca", + "cobranças": "cobranca", + } + return aliases.get(raw, raw) + + def _current_agent_name(self) -> str: + return self._normalize_agent_name( + self._request_context.get("agent") + or self._request_context.get("Agent") + or self._request_context.get("agente") + or "" + ) + + def _request_field(self, *keys: str) -> str: + for key in keys: + value = self._request_context.get(key) + if value not in (None, ""): + return str(value).strip() + + lowered = {str(key).lower(): value for key, value in self._request_context.items()} + for key in keys: + value = lowered.get(str(key).lower()) + if value not in (None, ""): + return str(value).strip() + + return "" + + def _current_invoice_number(self) -> str: + return self._request_field( + "current_invoice_number", + "currentInvoiceNumber", + "ID_FATURA", + "idFatura", + "id_fatura", + "IdFatura", + ) + + def _current_msisdn(self) -> str: + return self._request_field("msisdn", "GSM", "gsm", "NUM_TELEFONE") + + def _current_channel(self) -> str: + return self._request_field("channel", "Channel", "canal") or "SUPERVISOR" + + @staticmethod + def _extract_user_text(user_input: Any) -> str: + if isinstance(user_input, str): + return user_input.strip() + if isinstance(user_input, Mapping): + for key in ("text", "transcript", "utterance", "message", "content"): + value = user_input.get(key) + if value: + return str(value).strip() + return "" + return str(user_input or "").strip() + + def _base_payload(self) -> Dict[str, Any]: + payload = { + "agent": self._current_agent_name(), + "RouterCallKeyDay": str(self._request_context.get("RouterCallKeyDay") or "").strip(), + "RouterCallKey": str(self._request_context.get("RouterCallKey") or "").strip(), + "ANI": str(self._request_context.get("ANI") or "").strip(), + "GSM": str(self._request_context.get("GSM") or "").strip(), + "callIdGed": str(self._request_context.get("callIdGed") or "").strip(), + "protocol": self._protocol, + "stage": self._last_stage or self._default_stage, + } + id_fatura = str( + self._request_context.get("ID_FATURA") + or self._request_context.get("id_fatura") + or self._request_context.get("IdFatura") + or "" + ).strip() + if id_fatura: + payload["ID_FATURA"] = id_fatura + return payload + + def _build_turn_payload(self, user_input: Any) -> Dict[str, Any]: + text = self._extract_user_text(user_input) + payload = self._base_payload() + if self._current_agent_name() == "conta": + payload = { + "message": text, + "channel": self._current_channel(), + "msisdn": self._current_msisdn(), + } + current_invoice_number = self._current_invoice_number() + if current_invoice_number: + payload["current_invoice_number"] = current_invoice_number + if self._pending_interrupt is not None: + self._add_pending_speech_interruption(payload) + if self._pending_events: + payload["events"] = list(self._pending_events) + return { + "action": "chat", + "payload": payload, + "_agent": self._current_agent_name(), + "_stage": self._last_stage or self._default_stage, + } + + payload["text"] = text + if self._pending_interrupt is not None: + self._add_pending_speech_interruption(payload) + if self._pending_events: + payload["events"] = list(self._pending_events) + return payload + + def _build_end_payload(self) -> Dict[str, Any]: + payload = self._base_payload() + if self._current_agent_name() == "conta": + payload["msisdn"] = self._current_msisdn() + payload["channel"] = self._current_channel() + current_invoice_number = self._current_invoice_number() + if current_invoice_number: + payload["current_invoice_number"] = current_invoice_number + if self._pending_interrupt is not None: + self._add_pending_speech_interruption(payload) + if self._pending_events: + payload["events"] = list(self._pending_events) + return { + "action": "end", + "payload": payload, + "_agent": self._current_agent_name(), + "_stage": self._last_stage or self._default_stage, + } + + payload["type"] = "end" + if self._pending_interrupt is not None: + self._add_pending_speech_interruption(payload) + if self._pending_events: + payload["events"] = list(self._pending_events) + return payload + + def _add_pending_speech_interruption(self, payload: Dict[str, Any]) -> None: + if self._pending_interrupt is None: + return + interruption = dict(self._pending_interrupt) + interruption_key = str( + interruption.pop("_interruption_field", "speech_interruption") + ) + payload[interruption_key] = interruption + + @staticmethod + def _reply_text(response: Mapping[str, Any]) -> str: + result = response.get("result") + if isinstance(result, Mapping): + content = result.get("content") + if content: + return str(content).strip() + text = response.get("text") + if text: + return str(text).strip() + return "" + + @staticmethod + def _export_payload(response: Mapping[str, Any]) -> Any: + return response.get("result") + + def _to_backend_reply(self, response: Mapping[str, Any]) -> BackendReply: + stage = str(response.get("stage") or self._last_stage or self._default_stage).strip().upper() + done = stage == "DONE" or str(response.get("type") or "").strip().lower() == "done" + return BackendReply( + stage=stage, + text=self._reply_text(response), + done=done, + export_payload=self._export_payload(response), + ) + + def _clear_pending_state(self) -> None: + self._pending_interrupt = None + self._pending_events.clear() + + async def prepare( + self, + elegibility: bool, + protocol: str, + ) -> None: + if self._timeline is not None: + self._timeline.emit( + "backend_prepare_started", + backend=self._backend_label, + backend_family="remote_ws_fake", + agent=self._current_agent_name(), + ) + self._elegibility = bool(elegibility) + self._protocol = str(protocol or "") + self._last_stage = "INTRO" + self._pending_interrupt = None + self._pending_events.clear() + self._ended = False + self._end_reply = None + self._response_index = 0 + self._scripted_final_reply = None + if self._timeline is not None: + self._timeline.emit( + "backend_prepare_completed", + backend=self._backend_label, + backend_family="remote_ws_fake", + agent=self._current_agent_name(), + protocol=self._protocol, + ) + + async def run(self, user_input: Any) -> BackendReply: + if self._scripted_final_reply is not None: + return self._scripted_final_reply + + payload = self._build_turn_payload(user_input) + if self._timeline is not None: + self._timeline.emit( + "remote_agent_request", + backend=self._backend_label, + backend_family="remote_ws_fake", + agent=self._current_agent_name(), + action=str(payload.get("action") or "chat"), + payload=payload, + ) + + if self._delay_s: + await asyncio.sleep(self._delay_s) + if self._responses: + index = self._response_index + text = self._responses[index] + if index == len(self._responses) - 1: + stage = "DONE" + elif index == len(self._responses) - 2: + stage = "FORMALIZATION" + else: + stage = "ARGUMENTATION" + done = stage == "DONE" + reply = BackendReply( + stage=stage, + text=text, + done=done, + export_payload=( + { + "type": "final", + "content": text, + "tool_calls": [], + "result": [{"status": "ok", "reason": "fake_done"}], + } + if done + else None + ), + ) + self._response_index += 1 + if done: + self._scripted_final_reply = reply + else: + response = build_fake_remote_agent_response(payload) + reply = self._to_backend_reply(response) + self._last_stage = reply.stage or self._last_stage or self._default_stage + self._clear_pending_state() + + if self._timeline is not None: + self._timeline.emit( + "remote_agent_response", + backend=self._backend_label, + backend_family="remote_ws_fake", + agent=self._current_agent_name(), + stage=reply.stage, + done=reply.done, + text_len=len(reply.text), + has_result=reply.export_payload is not None, + ) + return reply + + async def set_interruption( + self, + interrupted: bool, + listened_text: str = "", + skipped: bool = False, + speech_id: str = "", + ) -> None: + if not interrupted: + self._pending_interrupt = None + else: + self._pending_interrupt = { + "speech_id": str(speech_id or "").strip(), + "heard_text": listened_text or "", + } + if self._timeline is not None: + self._timeline.emit( + "backend_set_interruption", + backend=self._backend_label, + backend_family="remote_ws_fake", + agent=self._current_agent_name(), + interrupted=bool(interrupted), + skipped=bool(skipped), + listened_text=listened_text, + speech_id=str(speech_id or "").strip(), + ) + + async def set_processing_interruption( + self, + listened_text: str = "", + skipped: bool = False, + speech_id: str = "", + ) -> None: + await self.set_interruption( + True, + listened_text=listened_text, + skipped=skipped, + speech_id=speech_id, + ) + if self._pending_interrupt is not None: + self._pending_interrupt["_interruption_field"] = "processing_interruption" + + async def inject_idle_nudge(self, nudge_text: str) -> None: + text = str(nudge_text or "").strip() + if not text: + return + event = {"type": "idle_nudge", "text": text} + # Cada frase de inatividade substitui a anterior: o cliente responde ao + # que ouviu por ultimo, e as intermediarias so empilham falas do agente + # no historico remoto -- inclusive o aviso de encerramento, que passa a + # constar como dito logo antes de a conversa seguir normalmente. + if self._pending_events and self._pending_events[-1].get("type") == "idle_nudge": + self._pending_events[-1] = event + else: + self._pending_events.append(event) + if self._timeline is not None: + self._timeline.emit( + "backend_idle_nudge_buffered", + backend=self._backend_label, + backend_family="remote_ws_fake", + agent=self._current_agent_name(), + text=text, + ) + + def supports_server_push(self) -> bool: + return False + + async def wait_for_server_push(self) -> BackendReply | None: + return None + + async def end_service_once(self) -> BackendReply: + async with self._end_lock: + if self._ended: + return self._end_reply or BackendReply(stage="DONE", done=True, export_payload=[]) + + self._ended = True + if self._timeline is not None: + self._timeline.emit( + "backend_end_started", + backend=self._backend_label, + backend_family="remote_ws_fake", + agent=self._current_agent_name(), + ) + response = build_fake_remote_agent_response(self._build_end_payload()) + reply = self._to_backend_reply(response) + if not reply.done: + reply = BackendReply( + stage="DONE", + text=reply.text, + done=True, + export_payload=reply.export_payload, + ) + self._last_stage = reply.stage + self._clear_pending_state() + self._end_reply = reply + if self._timeline is not None: + self._timeline.emit( + "backend_end_completed", + backend=self._backend_label, + backend_family="remote_ws_fake", + agent=self._current_agent_name(), + result_type=type(reply.export_payload).__name__, + ) + return reply diff --git a/src/app/livekit/adapters/fake_tts.py b/src/app/livekit/adapters/fake_tts.py new file mode 100644 index 0000000..b4271eb --- /dev/null +++ b/src/app/livekit/adapters/fake_tts.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import asyncio + +from livekit.agents import tts, utils +from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS, APIConnectOptions + +from app.providers.tts import CHANNELS, SAMPLE_RATE +from app.providers.tts import FakeTTS as FakeProviderTTS + + +class FakeTTS(tts.TTS): + def __init__(self) -> None: + super().__init__( + capabilities=tts.TTSCapabilities(streaming=False, aligned_transcript=False), + sample_rate=SAMPLE_RATE, + num_channels=CHANNELS, + ) + self._provider_client = FakeProviderTTS() + + @property + def model(self) -> str: + return "fake-tone" + + @property + def provider(self) -> str: + return "fake" + + def synthesize( + self, + text: str, + *, + conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS, + ) -> ChunkedStream: + return ChunkedStream(tts=self, input_text=text, conn_options=conn_options) + + async def aclose(self) -> None: + return None + + +class ChunkedStream(tts.ChunkedStream): + def __init__( + self, + *, + tts: FakeTTS, + input_text: str, + conn_options: APIConnectOptions, + ) -> None: + super().__init__(tts=tts, input_text=input_text, conn_options=conn_options) + self._tts: FakeTTS = tts + + async def _run(self, output_emitter: tts.AudioEmitter) -> None: + output_emitter.initialize( + request_id=utils.shortuuid(), + sample_rate=self._tts.sample_rate, + num_channels=self._tts.num_channels, + mime_type="audio/pcm", + ) + + audio = await asyncio.to_thread( + self._tts._provider_client.synthesize_pcm16k, + self._input_text, + ) + output_emitter.push(audio) + output_emitter.flush() diff --git a/src/app/livekit/adapters/pipeline_adapter.py b/src/app/livekit/adapters/pipeline_adapter.py new file mode 100644 index 0000000..6788245 --- /dev/null +++ b/src/app/livekit/adapters/pipeline_adapter.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import asyncio +from typing import Any, Dict, Optional + +from agent.pipeline.customer_pipeline_langgraph import CustomerPipeline +from app.livekit.adapters.agent_backend import BackendReply +from app.utils.logging import setup_minimal_logging + +logger = setup_minimal_logging() + + +class PipelineAdapter: + def __init__( + self, + *, + session_data: Dict[str, Any], + intro: str, + timeline: Any | None = None, + streaming: bool = False, + ) -> None: + self._session_data = session_data + self._intro = intro + self._timeline = timeline + self._streaming = streaming + + self._pipeline: Optional[CustomerPipeline] = None + self._last_stage = "INTRO" + self._end_lock = asyncio.Lock() + self._ended = False + self._end_reply: Optional[BackendReply] = None + + def _require_pipeline(self) -> CustomerPipeline: + if self._pipeline is None: + raise RuntimeError("PipelineAdapter used before prepare() completed") + return self._pipeline + + def _call_set_interruption( + self, + pipeline: CustomerPipeline, + interrupted: bool, + listened_text: str = "", + skipped: bool = False, + speech_id: str = "", + ) -> None: + fn = getattr(pipeline, "set_interruption", None) + if not callable(fn): + return + + try: + fn(interrupted, listened_text, skipped, speech_id) + return + except TypeError: + pass + + try: + fn(interrupted, listened_text, skipped) + return + except TypeError: + pass + + try: + fn(interrupted, listened_text) + return + except TypeError: + pass + + fn(interrupted) + + async def prepare( + self, + elegibility: bool, + protocol: str, + ) -> None: + if self._timeline is not None: + self._timeline.emit( + "backend_prepare_started", + backend="langgraph", + elegibility=bool(elegibility), + ) + + def _build_pipeline() -> CustomerPipeline: + pipeline = CustomerPipeline(self._session_data, streaming=self._streaming) + pipeline.intro = self._intro + return pipeline + + pipeline = await asyncio.to_thread(_build_pipeline) + self._pipeline = pipeline + + def _prepare() -> None: + pipeline.prepare(elegibility, protocol) + + await asyncio.to_thread(_prepare) + if self._timeline is not None: + self._timeline.emit("backend_prepare_completed", backend="langgraph") + + async def run(self, user_input: Any) -> BackendReply: + pipeline = self._require_pipeline() + result = await asyncio.to_thread(pipeline.run, user_input) + if not isinstance(result, tuple) or len(result) != 2: + raise RuntimeError(f"Unexpected pipeline.run() result: {type(result)!r}") + + stage_raw, output_raw = result + stage = str(stage_raw or self._last_stage or "PRESENTATION").strip().upper() + text = str(output_raw or "").strip() + self._last_stage = stage + reply = BackendReply( + stage=stage, + text=text, + done=stage == "DONE", + export_payload=None, + ) + if self._timeline is not None: + self._timeline.emit( + "backend_run_completed", + backend="langgraph", + stage=reply.stage, + output_len=len(reply.text), + ) + return reply + + async def set_interruption( + self, + interrupted: bool, + listened_text: str = "", + skipped: bool = False, + speech_id: str = "", + ) -> None: + pipeline = self._require_pipeline() + if self._timeline is not None: + self._timeline.emit( + "backend_set_interruption", + backend="langgraph", + interrupted=bool(interrupted), + skipped=bool(skipped), + listened_text=listened_text, + speech_id=speech_id, + ) + self._call_set_interruption(pipeline, interrupted, listened_text, skipped, speech_id) + + async def set_processing_interruption( + self, + listened_text: str = "", + skipped: bool = False, + speech_id: str = "", + ) -> None: + await self.set_interruption( + True, + listened_text=listened_text, + skipped=skipped, + speech_id=speech_id, + ) + + async def inject_idle_nudge(self, nudge_text: str) -> None: + pipeline = self._require_pipeline() + + inject_user = getattr(getattr(pipeline, "agent", None), "inject_user_message", None) + inject_ai = getattr(getattr(pipeline, "agent", None), "inject_ai_message", None) + if callable(inject_user) and callable(inject_ai): + inject_user("") + inject_ai(nudge_text) + + self._call_set_interruption(pipeline, False, "", False, "") + + update_auto = getattr(pipeline, "update_langfuse_auto", None) + if callable(update_auto): + update_auto("###idle###", nudge_text) + + async def end_service_once(self) -> BackendReply: + async with self._end_lock: + if self._ended: + return self._end_reply or BackendReply(stage="DONE", done=True, export_payload=[]) + + self._ended = True + if self._timeline is not None: + self._timeline.emit("backend_end_started", backend="langgraph") + pipeline = self._pipeline + if pipeline is None: + self._end_reply = BackendReply(stage="DONE", done=True, export_payload=[]) + return self._end_reply + + end_output: Any + try: + end_output = await pipeline.end_service() + except Exception: + logger.exception("[pipeline] end_service() falhou") + end_output = [] + + self._end_reply = BackendReply( + stage="DONE", + text="", + done=True, + export_payload=end_output if end_output is not None else [], + ) + + if self._timeline is not None: + self._timeline.emit( + "backend_end_completed", + backend="langgraph", + result_type=type(self._end_reply.export_payload).__name__, + ) + return self._end_reply diff --git a/src/app/livekit/adapters/remote_agent_sse_adapter.py b/src/app/livekit/adapters/remote_agent_sse_adapter.py new file mode 100644 index 0000000..dd93c99 --- /dev/null +++ b/src/app/livekit/adapters/remote_agent_sse_adapter.py @@ -0,0 +1,1817 @@ +from __future__ import annotations + +import asyncio +import json +import os +import re +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Dict, List, Mapping, Optional + +import httpx + +from app.livekit.adapters.agent_backend import BackendReply +from app.livekit.policies.agent_finalization import final_stop_from_agent_result +from app.utils.logging import setup_minimal_logging +from app.utils.turn_ids import next_turn_message_id + +logger = setup_minimal_logging() + +_STREAM_TYPES = {"chunk", "delta", "partial", "token", "progress"} +_TERMINAL_TYPES = {"done", "final", "message", "output", "response", "result", "proactive_result"} +_PREFETCH_TERMINAL_TYPES = {"prefetch_done", "prefetch_skipped", "prefetch_failed"} +_TEXT_KEYS = ("text", "response", "output", "message", "content") +_USER_TEXT_KEYS = ("text", "transcript", "utterance", "message", "content") +_OFERTA_INITIAL_MESSAGE_ENV = "REMOTE_AGENT_OFERTA_INITIAL_MESSAGE" +_OFERTA_INITIAL_MESSAGE_DEFAULT = "inicio_atendimento" +_OFERTA_SERVICE_STATUS_RESULT_TYPE = { + "RESOLVED": "resolvido", +} +_OFERTA_NON_FINAL_SERVICE_STATUSES = {"UNRESOLVED", "RESOLVED_WITH_NEW_REQUEST"} +_FALSE_ENV_VALUES = {"0", "false", "no", "off", "disable", "disabled"} +_TRUE_ENV_VALUES = {"1", "true", "yes", "on", "enable", "enabled"} +_CONTA_USER_RESPONSE_MESSAGE_TYPES = {"ready", "final"} + + +@dataclass(slots=True) +class _RemoteAgentReply: + stage: str + text: str + done: bool + result: Any + event_type: str = "" + metadata: Any = None + + +class RemoteAgentSSEAdapter: + def __init__( + self, + *, + intro: str, + url: str, + backend_label: str = "remote_sse", + url_by_agent: Optional[Dict[str, str]] = None, + request_context: Optional[Dict[str, Any]] = None, + timeline: Any | None = None, + streaming: bool = False, + default_stage: str = "PRESENTATION", + connect_timeout_s: float = 10.0, + read_timeout_s: float = 45.0, + write_timeout_s: float = 10.0, + ) -> None: + self._intro = intro + self._url = url + self._backend_label = str(backend_label or "remote_sse").strip().lower() + self._url_by_agent = { + self._normalize_agent_name(key): value.strip() + for key, value in (url_by_agent or {}).items() + if value and value.strip() + } + self._request_context = dict(request_context or {}) + self._timeline = timeline + self._streaming = streaming + self._default_stage = (default_stage or "PRESENTATION").strip().upper() + self._connect_timeout_s = connect_timeout_s + self._read_timeout_s = read_timeout_s + self._write_timeout_s = write_timeout_s + + self._elegibility = True + self._remote_session_id = "" + self._last_stage = "INTRO" + self._pending_interrupt: Optional[Dict[str, Any]] = None + self._pending_events: List[Dict[str, Any]] = [] + + self._end_lock = asyncio.Lock() + self._ended = False + self._end_reply: Optional[BackendReply] = None + self._session_lock = asyncio.Lock() + self._pending_ready_reply: Optional[_RemoteAgentReply] = None + self._push_queue: asyncio.Queue[_RemoteAgentReply] = asyncio.Queue() + self._session_actions: set[str] | None = None + self._protocol = "" + self._turn_seq = 0 + self._offer_stream_task: asyncio.Task[None] | None = None + self._conta_feedback_streams = 0 + self._conta_feedback_event = asyncio.Event() + self._active_request_message_id = "" + + @staticmethod + def _normalize_agent_name(value: Any) -> str: + raw = str(value or "").strip().lower() + aliases = { + "conta": "conta", + "contas": "conta", + "ofert": "oferta", + "oferta": "oferta", + "ofertas": "oferta", + "cobra": "cobranca", + "cobranca": "cobranca", + "cobrança": "cobranca", + "cobrancas": "cobranca", + "cobranças": "cobranca", + } + return aliases.get(raw, raw) + + @staticmethod + def _string_or_empty(value: Any) -> str: + return str(value or "").strip() + + @classmethod + def _message_id_from_mapping(cls, payload: Mapping[str, Any] | None) -> str: + if not isinstance(payload, Mapping): + return "" + + for key in ("message_id", "messageId"): + value = payload.get(key) + if value not in (None, ""): + return cls._string_or_empty(value) + + for container_key in ("metadata", "data", "payload", "context"): + metadata = payload.get(container_key) + if not isinstance(metadata, Mapping): + continue + for key in ("message_id", "messageId"): + value = metadata.get(key) + if value not in (None, ""): + return cls._string_or_empty(value) + + return "" + + @classmethod + def _metadata_with_message_id(cls, metadata: Any, message_id: Any) -> Dict[str, Any] | None: + normalized_message_id = cls._string_or_empty(message_id) + if not normalized_message_id: + return dict(metadata) if isinstance(metadata, Mapping) else metadata + + merged: Dict[str, Any] = dict(metadata) if isinstance(metadata, Mapping) else {} + merged.setdefault("message_id", normalized_message_id) + return merged + + @classmethod + def _reply_with_message_id( + cls, + reply: _RemoteAgentReply, + message_id: Any, + ) -> _RemoteAgentReply: + metadata = cls._metadata_with_message_id(reply.metadata, message_id) + if metadata is not reply.metadata: + reply.metadata = metadata + return reply + + @staticmethod + def _decode_message(raw_message: Any) -> Any: + if isinstance(raw_message, bytes): + raw_message = raw_message.decode("utf-8") + if not isinstance(raw_message, str): + raise RuntimeError(f"Unsupported SSE payload type: {type(raw_message)!r}") + + message = raw_message.strip() + if not message: + return {} + + try: + return json.loads(message) + except json.JSONDecodeError: + return message + + def _extract_text_from_value(self, value: Any) -> str: + if isinstance(value, str): + return value.strip() + if isinstance(value, Mapping): + for key in ("result", *_TEXT_KEYS): + text = self._extract_text_from_value(value.get(key)) + if text: + return text + return "" + if isinstance(value, list): + for item in value: + text = self._extract_text_from_value(item) + if text: + return text + return "" + + def _extract_user_text(self, user_input: Any) -> str: + if isinstance(user_input, str): + return user_input.strip() + + if isinstance(user_input, Mapping): + for key in _USER_TEXT_KEYS: + text = self._extract_text_from_value(user_input.get(key)) + if text: + return text + + for value in user_input.values(): + text = self._extract_text_from_value(value) + if text: + return text + + if isinstance(user_input, list): + for item in user_input: + text = self._extract_user_text(item) + if text: + return text + + return str(user_input).strip() + + def _current_timestamp(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _request_field(self, *keys: str) -> str: + for key in keys: + value = self._request_context.get(key) + if value not in (None, ""): + return self._string_or_empty(value) + + lowered = {str(key).lower(): value for key, value in self._request_context.items()} + for key in keys: + value = lowered.get(str(key).lower()) + if value not in (None, ""): + return self._string_or_empty(value) + + return "" + + def _current_agent_name(self) -> str: + return self._normalize_agent_name( + self._request_field("agent", "Agent", "agente") + ) + + def _is_oferta_agent(self) -> bool: + return self._current_agent_name() == "oferta" + + def _resolve_url(self) -> str: + agent_name = self._current_agent_name() + if agent_name: + routed_url = self._url_by_agent.get(agent_name, "") + if routed_url: + return routed_url + return self._url + + def _current_invoice_number(self) -> str: + return self._request_field( + "invoice_id", + "invoiceId", + "current_invoice_number", + "currentInvoiceNumber", + "ID_FATURA", + "idFatura", + "id_fatura", + "IdFatura", + ) + + def _current_msisdn(self) -> str: + return self._request_field("msisdn", "GSM", "gsm", "NUM_TELEFONE") + + def _current_ani(self) -> str: + return self._request_field("ANI", "ani") + + def _current_session_id(self) -> str: + return self._request_field("session_id", "sessionId") + + def _current_channel_id(self) -> str: + channel_id = self._request_field("channelId", "channel_id") + if channel_id: + return channel_id + return "ura" + + def _current_ura_call_id(self) -> str: + return self._request_field("uraCallId", "ura_call_id", "callIdGed") + + def _current_protocol_number(self) -> str: + return self._request_field("protocol_id", "protocolId", "protocolo", "protocolNumber", "protocol") + + def _message_id_from_user_input(self, user_input: Any) -> str: + if not isinstance(user_input, Mapping): + return "" + + for key in ("message_id", "messageId"): + value = user_input.get(key) + if value not in (None, ""): + return self._string_or_empty(value) + + for container_key in ("metadata", "data", "payload"): + nested = user_input.get(container_key) + if not isinstance(nested, Mapping): + continue + for key in ("message_id", "messageId"): + value = nested.get(key) + if value not in (None, ""): + return self._string_or_empty(value) + + return "" + + def _query_message_id(self, user_input: Any = None) -> str: + return ( + self._message_id_from_user_input(user_input) + or self._request_field("message_id", "messageId") + or self._next_message_id() + ) + + def _current_channel(self) -> str: + channel = self._request_field("channel", "Channel", "canal") + if channel: + return channel + + configured = self._string_or_empty( + os.getenv("REMOTE_AGENT_CHANNEL") + or os.getenv("REMOTE_AGENT_DEFAULT_CHANNEL") + ) + return configured or "SUPERVISOR" + + def _stream_open_params(self) -> Dict[str, str]: + params: Dict[str, str] = {} + + msisdn = self._current_msisdn() + if msisdn: + params["msisdn"] = msisdn + + invoice_number = self._current_invoice_number() + if invoice_number: + params["invoice_id"] = invoice_number + + if self._current_agent_name() != "conta": + return params + + ani = self._current_ani() + if ani: + params["ani"] = ani + + protocol_id = self._current_protocol_number() + if protocol_id: + params["protocol_id"] = protocol_id + + session_id = self._current_session_id() + if session_id: + params["session_id"] = session_id + + message_id = self._query_message_id() + if message_id: + params["message_id"] = message_id + + channel_id = self._current_channel_id() + if channel_id: + params["channelId"] = channel_id + + ura_call_id = self._current_ura_call_id() + if ura_call_id: + params["uraCallId"] = ura_call_id + + return params + + def _action_params( + self, + user_input: Any = None, + *, + message_id: str = "", + ) -> Dict[str, str]: + params: Dict[str, str] = {} + if self._remote_session_id: + params["session_id"] = self._remote_session_id + + if self._current_agent_name() != "conta": + return params + + if "session_id" not in params: + session_id = self._current_session_id() + if session_id: + params["session_id"] = session_id + + ani = self._current_ani() + if ani: + params["ani"] = ani + + protocol_id = self._current_protocol_number() + if protocol_id: + params["protocol_id"] = protocol_id + + request_message_id = message_id or self._query_message_id(user_input) + if request_message_id: + params["message_id"] = request_message_id + + channel_id = self._current_channel_id() + if channel_id: + params["channelId"] = channel_id + + ura_call_id = self._current_ura_call_id() + if ura_call_id: + params["uraCallId"] = ura_call_id + + return params + + def _stream_headers(self) -> Dict[str, str]: + return { + "Accept": "text/event-stream", + "Cache-Control": "no-cache", + } + + def _oferta_headers(self) -> Dict[str, str]: + headers = self._stream_headers() + headers["Content-Type"] = "application/json" + headers["Channel-id"] = self._current_channel_id() or "ura" + return headers + + def _client_timeout(self) -> httpx.Timeout: + return httpx.Timeout( + connect=self._connect_timeout_s, + read=self._read_timeout_s, + write=self._write_timeout_s, + pool=self._connect_timeout_s, + ) + + def _update_session_actions(self, payload: Mapping[str, Any]) -> None: + if "actions" not in payload: + return + + raw_actions = payload.get("actions") + parsed_actions: set[str] = set() + if isinstance(raw_actions, list): + for item in raw_actions: + action = str(item or "").strip().lower() + if action: + parsed_actions.add(action) + elif isinstance(raw_actions, str): + action = raw_actions.strip().lower() + if action: + parsed_actions.add(action) + + self._session_actions = parsed_actions + + def _update_session_id(self, payload: Mapping[str, Any]) -> None: + for key in ("session_id", "sessionId"): + value = payload.get(key) + if value not in (None, ""): + self._remote_session_id = self._string_or_empty(value) + return + + def _session_supports_action(self, action: str) -> bool: + normalized_action = str(action or "").strip().lower() + if not normalized_action: + return False + + if self._session_actions is None: + return normalized_action != "end" + + return normalized_action in self._session_actions + + @staticmethod + def _is_unsupported_action_error(error: BaseException, action: str) -> bool: + action_name = str(action or "").strip().lower() + if not action_name: + return False + + message = str(error or "").strip().lower() + return "não suportada" in message and action_name in message + + def _emit_request( + self, + *, + method: str, + url: str, + action: str, + params: Mapping[str, Any], + payload: Dict[str, Any] | None = None, + ) -> None: + if self._timeline is not None: + self._timeline.emit( + "remote_agent_request", + backend=self._backend_label, + backend_family="remote_sse", + agent=self._current_agent_name(), + method=str(method or "").upper(), + url=url, + action=action, + params=dict(params), + payload=payload, + ) + + def _clear_push_queue(self) -> None: + while True: + try: + self._push_queue.get_nowait() + except asyncio.QueueEmpty: + return + + def supports_server_push(self) -> bool: + if not self._push_queue.empty(): + return True + if not self._is_oferta_agent() or self._offer_stream_task is None: + return False + if not self._offer_stream_task.done(): + return True + try: + return self._offer_stream_task.exception() is not None + except asyncio.CancelledError: + return False + + def supports_inflight_backend_push(self) -> bool: + return self._current_agent_name() == "conta" + + async def wait_for_server_push(self) -> BackendReply | None: + reply = await self._next_pushed_reply() + if reply is None: + return None + + self._last_stage = reply.stage or self._last_stage or self._default_stage + return BackendReply( + stage=self._last_stage, + text=reply.text, + done=bool(reply.done) or self._last_stage == "DONE", + export_payload=reply.result, + metadata=reply.metadata, + ) + + async def _next_pushed_reply(self) -> _RemoteAgentReply | None: + try: + return self._push_queue.get_nowait() + except asyncio.QueueEmpty: + pass + + if self._current_agent_name() == "conta": + return await self._next_conta_feedback_reply() + + if not self._is_oferta_agent(): + return None + + stream_task = self._offer_stream_task + if stream_task is None: + return None + + if stream_task.done(): + return self._reply_from_finished_offer_stream(stream_task) + + get_task = asyncio.create_task(self._push_queue.get(), name="remote_sse_oferta_push_get") + try: + done, _pending = await asyncio.wait( + {get_task, stream_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + except asyncio.CancelledError: + get_task.cancel() + await asyncio.gather(get_task, return_exceptions=True) + raise + + if get_task in done: + return get_task.result() + + get_task.cancel() + await asyncio.gather(get_task, return_exceptions=True) + try: + return self._push_queue.get_nowait() + except asyncio.QueueEmpty: + return self._reply_from_finished_offer_stream(stream_task) + + def _reply_from_finished_offer_stream(self, stream_task: asyncio.Task[None]) -> _RemoteAgentReply | None: + try: + exc = stream_task.exception() + except asyncio.CancelledError: + return None + if exc is not None: + raise RuntimeError("Remote oferta SSE stream failed") from exc + return None + + async def _next_conta_feedback_reply(self) -> _RemoteAgentReply | None: + saw_stream = False + while True: + try: + return self._push_queue.get_nowait() + except asyncio.QueueEmpty: + pass + + if self._conta_feedback_streams <= 0: + if saw_stream: + return None + self._conta_feedback_event.clear() + try: + await asyncio.wait_for(self._conta_feedback_event.wait(), timeout=1.0) + except asyncio.TimeoutError: + return None + continue + + saw_stream = True + get_task = asyncio.create_task(self._push_queue.get(), name="remote_sse_conta_feedback_get") + state_task = asyncio.create_task( + self._conta_feedback_event.wait(), + name="remote_sse_conta_feedback_state", + ) + try: + done, _pending = await asyncio.wait( + {get_task, state_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + except asyncio.CancelledError: + get_task.cancel() + state_task.cancel() + await asyncio.gather(get_task, state_task, return_exceptions=True) + raise + + if get_task in done: + state_task.cancel() + await asyncio.gather(state_task, return_exceptions=True) + return get_task.result() + + get_task.cancel() + await asyncio.gather(get_task, return_exceptions=True) + self._conta_feedback_event.clear() + + def _base_payload(self) -> Dict[str, Any]: + payload = { + "timestamp": self._current_timestamp(), + "agent": self._current_agent_name(), + "RouterCallKeyDay": self._request_field("RouterCallKeyDay", "router_call_key_day", "routerCallKeyDay"), + "RouterCallKey": self._request_field("RouterCallKey", "router_call_key", "routerCallKey"), + "ANI": self._request_field("ANI", "ani"), + "GSM": self._request_field("GSM", "gsm", "NUM_TELEFONE"), + "callIdGed": self._request_field("callIdGed"), + } + + invoice_number = self._current_invoice_number() + if invoice_number: + payload["invoice_id"] = invoice_number + if self._current_agent_name() == "conta": + payload["ID_FATURA"] = invoice_number + + return payload + + def _next_message_id(self) -> str: + source = dict(self._request_context) + if self._protocol: + source.setdefault("protocol", self._protocol) + return next_turn_message_id(source) + + def _current_oferta_initial_message(self) -> str: + return ( + self._string_or_empty(os.getenv(_OFERTA_INITIAL_MESSAGE_ENV)) + or _OFERTA_INITIAL_MESSAGE_DEFAULT + ) + + def _env_first(self, *names: str) -> str: + for name in names: + value = self._string_or_empty(os.getenv(name)) + if value: + return value + return "" + + def _verify_tls(self) -> bool: + agent_name = re.sub(r"[^A-Z0-9]+", "_", self._current_agent_name().upper()).strip("_") + candidate_names = [] + if agent_name: + candidate_names.extend( + [ + f"REMOTE_AGENT_SSE_TLS_VERIFY_{agent_name}", + f"REMOTE_AGENT_TLS_VERIFY_{agent_name}", + f"REMOTE_AGENT_SSE_VERIFY_TLS_{agent_name}", + f"REMOTE_AGENT_VERIFY_TLS_{agent_name}", + ] + ) + candidate_names.extend( + [ + "REMOTE_AGENT_SSE_TLS_VERIFY", + "REMOTE_AGENT_TLS_VERIFY", + "REMOTE_AGENT_SSE_VERIFY_TLS", + "REMOTE_AGENT_VERIFY_TLS", + ] + ) + + raw_value = self._env_first(*candidate_names) + if not raw_value: + return True + + normalized = raw_value.strip().lower() + if normalized in _FALSE_ENV_VALUES: + return False + if normalized in _TRUE_ENV_VALUES: + return True + return True + + def _build_oferta_context(self) -> Dict[str, Any]: + context: Dict[str, Any] = {} + + explicit_session_id = self._request_field("session_id", "sessionId") + if explicit_session_id: + context["sessionId"] = explicit_session_id + + protocol_number = self._current_protocol_number() + if protocol_number: + context["protocolNumber"] = protocol_number + context["protocolo"] = protocol_number + + gsm = self._current_msisdn() + if gsm: + context["gsm"] = gsm + + call_id_ged = self._request_field("callIdGed") + if call_id_ged: + context["uraId"] = call_id_ged + context["callIdGed"] = call_id_ged + + optional_fields = ( + ("ani", ("ANI", "ani")), + ("routerCallKey", ("RouterCallKey", "routerCallKey")), + ("routerCallKeyDay", ("RouterCallKeyDay", "routerCallKeyDay")), + ("agent", ("agent",)), + ("assetId", ("assetId", "asset_id")), + ) + for output_key, input_keys in optional_fields: + value = self._request_field(*input_keys) + if value: + context[output_key] = value + + self._add_pending_speech_interruption(context) + if self._pending_events: + context["events"] = list(self._pending_events) + + return context + + def _build_oferta_execute_payload(self, user_input: Any) -> Dict[str, Any]: + user_text = self._extract_user_text(user_input) + message = user_text or self._current_oferta_initial_message() + message_id = self._message_id_from_user_input(user_input) or self._next_message_id() + return { + "messageId": message_id, + "message": message, + "context": self._build_oferta_context(), + } + + def _build_conta_payload( + self, + user_input: Any, + *, + message_id: str = "", + ) -> Dict[str, Any]: + payload = { + "message": self._extract_user_text(user_input), + } + if message_id: + payload["message_id"] = message_id + + self._add_pending_speech_interruption(payload) + if self._pending_events: + payload["events"] = list(self._pending_events) + + return payload + + def _build_turn_payload( + self, + user_input: Any, + *, + message_id: str = "", + ) -> Dict[str, Any]: + if self._current_agent_name() == "conta": + request_message_id = message_id or self._query_message_id(user_input) + return { + "action": "chat", + "payload": self._build_conta_payload( + user_input, + message_id=request_message_id, + ), + } + + payload = self._base_payload() + payload["message"] = self._extract_user_text(user_input) + payload["msisdn"] = self._current_msisdn() + payload["stage"] = self._last_stage + + self._add_pending_speech_interruption(payload) + if self._pending_events: + payload["events"] = list(self._pending_events) + + return { + "action": "chat", + "payload": payload, + } + + def _build_end_payload(self, *, message_id: str = "") -> Dict[str, Any]: + if self._current_agent_name() == "conta": + request_message_id = message_id or self._query_message_id() + return { + "action": "end", + "payload": self._build_conta_payload("", message_id=request_message_id), + } + + payload = self._base_payload() + payload["msisdn"] = self._current_msisdn() + payload["stage"] = self._last_stage + + self._add_pending_speech_interruption(payload) + if self._pending_events: + payload["events"] = list(self._pending_events) + + return { + "action": "end", + "payload": payload, + } + + def _clear_pending_state(self) -> None: + self._pending_interrupt = None + self._pending_events.clear() + + def _extract_stage(self, payload: Mapping[str, Any]) -> str: + stage = payload.get("stage") + if isinstance(stage, str) and stage.strip(): + return stage.strip().upper() + return "" + + def _extract_delta(self, payload: Mapping[str, Any]) -> str: + for key in ("delta", "chunk", "token"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value + return "" + + def _extract_reply_text(self, payload: Mapping[str, Any]) -> str: + message_type = str(payload.get("type") or "").strip().lower() + if message_type == "final": + text = self._extract_text_from_value(payload.get("content")) + if text: + return text + + for key in ("result", *_TEXT_KEYS): + value = payload.get(key) + text = self._extract_text_from_value(value) + if text: + return text + return "" + + @staticmethod + def _bool_or_none(value: Any) -> bool | None: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + return None + + def _reply_metadata(self, payload: Mapping[str, Any], result: Any = None) -> Dict[str, Any] | None: + raw_metadata = payload.get("metadata") + metadata: Dict[str, Any] = dict(raw_metadata) if isinstance(raw_metadata, Mapping) else {} + + sources: list[Mapping[str, Any]] = [] + if isinstance(result, Mapping): + sources.append(result) + sources.append(payload) + + for source in sources: + message_id = self._message_id_from_mapping(source) + if message_id: + metadata["message_id"] = message_id + break + + for source in sources: + speech_id = self._string_or_empty(source.get("speech_id")) + if speech_id: + metadata["speech_id"] = speech_id + break + + for source in sources: + interruptible = self._bool_or_none(source.get("is_interruptible")) + if interruptible is not None: + metadata["is_interruptible"] = interruptible + break + + if self._current_agent_name() == "conta": + message_type = str(payload.get("type") or "").strip().lower() + result_type = "" + if isinstance(result, Mapping): + result_type = str(result.get("type") or "").strip().lower() + + expects_user_response = ( + result_type == "final" + or (not result_type and message_type in _CONTA_USER_RESPONSE_MESSAGE_TYPES) + ) + terminal_result = final_stop_from_agent_result(result) is not None + protected_speech = ( + message_type in {"ready", "feedback"} + or terminal_result + or (bool(result_type) and result_type != "final") + ) + + if message_type: + metadata["agent_message_type"] = message_type + if result_type: + metadata["agent_result_type"] = result_type + metadata["expects_user_response"] = expects_user_response + metadata["drop_user_input_while_speaking"] = protected_speech + if protected_speech: + metadata["is_interruptible"] = False + + return metadata or None + + def _is_conta_feedback(self, message_type: str) -> bool: + return self._current_agent_name() == "conta" and message_type == "feedback" + + def _is_conta_result_feedback(self, result: Any) -> bool: + return ( + self._current_agent_name() == "conta" + and isinstance(result, Mapping) + and str(result.get("type") or "").strip().lower() == "feedback" + ) + + def _feedback_metadata( + self, + payload: Mapping[str, Any], + base_metadata: Any = None, + ) -> Dict[str, Any]: + metadata: Dict[str, Any] = {} + if isinstance(base_metadata, Mapping): + metadata.update(base_metadata) + metadata["event"] = "feedback" + metadata["payload"] = dict(payload) + return metadata + + def _queue_feedback_reply( + self, + *, + payload: Mapping[str, Any], + stage: str, + text: str, + metadata: Any = None, + ) -> None: + feedback_text = (text or "").strip() + if not feedback_text: + return + + metadata = self._metadata_with_message_id(metadata, self._active_request_message_id) + reply = _RemoteAgentReply( + stage=stage or self._fallback_stage(), + text=feedback_text, + done=False, + result=None, + event_type="feedback", + metadata=self._feedback_metadata(payload, metadata), + ) + self._push_queue.put_nowait(reply) + self._conta_feedback_event.set() + + if self._timeline is not None: + self._timeline.emit( + "remote_agent_response", + backend=self._backend_label, + backend_family="remote_sse", + agent=self._current_agent_name(), + sse_event="feedback", + stage=reply.stage, + done=False, + text_len=len(reply.text), + has_result=False, + ) + + def _is_conta_feedback_stream( + self, + *, + method: str, + action: str, + payload: Mapping[str, Any] | None, + ) -> bool: + return ( + self._current_agent_name() == "conta" + and str(method or "").upper() == "POST" + and str(action or "").strip().lower() == "chat" + and payload is not None + ) + + def _begin_conta_feedback_stream(self) -> None: + self._conta_feedback_streams += 1 + self._conta_feedback_event.set() + + def _end_conta_feedback_stream(self) -> None: + self._conta_feedback_streams = max(0, self._conta_feedback_streams - 1) + self._conta_feedback_event.set() + + def _add_pending_speech_interruption(self, payload: Dict[str, Any]) -> None: + if self._pending_interrupt is not None: + interruption = dict(self._pending_interrupt) + interruption_key = str( + interruption.pop("_interruption_field", "speech_interruption") + ) + payload[interruption_key] = interruption + + def _fallback_stage(self) -> str: + if self._last_stage and self._last_stage != "INTRO": + return self._last_stage + return self._default_stage + + async def _next_sse_event(self, line_iterator: Any) -> tuple[str, Any] | None: + event_name = "" + data_lines: List[str] = [] + + while True: + try: + raw_line = await line_iterator.__anext__() + except StopAsyncIteration: + if event_name or data_lines: + break + return None + + line = str(raw_line or "") + if not line: + if event_name or data_lines: + break + continue + if line.startswith(":"): + continue + + field, sep, value = line.partition(":") + if not sep: + field = line + value = "" + elif value.startswith(" "): + value = value[1:] + + if field == "event": + event_name = value.strip().lower() + elif field == "data": + data_lines.append(value) + + data = "\n".join(data_lines).strip() + payload = self._decode_message(data) if data else {} + return event_name, payload + + def _coerce_event_payload(self, event_name: str, payload: Any) -> Dict[str, Any]: + if isinstance(payload, str): + coerced = {"type": event_name or "message", "text": payload} + elif not isinstance(payload, Mapping): + coerced = {"type": event_name or "message"} + else: + coerced = dict(payload) + + if "type" not in coerced and event_name: + coerced["type"] = event_name + + return coerced + + def _payload_to_reply( + self, + *, + event_name: str, + payload: Mapping[str, Any], + chunks: List[str], + ) -> tuple[_RemoteAgentReply | None, str, bool]: + self._update_session_id(payload) + self._update_session_actions(payload) + + message_type = str(payload.get("type") or event_name or "").strip().lower() + prefetch_terminal = message_type in _PREFETCH_TERMINAL_TYPES or event_name in _PREFETCH_TERMINAL_TYPES + + error = payload.get("error") + if event_name == "error" or (error and not prefetch_terminal): + raise RuntimeError(str(error or self._extract_reply_text(payload) or "remote agent SSE error")) + + stage = self._extract_stage(payload) + delta = self._extract_delta(payload) + if message_type == "progress" and not delta: + delta = self._extract_reply_text(payload) + if delta: + chunks.append(delta) + if self._timeline is not None: + self._timeline.emit( + "remote_agent_stream_chunk", + agent=self._current_agent_name(), + chunk_len=len(delta), + ) + + if prefetch_terminal or message_type in _STREAM_TYPES: + return None, message_type, prefetch_terminal + + result: Any = None + if "result" in payload: + result = payload.get("result") + elif message_type in {"result", "proactive_result"}: + result = dict(payload) + metadata = self._reply_metadata(payload, result) + + reply_text = self._extract_reply_text(payload) + if self._is_conta_feedback(message_type) or self._is_conta_result_feedback(result): + self._queue_feedback_reply( + payload=payload, + stage=stage or self._fallback_stage(), + text=reply_text, + metadata=metadata, + ) + return None, message_type, prefetch_terminal + + done = ( + final_stop_from_agent_result(result) is not None + or message_type == "done" + or bool(payload.get("done")) + or stage == "DONE" + ) + terminal = ( + bool(payload.get("final")) + or message_type in _TERMINAL_TYPES + or bool(reply_text) + or result is not None + or done + ) + if not terminal: + return None, message_type, prefetch_terminal + + text = (reply_text or "".join(chunks)).strip() + final_stage = stage or self._fallback_stage() + if done: + final_stage = "DONE" + + reply = _RemoteAgentReply( + stage=final_stage, + text=text, + done=done, + result=result, + event_type=message_type, + metadata=metadata, + ) + + if self._timeline is not None: + self._timeline.emit( + "remote_agent_response", + backend=self._backend_label, + backend_family="remote_sse", + agent=self._current_agent_name(), + stage=reply.stage, + done=reply.done, + text_len=len(reply.text), + has_result=reply.result is not None, + ) + logger.info( + "REMOTE_AGENT_SSE_RESPONSE | backend=%s | agent=%s | stage=%s | done=%s | text=%r", + self._backend_label, + self._current_agent_name(), + reply.stage, + reply.done, + reply.text, + ) + + chunks.clear() + return reply, message_type, prefetch_terminal + + def _oferta_done_result(self, payload: Mapping[str, Any]) -> tuple[Dict[str, Any], bool]: + status = str(payload.get("status") or "").strip().lower() + additional = payload.get("additionalInformations") + if not isinstance(additional, Mapping): + additional = {} + + service_status = str(additional.get("service_status") or "").strip().upper() + if status == "transferred": + return ( + { + "type": "transferred", + "content": "", + "status": str(payload.get("status") or "").strip() or "completed", + "additionalInformations": dict(additional), + }, + False, + ) + + if service_status in _OFERTA_NON_FINAL_SERVICE_STATUSES: + return ( + { + "type": service_status.lower(), + "content": "", + "status": str(payload.get("status") or "").strip() or "completed", + "additionalInformations": dict(additional), + }, + False, + ) + + result_type = _OFERTA_SERVICE_STATUS_RESULT_TYPE.get(service_status) + if not result_type: + return ( + { + "type": service_status.lower() if service_status else "completed", + "content": "", + "status": str(payload.get("status") or "").strip() or "completed", + "additionalInformations": dict(additional), + }, + False, + ) + + return ( + { + "type": result_type, + "content": "", + "status": str(payload.get("status") or "").strip() or "completed", + "additionalInformations": dict(additional), + }, + True, + ) + + def _oferta_payload_to_reply( + self, + *, + event_name: str, + payload: Mapping[str, Any], + ) -> _RemoteAgentReply | None: + normalized_event = str(event_name or payload.get("type") or "").strip().lower() + self._update_session_id(payload) + + if normalized_event == "schedule_message": + scheduled = payload.get("scheduledMessage") + if not isinstance(scheduled, Mapping): + scheduled = {} + text = self._extract_text_from_value(scheduled.get("message")) + if not text: + return None + return _RemoteAgentReply( + stage=self._fallback_stage(), + text=text, + done=False, + result=None, + event_type="schedule_message", + metadata={ + "event": "schedule_message", + "scheduledMessage": dict(scheduled), + "payload": dict(payload), + **(self._reply_metadata(payload) or {}), + }, + ) + + if normalized_event == "message": + text = ( + self._extract_text_from_value(payload.get("response")) + or self._extract_reply_text(payload) + ) + if not text: + return None + metadata: Dict[str, Any] = { + "event": "message", + "payload": dict(payload), + } + speech_metadata = self._reply_metadata(payload) + if speech_metadata: + metadata.update(speech_metadata) + if payload.get("sessionId") not in (None, ""): + metadata["sessionId"] = self._string_or_empty(payload.get("sessionId")) + additional = payload.get("additionalInformations") + if isinstance(additional, Mapping): + metadata["additionalInformations"] = dict(additional) + return _RemoteAgentReply( + stage=self._fallback_stage(), + text=text, + done=False, + result=None, + event_type="message", + metadata=metadata, + ) + + if normalized_event == "done": + additional = payload.get("additionalInformations") + if not isinstance(additional, Mapping): + additional = {} + result, finalizes_conversation = self._oferta_done_result(payload) + metadata = { + "event": "done", + "payload": dict(payload), + "additionalInformations": dict(additional), + } + return _RemoteAgentReply( + stage="DONE" if finalizes_conversation else self._fallback_stage(), + text="", + done=finalizes_conversation, + result=result, + event_type="done", + metadata=metadata, + ) + + error = payload.get("error") + if normalized_event == "error" or error: + raise RuntimeError(str(error or self._extract_reply_text(payload) or "remote oferta SSE error")) + + return None + + async def _consume_oferta_execute_stream( + self, + *, + url: str, + payload: Dict[str, Any], + ) -> None: + self._emit_request(method="POST", url=url, action="execute", params={}, payload=payload) + request_started = time.perf_counter() + saw_event = False + saw_done = False + + async with httpx.AsyncClient( + follow_redirects=True, + timeout=self._client_timeout(), + verify=self._verify_tls(), + ) as client: + async with client.stream( + "POST", + url, + params={}, + headers=self._oferta_headers(), + json=payload, + ) as response: + response.raise_for_status() + line_iterator = response.aiter_lines() + while True: + event = await self._next_sse_event(line_iterator) + if event is None: + break + + saw_event = True + event_name, raw_payload = event + event_payload = self._coerce_event_payload(event_name, raw_payload) + if self._timeline is not None: + self._timeline.emit( + "remote_agent_response_raw", + backend=self._backend_label, + backend_family="remote_sse", + agent=self._current_agent_name(), + sse_event=event_name or event_payload.get("type", ""), + response_payload=dict(event_payload), + ) + reply = self._oferta_payload_to_reply( + event_name=event_name, + payload=event_payload, + ) + if reply is not None: + self._reply_with_message_id(reply, payload.get("messageId")) + if self._timeline is not None: + self._timeline.emit( + "remote_agent_response", + backend=self._backend_label, + backend_family="remote_sse", + agent=self._current_agent_name(), + sse_event=reply.event_type, + stage=reply.stage, + done=reply.done, + text_len=len(reply.text), + has_result=reply.result is not None, + ) + logger.info( + "REMOTE_AGENT_SSE_RESPONSE | backend=%s | agent=%s | event=%s | stage=%s | done=%s | duration_ms=%s | text=%r", + self._backend_label, + self._current_agent_name(), + reply.event_type, + reply.stage, + reply.done, + round((time.perf_counter() - request_started) * 1000), + reply.text, + ) + await self._push_queue.put(reply) + if reply.event_type == "done": + saw_done = True + break + + if not saw_event: + raise RuntimeError("Remote oferta SSE stream closed without a usable response") + if not saw_done: + raise RuntimeError("Remote oferta SSE stream closed before done") + + async def _start_oferta_execute_stream_locked(self, payload: Dict[str, Any]) -> _RemoteAgentReply: + await self._cancel_oferta_stream_locked() + self._clear_push_queue() + url = self._resolve_url() + self._offer_stream_task = asyncio.create_task( + self._consume_oferta_execute_stream(url=url, payload=payload), + name="remote_sse_oferta_stream", + ) + reply = await self._next_pushed_reply() + if reply is None: + raise RuntimeError("Remote oferta SSE stream closed without a usable response") + return reply + + async def _cancel_oferta_stream_locked(self) -> None: + task = self._offer_stream_task + if task is None: + return + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + else: + try: + task.exception() + except asyncio.CancelledError: + pass + self._offer_stream_task = None + + async def _consume_sse_response( + self, + line_iterator: Any, + *, + stop_on_first_result: bool, + stop_on_ready: bool, + stop_on_prefetch_terminal: bool, + ) -> list[_RemoteAgentReply]: + chunks: List[str] = [] + replies: list[_RemoteAgentReply] = [] + saw_event = False + + while True: + event = await self._next_sse_event(line_iterator) + if event is None: + break + + saw_event = True + event_name, raw_payload = event + payload = self._coerce_event_payload(event_name, raw_payload) + reply, message_type, prefetch_terminal = self._payload_to_reply( + event_name=event_name, + payload=payload, + chunks=chunks, + ) + if reply is not None: + replies.append(reply) + if stop_on_ready and message_type == "ready": + break + if stop_on_first_result and message_type in {"result", "proactive_result", "done", "final"}: + break + + if stop_on_prefetch_terminal and prefetch_terminal: + break + + if chunks: + replies.append( + _RemoteAgentReply( + stage=self._fallback_stage(), + text="".join(chunks).strip(), + done=False, + result=None, + event_type="progress", + ) + ) + + if not saw_event: + raise RuntimeError("Remote agent SSE stream closed without a usable response") + + return replies + + async def _stream_request( + self, + *, + method: str, + url: str, + params: Mapping[str, Any], + action: str, + payload: Dict[str, Any] | None = None, + stop_on_first_result: bool = False, + stop_on_ready: bool = False, + stop_on_prefetch_terminal: bool = False, + ) -> list[_RemoteAgentReply]: + self._emit_request(method=method, url=url, action=action, params=params, payload=payload) + request_started = time.perf_counter() + feedback_stream = self._is_conta_feedback_stream( + method=method, + action=action, + payload=payload, + ) + request_message_id = self._message_id_from_mapping(params) + previous_request_message_id = self._active_request_message_id + self._active_request_message_id = request_message_id + if feedback_stream: + self._begin_conta_feedback_stream() + try: + async with httpx.AsyncClient( + follow_redirects=True, + timeout=self._client_timeout(), + verify=self._verify_tls(), + ) as client: + kwargs: Dict[str, Any] = { + "params": dict(params), + "headers": self._stream_headers(), + } + if payload is not None: + kwargs["json"] = payload + + stream_cm = client.stream(method, url, **kwargs) + async with stream_cm as response: + response.raise_for_status() + replies = await self._consume_sse_response( + response.aiter_lines(), + stop_on_first_result=stop_on_first_result, + stop_on_ready=stop_on_ready, + stop_on_prefetch_terminal=stop_on_prefetch_terminal, + ) + logger.info( + "REMOTE_AGENT_SSE_REQUEST_DONE | backend=%s | agent=%s | method=%s | action=%s | duration_ms=%s | replies=%s", + self._backend_label, + self._current_agent_name(), + str(method or "").upper(), + action, + round((time.perf_counter() - request_started) * 1000), + len(replies), + ) + if request_message_id: + for reply in replies: + self._reply_with_message_id(reply, request_message_id) + return replies + finally: + self._active_request_message_id = previous_request_message_id + if feedback_stream: + self._end_conta_feedback_stream() + + @staticmethod + def _select_ready_reply(replies: list[_RemoteAgentReply]) -> _RemoteAgentReply | None: + for reply in replies: + if reply.event_type == "ready": + return reply + return replies[0] if replies else None + + @staticmethod + def _select_action_reply(replies: list[_RemoteAgentReply]) -> _RemoteAgentReply: + for reply in replies: + if reply.event_type in {"result", "proactive_result", "done", "final"}: + return reply + for reply in replies: + if reply.event_type != "ready": + return reply + if replies: + return replies[-1] + raise RuntimeError("Remote agent SSE stream closed without a usable response") + + async def prepare( + self, + elegibility: bool, + protocol: str, + ) -> None: + if self._timeline is not None: + self._timeline.emit( + "backend_prepare_started", + backend=self._backend_label, + backend_family="remote_sse", + agent=self._current_agent_name(), + ) + self._elegibility = bool(elegibility) + self._protocol = str(protocol or "") + self._remote_session_id = "" + self._turn_seq = 0 + self._last_stage = "INTRO" + self._pending_interrupt = None + self._pending_events.clear() + self._pending_ready_reply = None + self._session_actions = None + self._conta_feedback_streams = 0 + self._conta_feedback_event.clear() + async with self._session_lock: + await self._cancel_oferta_stream_locked() + self._clear_push_queue() + + if self._is_oferta_agent(): + if self._timeline is not None: + self._timeline.emit( + "backend_prepare_completed", + backend=self._backend_label, + backend_family="remote_sse", + agent=self._current_agent_name(), + remote_session_id="", + ) + return + + async with self._session_lock: + url = self._resolve_url() + replies = await self._stream_request( + method="GET", + url=url, + params=self._stream_open_params(), + action="connect", + stop_on_first_result=False, + stop_on_ready=True, + stop_on_prefetch_terminal=True, + ) + self._pending_ready_reply = self._select_ready_reply(replies) + for reply in replies: + if reply is not self._pending_ready_reply and reply.text: + await self._push_queue.put(reply) + + if self._timeline is not None: + self._timeline.emit( + "backend_prepare_completed", + backend=self._backend_label, + backend_family="remote_sse", + agent=self._current_agent_name(), + remote_session_id=self._remote_session_id, + ) + + async def run(self, user_input: Any) -> BackendReply: + user_text = self._extract_user_text(user_input) + sent_payload = False + + async with self._session_lock: + if self._is_oferta_agent(): + payload = self._build_oferta_execute_payload(user_input) + reply = await self._start_oferta_execute_stream_locked(payload) + sent_payload = True + elif self._pending_ready_reply is not None and not user_text: + reply = self._pending_ready_reply + self._pending_ready_reply = None + elif not user_text: + self._pending_ready_reply = None + reply = _RemoteAgentReply( + stage=self._fallback_stage(), + text="", + done=False, + result=None, + event_type="noop", + ) + else: + self._pending_ready_reply = None + message_id = ( + self._query_message_id(user_input) + if self._current_agent_name() == "conta" + else "" + ) + payload = self._build_turn_payload(user_input, message_id=message_id) + replies = await self._stream_request( + method="POST", + url=self._resolve_url(), + params=self._action_params(user_input, message_id=message_id), + action=str(payload.get("action") or "chat"), + payload=payload, + stop_on_first_result=True, + stop_on_prefetch_terminal=False, + ) + reply = self._select_action_reply(replies) + sent_payload = True + + if sent_payload: + self._clear_pending_state() + self._last_stage = reply.stage or self._last_stage or self._default_stage + return BackendReply( + stage=self._last_stage, + text=reply.text, + done=bool(reply.done) or self._last_stage == "DONE", + export_payload=reply.result, + metadata=reply.metadata, + ) + + async def set_interruption( + self, + interrupted: bool, + listened_text: str = "", + skipped: bool = False, + speech_id: str = "", + ) -> None: + if not interrupted: + self._pending_interrupt = None + if self._timeline is not None: + self._timeline.emit( + "backend_set_interruption", + backend=self._backend_label, + backend_family="remote_sse", + agent=self._current_agent_name(), + interrupted=False, + ) + return + + speech_id = self._string_or_empty(speech_id) + if not speech_id: + self._pending_interrupt = None + if self._timeline is not None: + self._timeline.emit( + "backend_set_interruption", + backend=self._backend_label, + backend_family="remote_sse", + agent=self._current_agent_name(), + interrupted=True, + skipped=True, + listened_text=listened_text, + speech_id="", + reason="missing_speech_id", + ) + return + self._pending_interrupt = { + "speech_id": speech_id, + "heard_text": listened_text or "", + } + if self._timeline is not None: + self._timeline.emit( + "backend_set_interruption", + backend=self._backend_label, + backend_family="remote_sse", + agent=self._current_agent_name(), + interrupted=True, + skipped=bool(skipped), + listened_text=listened_text, + speech_id=speech_id, + ) + + async def set_processing_interruption( + self, + listened_text: str = "", + skipped: bool = False, + speech_id: str = "", + ) -> None: + await self.set_interruption( + True, + listened_text=listened_text, + skipped=skipped, + speech_id=speech_id, + ) + if self._pending_interrupt is not None: + self._pending_interrupt["_interruption_field"] = "processing_interruption" + + async def inject_idle_nudge(self, nudge_text: str) -> None: + text = (nudge_text or "").strip() + if not text: + return + + event = { + "type": "idle_nudge", + "text": text, + } + # Cada frase de inatividade substitui a anterior: o cliente responde ao + # que ouviu por ultimo, e as intermediarias so empilham falas do agente + # no historico remoto -- inclusive o aviso de encerramento, que passa a + # constar como dito logo antes de a conversa seguir normalmente. + if self._pending_events and self._pending_events[-1].get("type") == "idle_nudge": + self._pending_events[-1] = event + else: + self._pending_events.append(event) + if self._timeline is not None: + self._timeline.emit( + "backend_idle_nudge_buffered", + backend=self._backend_label, + backend_family="remote_sse", + agent=self._current_agent_name(), + text=text, + ) + + async def end_service_once(self) -> BackendReply: + async with self._end_lock: + if self._ended: + return self._end_reply or BackendReply(stage="DONE", done=True, export_payload=[]) + + self._ended = True + if self._is_oferta_agent(): + async with self._session_lock: + await self._cancel_oferta_stream_locked() + self._end_reply = BackendReply(stage="DONE", done=True, export_payload=[]) + return self._end_reply + + if self._timeline is not None: + self._timeline.emit( + "backend_end_started", + backend=self._backend_label, + backend_family="remote_sse", + agent=self._current_agent_name(), + ) + try: + async with self._session_lock: + if not self._session_supports_action("end"): + reply = _RemoteAgentReply( + stage="DONE", + text="", + done=True, + result=[], + event_type="done", + ) + else: + message_id = ( + self._query_message_id() + if self._current_agent_name() == "conta" + else "" + ) + payload = self._build_end_payload(message_id=message_id) + try: + replies = await self._stream_request( + method="POST", + url=self._resolve_url(), + params=self._action_params(message_id=message_id), + action="end", + payload=payload, + stop_on_first_result=True, + stop_on_prefetch_terminal=False, + ) + reply = self._select_action_reply(replies) + except RuntimeError as exc: + if not self._is_unsupported_action_error(exc, "end"): + raise + reply = _RemoteAgentReply( + stage="DONE", + text="", + done=True, + result=[], + event_type="done", + ) + except Exception: + logger.exception("[remote-agent] end_service SSE falhou") + if self._timeline is not None: + self._timeline.emit( + "backend_end_failed", + backend=self._backend_label, + backend_family="remote_sse", + agent=self._current_agent_name(), + ) + self._end_reply = BackendReply(stage="DONE", done=True, export_payload=[]) + return self._end_reply + + self._clear_pending_state() + stage = (reply.stage or self._last_stage or self._default_stage).upper() + self._last_stage = stage + self._end_reply = BackendReply( + stage=stage, + text=reply.text, + done=bool(reply.done) or stage == "DONE", + export_payload=reply.result if reply.result is not None else [], + metadata=reply.metadata, + ) + if self._timeline is not None: + self._timeline.emit( + "backend_end_completed", + backend=self._backend_label, + backend_family="remote_sse", + agent=self._current_agent_name(), + result_type=type(self._end_reply.export_payload).__name__, + ) + return self._end_reply diff --git a/src/app/livekit/adapters/remote_agent_ws_adapter.py b/src/app/livekit/adapters/remote_agent_ws_adapter.py new file mode 100644 index 0000000..433541f --- /dev/null +++ b/src/app/livekit/adapters/remote_agent_ws_adapter.py @@ -0,0 +1,1577 @@ +from __future__ import annotations + +import asyncio +import importlib +import json +import logging +import os +import re +import socket +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Dict, List, Mapping, Optional +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from app.livekit.adapters.agent_backend import BackendReply +from app.livekit.policies.agent_finalization import final_stop_from_agent_result +from app.utils.logging import setup_minimal_logging +from app.utils.turn_ids import next_turn_message_id + +logger = setup_minimal_logging() + +_STREAM_TYPES = {"chunk", "delta", "partial", "token"} +_TERMINAL_TYPES = {"done", "final", "message", "output", "response", "result"} +_TEXT_KEYS = ("text", "response", "output", "message", "content") +_USER_TEXT_KEYS = ("text", "transcript", "utterance", "message", "content") +_CONTA_USER_RESPONSE_MESSAGE_TYPES = {"ready", "final"} + + +@dataclass(slots=True) +class _RemoteAgentReply: + stage: str + text: str + done: bool + result: Any + metadata: Any = None + + +class RemoteAgentWSAdapter: + def __init__( + self, + *, + intro: str, + url: str, + backend_label: str = "remote_ws", + url_by_agent: Optional[Dict[str, str]] = None, + request_context: Optional[Dict[str, Any]] = None, + timeline: Any | None = None, + streaming: bool = False, + default_stage: str = "PRESENTATION", + open_timeout_s: float = 10.0, + read_timeout_s: float = 45.0, + write_timeout_s: float = 10.0, + close_timeout_s: float = 10.0, + max_message_bytes: int = 1024 * 1024, + ) -> None: + self._intro = intro + self._url = url + self._backend_label = str(backend_label or "remote_ws").strip().lower() + self._url_by_agent = { + self._normalize_agent_name(key): value.strip() + for key, value in (url_by_agent or {}).items() + if value and value.strip() + } + self._request_context = dict(request_context or {}) + self._timeline = timeline + self._streaming = streaming + self._default_stage = (default_stage or "PRESENTATION").strip().upper() + self._open_timeout_s = open_timeout_s + self._read_timeout_s = read_timeout_s + self._write_timeout_s = write_timeout_s + self._close_timeout_s = close_timeout_s + self._max_message_bytes = max_message_bytes + self._instance_name = (os.getenv("HOSTNAME") or socket.gethostname() or "").strip() or "-" + + self._elegibility = True + self._protocol = "" + self._last_stage = "INTRO" + self._pending_interrupt: Optional[Dict[str, Any]] = None + self._pending_events: List[Dict[str, Any]] = [] + + self._end_lock = asyncio.Lock() + self._ended = False + self._end_reply: Optional[BackendReply] = None + self._session_lock = asyncio.Lock() + self._session_cm: Any | None = None + self._session_websocket: Any | None = None + self._pending_ready_reply: Optional[_RemoteAgentReply] = None + self._reader_task: asyncio.Task[Any] | None = None + self._response_waiter: asyncio.Future[_RemoteAgentReply] | None = None + self._push_queue: asyncio.Queue[_RemoteAgentReply] = asyncio.Queue() + self._reader_error: BaseException | None = None + self._terminal_reply: Optional[_RemoteAgentReply] = None + self._session_actions: set[str] | None = None + self._query_message_seq = 0 + self._connection_message_id = "" + self._active_request_message_id = "" + + @staticmethod + def _normalize_agent_name(value: Any) -> str: + raw = str(value or "").strip().lower() + aliases = { + "conta": "conta", + "contas": "conta", + "ofert": "oferta", + "oferta": "oferta", + "ofertas": "oferta", + "cobra": "cobranca", + "cobranca": "cobranca", + "cobrança": "cobranca", + "cobrancas": "cobranca", + "cobranças": "cobranca", + } + return aliases.get(raw, raw) + + @staticmethod + def _string_or_empty(value: Any) -> str: + return str(value or "").strip() + + @classmethod + def _message_id_from_mapping(cls, payload: Mapping[str, Any] | None) -> str: + if not isinstance(payload, Mapping): + return "" + + for key in ("message_id", "messageId"): + value = payload.get(key) + if value not in (None, ""): + return cls._string_or_empty(value) + + for container_key in ("metadata", "data", "payload", "context"): + metadata = payload.get(container_key) + if not isinstance(metadata, Mapping): + continue + for key in ("message_id", "messageId"): + value = metadata.get(key) + if value not in (None, ""): + return cls._string_or_empty(value) + + return "" + + @classmethod + def _metadata_with_message_id(cls, metadata: Any, message_id: Any) -> Dict[str, Any] | None: + normalized_message_id = cls._string_or_empty(message_id) + if not normalized_message_id: + return dict(metadata) if isinstance(metadata, Mapping) else metadata + + merged: Dict[str, Any] = dict(metadata) if isinstance(metadata, Mapping) else {} + merged.setdefault("message_id", normalized_message_id) + return merged + + @classmethod + def _reply_with_message_id( + cls, + reply: _RemoteAgentReply, + message_id: Any, + ) -> _RemoteAgentReply: + metadata = cls._metadata_with_message_id(reply.metadata, message_id) + if metadata is not reply.metadata: + reply.metadata = metadata + return reply + + def _import_websockets(self): + module = importlib.import_module("websockets") + connect = getattr(module, "connect", None) + if callable(connect): + return connect + + client_module = getattr(module, "client", None) + connect = getattr(client_module, "connect", None) + if callable(connect): + return connect + + raise RuntimeError("websockets.connect is not available") + + def _serialize_message(self, payload: Dict[str, Any]) -> str: + return json.dumps(payload, ensure_ascii=False) + + def _decode_message(self, raw_message: Any) -> Any: + if isinstance(raw_message, bytes): + raw_message = raw_message.decode("utf-8") + if not isinstance(raw_message, str): + raise RuntimeError(f"Unsupported websocket payload type: {type(raw_message)!r}") + + message = raw_message.strip() + if not message: + return {} + + try: + return json.loads(message) + except json.JSONDecodeError: + return message + + def _extract_text_from_value(self, value: Any) -> str: + if isinstance(value, str): + return value.strip() + if isinstance(value, Mapping): + for key in ("result", * _TEXT_KEYS): + text = self._extract_text_from_value(value.get(key)) + if text: + return text + return "" + if isinstance(value, list): + for item in value: + text = self._extract_text_from_value(item) + if text: + return text + return "" + + def _extract_user_text(self, user_input: Any) -> str: + if isinstance(user_input, str): + return user_input.strip() + + if isinstance(user_input, Mapping): + for key in _USER_TEXT_KEYS: + text = self._extract_text_from_value(user_input.get(key)) + if text: + return text + + for value in user_input.values(): + text = self._extract_text_from_value(value) + if text: + return text + + if isinstance(user_input, list): + for item in user_input: + text = self._extract_user_text(item) + if text: + return text + + return str(user_input).strip() + + def _copy_context(self) -> Dict[str, Any]: + return { + "intro": self._intro, + "elegibility": self._elegibility, + "streaming": self._streaming, + } + + def _current_timestamp(self) -> str: + return datetime.now(timezone.utc).isoformat() + + def _request_field(self, *keys: str) -> str: + for key in keys: + value = self._request_context.get(key) + if value not in (None, ""): + return self._string_or_empty(value) + + lowered = {str(key).lower(): value for key, value in self._request_context.items()} + for key in keys: + value = lowered.get(str(key).lower()) + if value not in (None, ""): + return self._string_or_empty(value) + + return "" + + def _current_agent_name(self) -> str: + return self._normalize_agent_name( + self._request_field("agent", "Agent", "agente") + ) + + def _resolve_url(self) -> str: + agent_name = self._current_agent_name() + if agent_name: + routed_url = self._url_by_agent.get(agent_name, "") + if routed_url: + return routed_url + return self._url + + def _current_invoice_number(self) -> str: + return self._request_field( + "current_invoice_number", + "currentInvoiceNumber", + "ID_FATURA", + "idFatura", + "id_fatura", + "IdFatura", + ) + + def _current_msisdn(self) -> str: + return self._request_field("msisdn", "GSM", "gsm", "NUM_TELEFONE") + + def _current_ani(self) -> str: + return self._request_field("ANI", "ani") + + def _current_protocol_number(self) -> str: + return self._request_field("protocol_id", "protocolId", "protocolo", "protocolNumber", "protocol") + + def _current_session_id(self) -> str: + return self._request_field("session_id", "sessionId") + + def _next_query_message_id(self) -> str: + source = dict(self._request_context) + if self._protocol: + source.setdefault("protocol", self._protocol) + return next_turn_message_id(source) + + def _current_connection_message_id(self) -> str: + explicit_message_id = self._request_field("message_id", "messageId") + if explicit_message_id: + return explicit_message_id + + if not self._connection_message_id: + self._connection_message_id = self._next_query_message_id() + return self._connection_message_id + + def _current_channel_id(self) -> str: + channel_id = self._request_field("channelId", "channel_id") + if channel_id: + return channel_id + return "ura" + + def _current_ura_call_id(self) -> str: + return self._request_field("uraCallId", "ura_call_id", "callIdGed") + + def _current_channel(self) -> str: + channel = self._request_field("channel", "Channel", "canal") + if channel: + return channel + + configured = self._string_or_empty( + os.getenv("REMOTE_AGENT_CHANNEL") + or os.getenv("REMOTE_AGENT_DEFAULT_CHANNEL") + ) + return configured or "SUPERVISOR" + + def _connection_query_params(self) -> Dict[str, str]: + if self._current_agent_name() != "conta": + return {} + + params: Dict[str, str] = {} + msisdn = self._current_msisdn() + if msisdn: + params["msisdn"] = msisdn + + current_invoice_number = self._current_invoice_number() + if current_invoice_number: + params["current_invoice_number"] = current_invoice_number + + ani = self._current_ani() + if ani: + params["ani"] = ani + + protocol_id = self._current_protocol_number() + if protocol_id: + params["protocol_id"] = protocol_id + + session_id = self._current_session_id() + if session_id: + params["session_id"] = session_id + + message_id = self._current_connection_message_id() + if message_id: + params["message_id"] = message_id + + channel_id = self._current_channel_id() + if channel_id: + params["channelId"] = channel_id + + ura_call_id = self._current_ura_call_id() + if ura_call_id: + params["uraCallId"] = ura_call_id + + return params + + @staticmethod + def _append_query_params(url: str, params: Mapping[str, Any]) -> str: + filtered_params = { + str(key): str(value).strip() + for key, value in params.items() + if value not in (None, "") and str(value).strip() + } + if not filtered_params: + return url + + parsed = urlsplit(url) + query_params = dict(parse_qsl(parsed.query, keep_blank_values=True)) + query_params.update(filtered_params) + return urlunsplit( + ( + parsed.scheme, + parsed.netloc, + parsed.path, + urlencode(query_params), + parsed.fragment, + ) + ) + + def _uses_persistent_session(self) -> bool: + return self._current_agent_name() == "conta" + + def _connection_url(self) -> str: + return self._append_query_params( + self._resolve_url(), + self._connection_query_params(), + ) + + def _connection_kwargs(self) -> Dict[str, Any]: + return { + "open_timeout": self._open_timeout_s, + "close_timeout": self._close_timeout_s, + "max_size": self._max_message_bytes, + } + + @staticmethod + def _compact_log_value(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "-" + if len(text) > 240: + return f"{text[:237]}..." + return text.replace("\n", "\\n") + + def _log_ws_event(self, event: str, *, level: int = logging.INFO, **fields: Any) -> None: + base_fields: Dict[str, Any] = { + "backend": self._backend_label, + "agent": self._current_agent_name() or "-", + "persistent": int(self._uses_persistent_session()), + "instance": self._instance_name, + } + base_fields.update(fields) + + ordered_keys = ( + "backend", + "agent", + "persistent", + "instance", + "url", + "host", + "action", + "stage", + "protocol", + "duration_ms", + "text_len", + "text_preview", + "payload_bytes", + "done", + "result_type", + "error_type", + "error", + ) + parts = [] + used_keys: set[str] = set() + for key in ordered_keys: + if key not in base_fields: + continue + value = base_fields[key] + if value in (None, ""): + continue + parts.append(f"{key}={self._compact_log_value(value)}") + used_keys.add(key) + + for key, value in base_fields.items(): + if key in used_keys or value in (None, ""): + continue + parts.append(f"{key}={self._compact_log_value(value)}") + + logger.log(level, "REMOTE_AGENT_WS_%s | %s", event, " | ".join(parts)) + + def _log_ws_failure(self, event: str, error: BaseException, **fields: Any) -> None: + self._log_ws_event( + event, + level=logging.ERROR, + error_type=type(error).__name__, + error=str(error), + **fields, + ) + + def _payload_metadata(self, payload: Mapping[str, Any]) -> Dict[str, Any]: + body = payload.get("payload") + if not isinstance(body, Mapping): + body = payload + + action = str(payload.get("action") or body.get("action") or "").strip().lower() + stage = str(body.get("stage") or payload.get("stage") or self._fallback_stage()).strip().upper() + protocol = str(body.get("protocol") or payload.get("protocol") or self._protocol or "").strip() + if "message" in body: + user_input = body.get("message") or "" + elif "text" in body: + user_input = body.get("text") or "" + else: + user_input = "" + user_text = self._extract_user_text(user_input) + return { + "action": action or "chat", + "stage": stage or self._fallback_stage(), + "protocol": protocol or "-", + "text_len": len(user_text), + } + + @staticmethod + def _url_host(url: str) -> str: + parsed = urlsplit(str(url or "")) + return (parsed.hostname or "").strip() + + def _update_session_actions(self, payload: Mapping[str, Any]) -> None: + if "actions" not in payload: + return + + raw_actions = payload.get("actions") + parsed_actions: set[str] = set() + if isinstance(raw_actions, list): + for item in raw_actions: + action = str(item or "").strip().lower() + if action: + parsed_actions.add(action) + elif isinstance(raw_actions, str): + action = raw_actions.strip().lower() + if action: + parsed_actions.add(action) + + self._session_actions = parsed_actions + + def _persistent_session_supports_action(self, action: str) -> bool: + normalized_action = str(action or "").strip().lower() + if not normalized_action: + return False + + if not self._uses_persistent_session(): + return True + + if self._session_actions is None: + return normalized_action != "end" + + return normalized_action in self._session_actions + + @staticmethod + def _is_unsupported_action_error(error: BaseException, action: str) -> bool: + action_name = str(action or "").strip().lower() + if not action_name: + return False + message = str(error or "").strip().lower() + return "não suportada" in message and action_name in message + + def _emit_request(self, *, url: str, payload: Dict[str, Any]) -> None: + if self._timeline is not None: + self._timeline.emit( + "remote_agent_request", + backend=self._backend_label, + backend_family="remote_ws", + agent=self._current_agent_name(), + url=url, + action=payload.get("action", ""), + payload=payload, + ) + try: + payload_bytes = len(self._serialize_message(payload).encode("utf-8")) + except Exception: + payload_bytes = None + self._log_ws_event( + "REQUEST", + url=url, + host=self._url_host(url), + payload_bytes=payload_bytes, + **self._payload_metadata(payload), + ) + + async def _open_persistent_session_locked(self, *, capture_ready: bool) -> None: + if self._session_websocket is not None: + return + + connect = self._import_websockets() + url = self._connection_url() + self._log_ws_event( + "CONNECT_OPEN", + url=url, + host=self._url_host(url), + protocol=self._protocol or "-", + ) + session_cm = connect(url, **self._connection_kwargs()) + try: + websocket = await session_cm.__aenter__() + except Exception as exc: + self._log_ws_failure( + "CONNECT_FAIL", + exc, + url=url, + host=self._url_host(url), + protocol=self._protocol or "-", + ) + raise + self._session_cm = session_cm + self._session_websocket = websocket + self._reader_error = None + self._terminal_reply = None + self._clear_push_queue() + self._log_ws_event( + "CONNECT_OK", + url=url, + host=self._url_host(url), + protocol=self._protocol or "-", + ) + + ready_waiter: asyncio.Future[_RemoteAgentReply] | None = None + if capture_ready: + ready_waiter = self._create_response_waiter_locked() + + self._start_reader_task_locked() + + if ready_waiter is None: + return + + try: + self._pending_ready_reply = await asyncio.wait_for( + ready_waiter, + timeout=self._read_timeout_s, + ) + except asyncio.TimeoutError: + self._log_ws_event( + "READY_TIMEOUT", + url=url, + host=self._url_host(url), + protocol=self._protocol or "-", + ) + if self._response_waiter is ready_waiter: + self._response_waiter = None + if ready_waiter is not None and not ready_waiter.done(): + ready_waiter.cancel() + await self._close_persistent_session_locked() + raise + except Exception as exc: + self._log_ws_failure( + "READY_FAIL", + exc, + url=url, + host=self._url_host(url), + protocol=self._protocol or "-", + ) + await self._close_persistent_session_locked() + raise + + async def _close_persistent_session_locked(self) -> None: + reader_task = self._reader_task + self._reader_task = None + waiter = self._response_waiter + self._response_waiter = None + session_cm = self._session_cm + self._session_cm = None + self._session_websocket = None + self._pending_ready_reply = None + self._reader_error = None + self._terminal_reply = None + self._session_actions = None + self._query_message_seq = 0 + self._connection_message_id = "" + self._active_request_message_id = "" + self._clear_push_queue() + + if waiter is not None and not waiter.done(): + waiter.cancel() + + if reader_task is not None and not reader_task.done(): + reader_task.cancel() + await asyncio.gather(reader_task, return_exceptions=True) + + if session_cm is None: + return + + await session_cm.__aexit__(None, None, None) + + async def _send_persistent_payload_locked(self, payload: Dict[str, Any]) -> _RemoteAgentReply: + websocket = self._session_websocket + if websocket is None: + raise RuntimeError("Persistent websocket session is not connected") + + waiter = self._create_response_waiter_locked() + url = self._connection_url() + self._emit_request(url=url, payload=payload) + request_started = time.perf_counter() + request_message_id = self._message_id_from_mapping(payload) + previous_request_message_id = self._active_request_message_id + self._active_request_message_id = request_message_id + try: + await asyncio.wait_for( + websocket.send(self._serialize_message(payload)), + timeout=self._write_timeout_s, + ) + except Exception as exc: + self._log_ws_failure( + "SEND_FAIL", + exc, + url=url, + host=self._url_host(url), + **self._payload_metadata(payload), + ) + if self._response_waiter is waiter: + self._response_waiter = None + if not waiter.done(): + waiter.cancel() + self._active_request_message_id = previous_request_message_id + raise + try: + reply = await asyncio.wait_for(waiter, timeout=self._read_timeout_s) + if request_message_id: + self._reply_with_message_id(reply, request_message_id) + done_fields = { + **self._payload_metadata(payload), + "duration_ms": round((time.perf_counter() - request_started) * 1000), + "stage": reply.stage, + "text_len": len(reply.text), + "text_preview": reply.text, + "done": int(reply.done), + "result_type": type(reply.result).__name__ if reply.result is not None else "-", + } + self._log_ws_event( + "TURN_DONE", + url=url, + host=self._url_host(url), + **done_fields, + ) + return reply + except asyncio.TimeoutError: + self._log_ws_event( + "RESPONSE_TIMEOUT", + url=url, + host=self._url_host(url), + duration_ms=round((time.perf_counter() - request_started) * 1000), + **self._payload_metadata(payload), + ) + if self._response_waiter is waiter: + self._response_waiter = None + if not waiter.done(): + waiter.cancel() + raise + finally: + self._active_request_message_id = previous_request_message_id + + async def _run_with_persistent_session(self, user_input: Any) -> tuple[_RemoteAgentReply, bool]: + user_text = self._extract_user_text(user_input) + + async with self._session_lock: + await self._open_persistent_session_locked(capture_ready=self._session_websocket is None) + + if self._pending_ready_reply is not None: + if not user_text: + reply = self._pending_ready_reply + self._pending_ready_reply = None + return reply, False + self._pending_ready_reply = None + + try: + reply = await self._send_persistent_payload_locked(self._build_turn_payload(user_input)) + except Exception: + await self._close_persistent_session_locked() + raise + + return reply, True + + def _clear_push_queue(self) -> None: + while True: + try: + self._push_queue.get_nowait() + except asyncio.QueueEmpty: + return + + def _create_response_waiter_locked(self) -> asyncio.Future[_RemoteAgentReply]: + current = self._response_waiter + if current is not None and not current.done(): + raise RuntimeError("Another persistent websocket response is already pending") + + waiter: asyncio.Future[_RemoteAgentReply] = asyncio.get_running_loop().create_future() + self._response_waiter = waiter + return waiter + + def _start_reader_task_locked(self) -> None: + if self._reader_task is not None and not self._reader_task.done(): + return + websocket = self._session_websocket + if websocket is None: + raise RuntimeError("Persistent websocket session is not connected") + self._reader_task = asyncio.create_task( + self._reader_loop(websocket), + name="remote_agent_ws_reader", + ) + + async def _reader_loop(self, websocket: Any) -> None: + try: + while True: + reply = await self._recv_reply(websocket, timeout_s=None) + waiter = self._response_waiter + if waiter is not None and not waiter.done(): + self._response_waiter = None + waiter.set_result(reply) + else: + await self._push_queue.put(reply) + + if reply.done: + self._terminal_reply = reply + return + except asyncio.CancelledError: + raise + except Exception as exc: + self._reader_error = exc + self._log_ws_failure( + "READER_FAIL", + exc, + url=self._connection_url(), + host=self._url_host(self._connection_url()), + protocol=self._protocol or "-", + ) + waiter = self._response_waiter + if waiter is not None and not waiter.done(): + self._response_waiter = None + waiter.set_exception(exc) + + def supports_server_push(self) -> bool: + return self._uses_persistent_session() + + def supports_inflight_backend_push(self) -> bool: + return self._current_agent_name() == "conta" + + async def wait_for_server_push(self) -> BackendReply | None: + if not self._uses_persistent_session(): + return None + + while True: + try: + reply = self._push_queue.get_nowait() + except asyncio.QueueEmpty: + reply = None + + if reply is not None: + self._last_stage = reply.stage or self._last_stage or self._default_stage + return BackendReply( + stage=self._last_stage, + text=reply.text, + done=bool(reply.done) or self._last_stage == "DONE", + export_payload=reply.result, + metadata=reply.metadata, + ) + + if self._reader_error is not None: + raise RuntimeError("Persistent websocket reader failed") from self._reader_error + + reader_task = self._reader_task + if reader_task is None or reader_task.done(): + return None + + get_task = asyncio.create_task(self._push_queue.get(), name="remote_agent_ws_push_wait") + try: + done, pending = await asyncio.wait( + {get_task, reader_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + except asyncio.CancelledError: + get_task.cancel() + await asyncio.gather(get_task, return_exceptions=True) + raise + + if get_task in done: + reply = get_task.result() + self._last_stage = reply.stage or self._last_stage or self._default_stage + return BackendReply( + stage=self._last_stage, + text=reply.text, + done=bool(reply.done) or self._last_stage == "DONE", + export_payload=reply.result, + metadata=reply.metadata, + ) + + get_task.cancel() + await asyncio.gather(get_task, return_exceptions=True) + + if self._reader_error is not None: + raise RuntimeError("Persistent websocket reader failed") from self._reader_error + + if reader_task.done() and self._push_queue.empty(): + return None + + def _base_payload(self) -> Dict[str, Any]: + payload = { + "timestamp": self._current_timestamp(), + "agent": self._current_agent_name(), + "RouterCallKeyDay": self._request_field("RouterCallKeyDay", "router_call_key_day", "routerCallKeyDay"), + "RouterCallKey": self._request_field("RouterCallKey", "router_call_key", "routerCallKey"), + "ANI": self._request_field("ANI", "ani"), + "GSM": self._request_field("GSM", "gsm", "NUM_TELEFONE"), + "callIdGed": self._request_field("callIdGed"), + } + + if payload["agent"] == "conta": + id_fatura = self._request_field("ID_FATURA", "id_fatura", "IdFatura") + if id_fatura: + payload["ID_FATURA"] = id_fatura + + return payload + + def _build_conta_turn_payload(self, user_input: Any) -> Dict[str, Any]: + payload = { + "message": self._extract_user_text(user_input), + "channel": self._current_channel(), + "msisdn": self._current_msisdn(), + } + message_id = self._message_id_from_mapping(user_input) + if message_id: + payload["message_id"] = message_id + + current_invoice_number = self._current_invoice_number() + if current_invoice_number: + payload["current_invoice_number"] = current_invoice_number + + self._add_pending_speech_interruption(payload) + if self._pending_events: + payload["events"] = list(self._pending_events) + + return { + "action": "chat", + "payload": payload, + } + + def _build_turn_payload(self, user_input: Any) -> Dict[str, Any]: + if self._current_agent_name() == "conta": + return self._build_conta_turn_payload(user_input) + + payload = self._base_payload() + payload["text"] = self._extract_user_text(user_input) + payload["protocol"] = self._protocol + payload["stage"] = self._last_stage + message_id = self._message_id_from_mapping(user_input) + if message_id: + payload["message_id"] = message_id + + self._add_pending_speech_interruption(payload) + if self._pending_events: + payload["events"] = list(self._pending_events) + + return payload + + def _build_end_payload(self) -> Dict[str, Any]: + if self._current_agent_name() == "conta": + payload = self._base_payload() + payload["protocol"] = self._protocol + payload["stage"] = self._last_stage + payload["msisdn"] = self._current_msisdn() + payload["channel"] = self._current_channel() + + current_invoice_number = self._current_invoice_number() + if current_invoice_number: + payload["current_invoice_number"] = current_invoice_number + + self._add_pending_speech_interruption(payload) + if self._pending_events: + payload["events"] = list(self._pending_events) + + return { + "action": "end", + "payload": payload, + } + + payload = self._base_payload() + payload["type"] = "end" + payload["protocol"] = self._protocol + payload["stage"] = self._last_stage + + self._add_pending_speech_interruption(payload) + if self._pending_events: + payload["events"] = list(self._pending_events) + + return payload + + def _clear_pending_state(self) -> None: + self._pending_interrupt = None + self._pending_events.clear() + + def _extract_stage(self, payload: Mapping[str, Any]) -> str: + stage = payload.get("stage") + if isinstance(stage, str) and stage.strip(): + return stage.strip().upper() + return "" + + def _extract_delta(self, payload: Mapping[str, Any]) -> str: + for key in ("delta", "chunk", "token"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value + return "" + + def _extract_reply_text(self, payload: Mapping[str, Any]) -> str: + message_type = str(payload.get("type") or "").strip().lower() + if message_type == "final": + text = self._extract_text_from_value(payload.get("content")) + if text: + return text + + for key in ("result", *_TEXT_KEYS): + value = payload.get(key) + text = self._extract_text_from_value(value) + if text: + return text + return "" + + @staticmethod + def _bool_or_none(value: Any) -> bool | None: + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + return None + + def _reply_metadata(self, payload: Mapping[str, Any], result: Any = None) -> Dict[str, Any] | None: + raw_metadata = payload.get("metadata") + metadata: Dict[str, Any] = dict(raw_metadata) if isinstance(raw_metadata, Mapping) else {} + + sources: list[Mapping[str, Any]] = [] + if isinstance(result, Mapping): + sources.append(result) + sources.append(payload) + + for source in sources: + message_id = self._message_id_from_mapping(source) + if message_id: + metadata["message_id"] = message_id + break + + for source in sources: + speech_id = self._string_or_empty(source.get("speech_id")) + if speech_id: + metadata["speech_id"] = speech_id + break + + for source in sources: + interruptible = self._bool_or_none(source.get("is_interruptible")) + if interruptible is not None: + metadata["is_interruptible"] = interruptible + break + + if self._current_agent_name() == "conta": + message_type = str(payload.get("type") or "").strip().lower() + result_type = "" + if isinstance(result, Mapping): + result_type = str(result.get("type") or "").strip().lower() + + expects_user_response = ( + result_type == "final" + or (not result_type and message_type in _CONTA_USER_RESPONSE_MESSAGE_TYPES) + ) + terminal_result = final_stop_from_agent_result(result) is not None + protected_speech = ( + message_type in {"ready", "feedback"} + or terminal_result + or (bool(result_type) and result_type != "final") + ) + + if message_type: + metadata["agent_message_type"] = message_type + if result_type: + metadata["agent_result_type"] = result_type + metadata["expects_user_response"] = expects_user_response + metadata["drop_user_input_while_speaking"] = protected_speech + if protected_speech: + metadata["is_interruptible"] = False + + return metadata or None + + def _is_conta_feedback(self, message_type: str) -> bool: + return self._current_agent_name() == "conta" and message_type == "feedback" + + def _is_conta_result_feedback(self, result: Any) -> bool: + return ( + self._current_agent_name() == "conta" + and isinstance(result, Mapping) + and str(result.get("type") or "").strip().lower() == "feedback" + ) + + def _feedback_metadata( + self, + payload: Mapping[str, Any], + base_metadata: Any = None, + ) -> Dict[str, Any]: + metadata: Dict[str, Any] = {} + if isinstance(base_metadata, Mapping): + metadata.update(base_metadata) + metadata["event"] = "feedback" + metadata["payload"] = dict(payload) + return metadata + + def _queue_feedback_reply( + self, + *, + payload: Mapping[str, Any], + stage: str, + text: str, + metadata: Any = None, + ) -> None: + feedback_text = (text or "").strip() + if not feedback_text: + return + + if self._current_agent_name() == "conta": + metadata = self._metadata_with_message_id( + metadata, + self._active_request_message_id + or self._connection_message_id + or self._request_field("message_id", "messageId"), + ) + reply = _RemoteAgentReply( + stage=stage or self._fallback_stage(), + text=feedback_text, + done=False, + result=None, + metadata=self._feedback_metadata(payload, metadata), + ) + self._push_queue.put_nowait(reply) + + if self._timeline is not None: + self._timeline.emit( + "remote_agent_response", + backend=self._backend_label, + backend_family="remote_ws", + agent=self._current_agent_name(), + message_type="feedback", + stage=reply.stage, + done=False, + text_len=len(reply.text), + has_result=False, + ) + + def _add_pending_speech_interruption(self, payload: Dict[str, Any]) -> None: + if self._pending_interrupt is not None: + interruption = dict(self._pending_interrupt) + interruption_key = str( + interruption.pop("_interruption_field", "speech_interruption") + ) + payload[interruption_key] = interruption + + def _fallback_stage(self) -> str: + if self._last_stage and self._last_stage != "INTRO": + return self._last_stage + return self._default_stage + + async def _recv_reply(self, websocket: Any, timeout_s: float | None = None) -> _RemoteAgentReply: + chunks: List[str] = [] + final_text = "" + final_stage = "" + done = False + result: Any = None + metadata: Any = None + saw_message = False + + while True: + try: + recv_coro = websocket.recv() + if timeout_s is None: + raw_message = await recv_coro + else: + raw_message = await asyncio.wait_for(recv_coro, timeout=timeout_s) + except asyncio.TimeoutError: + if saw_message or chunks or final_text or result is not None: + break + raise + except asyncio.CancelledError: + raise + except Exception: + if saw_message or chunks or final_text or result is not None: + break + raise + + saw_message = True + message = self._decode_message(raw_message) + + if isinstance(message, str): + final_text = message + break + + if not isinstance(message, Mapping): + continue + + self._update_session_actions(message) + + error = message.get("error") + if error: + raise RuntimeError(str(error)) + + message_type = str(message.get("type") or "").strip().lower() + stage = self._extract_stage(message) + if stage and not self._is_conta_feedback(message_type): + final_stage = stage + + previous_result = result + if "result" in message: + result = message.get("result") + message_metadata = self._reply_metadata(message, result) + + delta = self._extract_delta(message) + if delta: + chunks.append(delta) + if self._timeline is not None: + self._timeline.emit( + "remote_agent_stream_chunk", + agent=self._current_agent_name(), + chunk_len=len(delta), + ) + + reply_text = self._extract_reply_text(message) + + if self._is_conta_feedback(message_type) or self._is_conta_result_feedback(result): + self._queue_feedback_reply( + payload=message, + stage=stage or final_stage or self._fallback_stage(), + text=reply_text, + metadata=message_metadata, + ) + result = previous_result + continue + + if message_metadata is not None: + metadata = message_metadata + + if reply_text: + final_text = reply_text + + if final_stop_from_agent_result(result) is not None: + done = True + if message_type == "done": + done = True + if done or bool(message.get("done")) or final_stage == "DONE": + done = True + break + + if message_type in _STREAM_TYPES: + continue + + if bool(message.get("final")) or message_type in _TERMINAL_TYPES or reply_text: + break + + text = (final_text or "".join(chunks)).strip() + stage = final_stage or self._fallback_stage() + if done: + stage = "DONE" + + if not text and result is None: + raise RuntimeError("Remote agent websocket closed without a usable response") + + if self._timeline is not None: + self._timeline.emit( + "remote_agent_response", + backend=self._backend_label, + backend_family="remote_ws", + agent=self._current_agent_name(), + stage=stage, + done=done, + text_len=len(text), + has_result=result is not None, + ) + self._log_ws_event( + "RESPONSE", + url=self._connection_url(), + host=self._url_host(self._connection_url()), + stage=stage, + protocol=self._protocol or "-", + text_len=len(text), + text_preview=text, + done=int(done), + result_type=type(result).__name__ if result is not None else "-", + ) + + reply = _RemoteAgentReply( + stage=stage, + text=text, + done=done, + result=result, + metadata=metadata, + ) + if self._current_agent_name() == "conta": + self._reply_with_message_id( + reply, + self._active_request_message_id + or self._connection_message_id + or self._request_field("message_id", "messageId"), + ) + return reply + + async def _request(self, payload: Dict[str, Any]) -> _RemoteAgentReply: + connect = self._import_websockets() + url = self._connection_url() + self._log_ws_event( + "CONNECT_OPEN", + url=url, + host=self._url_host(url), + protocol=self._protocol or "-", + ) + request_message_id = self._message_id_from_mapping(payload) + previous_request_message_id = self._active_request_message_id + self._active_request_message_id = request_message_id + try: + async with connect(url, **self._connection_kwargs()) as websocket: + self._log_ws_event( + "CONNECT_OK", + url=url, + host=self._url_host(url), + protocol=self._protocol or "-", + ) + self._emit_request(url=url, payload=payload) + request_started = time.perf_counter() + try: + await asyncio.wait_for( + websocket.send(self._serialize_message(payload)), + timeout=self._write_timeout_s, + ) + except Exception as exc: + self._log_ws_failure( + "SEND_FAIL", + exc, + url=url, + host=self._url_host(url), + **self._payload_metadata(payload), + ) + raise + reply = await self._recv_reply(websocket, timeout_s=self._read_timeout_s) + if request_message_id: + self._reply_with_message_id(reply, request_message_id) + done_fields = { + **self._payload_metadata(payload), + "duration_ms": round((time.perf_counter() - request_started) * 1000), + "stage": reply.stage, + "text_len": len(reply.text), + "text_preview": reply.text, + "done": int(reply.done), + "result_type": type(reply.result).__name__ if reply.result is not None else "-", + } + self._log_ws_event( + "TURN_DONE", + url=url, + host=self._url_host(url), + **done_fields, + ) + self._active_request_message_id = previous_request_message_id + return reply + except asyncio.TimeoutError: + self._active_request_message_id = previous_request_message_id + self._log_ws_event( + "REQUEST_TIMEOUT", + url=url, + host=self._url_host(url), + **self._payload_metadata(payload), + ) + raise + except Exception as exc: + self._active_request_message_id = previous_request_message_id + self._log_ws_failure( + "REQUEST_FAIL", + exc, + url=url, + host=self._url_host(url), + **self._payload_metadata(payload), + ) + raise + + async def prepare( + self, + elegibility: bool, + protocol: str, + ) -> None: + if self._timeline is not None: + self._timeline.emit( + "backend_prepare_started", + backend=self._backend_label, + backend_family="remote_ws", + agent=self._current_agent_name(), + ) + self._elegibility = bool(elegibility) + self._protocol = str(protocol or "") + self._last_stage = "INTRO" + self._pending_interrupt = None + self._pending_events.clear() + self._pending_ready_reply = None + if self._uses_persistent_session(): + async with self._session_lock: + await self._close_persistent_session_locked() + await self._open_persistent_session_locked(capture_ready=True) + if self._timeline is not None: + self._timeline.emit( + "backend_prepare_completed", + backend=self._backend_label, + backend_family="remote_ws", + agent=self._current_agent_name(), + protocol=self._protocol, + ) + + async def run(self, user_input: Any) -> BackendReply: + sent_payload = True + if self._uses_persistent_session(): + reply, sent_payload = await self._run_with_persistent_session(user_input) + else: + reply = await self._request(self._build_turn_payload(user_input)) + + if sent_payload: + self._clear_pending_state() + self._last_stage = reply.stage or self._last_stage or self._default_stage + return BackendReply( + stage=self._last_stage, + text=reply.text, + done=bool(reply.done) or self._last_stage == "DONE", + export_payload=reply.result, + metadata=reply.metadata, + ) + + async def set_interruption( + self, + interrupted: bool, + listened_text: str = "", + skipped: bool = False, + speech_id: str = "", + ) -> None: + if not interrupted: + self._pending_interrupt = None + if self._timeline is not None: + self._timeline.emit( + "backend_set_interruption", + backend=self._backend_label, + backend_family="remote_ws", + agent=self._current_agent_name(), + interrupted=False, + ) + return + + speech_id = self._string_or_empty(speech_id) + if not speech_id: + self._pending_interrupt = None + if self._timeline is not None: + self._timeline.emit( + "backend_set_interruption", + backend=self._backend_label, + backend_family="remote_ws", + agent=self._current_agent_name(), + interrupted=True, + skipped=True, + listened_text=listened_text, + speech_id="", + reason="missing_speech_id", + ) + return + self._pending_interrupt = { + "speech_id": speech_id, + "heard_text": listened_text or "", + } + if self._timeline is not None: + self._timeline.emit( + "backend_set_interruption", + backend=self._backend_label, + backend_family="remote_ws", + agent=self._current_agent_name(), + interrupted=True, + skipped=bool(skipped), + listened_text=listened_text, + speech_id=speech_id, + ) + + async def set_processing_interruption( + self, + listened_text: str = "", + skipped: bool = False, + speech_id: str = "", + ) -> None: + await self.set_interruption( + True, + listened_text=listened_text, + skipped=skipped, + speech_id=speech_id, + ) + if self._pending_interrupt is not None: + self._pending_interrupt["_interruption_field"] = "processing_interruption" + + async def inject_idle_nudge(self, nudge_text: str) -> None: + text = (nudge_text or "").strip() + if not text: + return + + event = { + "type": "idle_nudge", + "text": text, + } + # Cada frase de inatividade substitui a anterior: o cliente responde ao + # que ouviu por ultimo, e as intermediarias so empilham falas do agente + # no historico remoto -- inclusive o aviso de encerramento, que passa a + # constar como dito logo antes de a conversa seguir normalmente. + if self._pending_events and self._pending_events[-1].get("type") == "idle_nudge": + self._pending_events[-1] = event + else: + self._pending_events.append(event) + if self._timeline is not None: + self._timeline.emit( + "backend_idle_nudge_buffered", + backend=self._backend_label, + backend_family="remote_ws", + agent=self._current_agent_name(), + text=text, + ) + + async def end_service_once(self) -> BackendReply: + async with self._end_lock: + if self._ended: + return self._end_reply or BackendReply(stage="DONE", done=True, export_payload=[]) + + self._ended = True + if self._timeline is not None: + self._timeline.emit( + "backend_end_started", + backend=self._backend_label, + backend_family="remote_ws", + agent=self._current_agent_name(), + ) + try: + if self._uses_persistent_session(): + async with self._session_lock: + await self._open_persistent_session_locked( + capture_ready=self._session_websocket is None + ) + self._pending_ready_reply = None + if self._reader_task is not None and self._reader_task.done(): + if self._reader_error is not None: + raise RuntimeError("Persistent websocket reader failed") from self._reader_error + reply = self._terminal_reply or _RemoteAgentReply( + stage="DONE", + text="", + done=True, + result=[], + ) + elif not self._persistent_session_supports_action("end"): + reply = self._terminal_reply or _RemoteAgentReply( + stage="DONE", + text="", + done=True, + result=[], + ) + else: + try: + reply = await self._send_persistent_payload_locked(self._build_end_payload()) + except RuntimeError as exc: + if not self._is_unsupported_action_error(exc, "end"): + raise + reply = self._terminal_reply or _RemoteAgentReply( + stage="DONE", + text="", + done=True, + result=[], + ) + await self._close_persistent_session_locked() + else: + reply = await self._request(self._build_end_payload()) + except Exception: + logger.exception("[remote-agent] end_service websocket falhou") + if self._uses_persistent_session(): + async with self._session_lock: + await self._close_persistent_session_locked() + if self._timeline is not None: + self._timeline.emit( + "backend_end_failed", + backend=self._backend_label, + backend_family="remote_ws", + agent=self._current_agent_name(), + ) + self._end_reply = BackendReply(stage="DONE", done=True, export_payload=[]) + return self._end_reply + + self._clear_pending_state() + stage = (reply.stage or self._last_stage or self._default_stage).upper() + self._last_stage = stage + self._end_reply = BackendReply( + stage=stage, + text=reply.text, + done=bool(reply.done) or stage == "DONE", + export_payload=reply.result if reply.result is not None else [], + metadata=reply.metadata, + ) + if self._timeline is not None: + self._timeline.emit( + "backend_end_completed", + backend=self._backend_label, + backend_family="remote_ws", + agent=self._current_agent_name(), + result_type=type(self._end_reply.export_payload).__name__, + ) + return self._end_reply diff --git a/src/app/livekit/adapters/speech_service.py b/src/app/livekit/adapters/speech_service.py new file mode 100644 index 0000000..78c742d --- /dev/null +++ b/src/app/livekit/adapters/speech_service.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import inspect +import re +from typing import Any + + +def normalize_text(text: str) -> str: + text = re.sub(r"\s+", " ", (text or "")).strip() + text = re.sub(r"\s+([,.;:!?…])", r"\1", text) + return text.strip() + + +def _extract_text_from_content(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + else: + text = getattr(item, "text", None) + if isinstance(text, str): + parts.append(text) + else: + rendered = str(item) + if rendered and rendered != "None": + parts.append(rendered) + return "".join(parts).strip() + text = getattr(content, "text", None) + if isinstance(text, str): + return text + return str(content).strip() + + +def extract_spoken_from_speech_handle(handle: Any) -> str: + items = getattr(handle, "chat_items", None) + if not isinstance(items, list) or not items: + return "" + + for item in reversed(items): + role = getattr(item, "role", None) + if role is None or str(role).lower() == "assistant": + content = getattr(item, "content", None) + spoken = normalize_text(_extract_text_from_content(content)) + if spoken: + return spoken + + item = items[-1] + return normalize_text(_extract_text_from_content(getattr(item, "content", None))) + + +async def _maybe_await(value: Any) -> Any: + if inspect.isawaitable(value): + return await value + return value + + +async def _maybe_await_call(fn, *args, **kwargs) -> Any: + if fn is None: + return None + try: + value = fn(*args, **kwargs) + except TypeError: + value = fn(*args) + return await _maybe_await(value) + + +class SpeechService: + def __init__(self, session: Any) -> None: + self._session = session + + async def start( + self, + text: str, + *, + allow_interruptions: bool, + add_to_chat_ctx: bool = True, + audio: Any = None, + ) -> Any: + kwargs = { + "allow_interruptions": allow_interruptions, + "add_to_chat_ctx": add_to_chat_ctx, + } + if audio is not None: + kwargs["audio"] = audio + value = self._session.say(text, **kwargs) + if inspect.isawaitable(value) and not callable(getattr(value, "wait_for_playout", None)): + return await value + return value + + async def wait_for_playout(self, handle: Any) -> None: + wait_for_playout = getattr(handle, "wait_for_playout", None) + if callable(wait_for_playout): + await _maybe_await_call(wait_for_playout) + + async def interrupt(self, handle: Any, *, force: bool = False) -> None: + interrupt = getattr(handle, "interrupt", None) + if callable(interrupt): + await _maybe_await_call(interrupt, force=force) + return + + session_interrupt = getattr(self._session, "interrupt", None) + if callable(session_interrupt): + await _maybe_await_call(session_interrupt, force=force) + + def extract_spoken_text(self, handle: Any) -> str: + return extract_spoken_from_speech_handle(handle) diff --git a/src/app/livekit/adapters/xai_pool_proxy.py b/src/app/livekit/adapters/xai_pool_proxy.py new file mode 100644 index 0000000..66cac72 --- /dev/null +++ b/src/app/livekit/adapters/xai_pool_proxy.py @@ -0,0 +1,579 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import os +import random +import time +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlencode + +import aiohttp +from aiohttp import WSMsgType, web + +from app.livekit.adapters.xai_tts import ( + AUTH_METHOD_API_KEY, + AUTH_METHOD_CONFIG_FILE, + DEFAULT_LANGUAGE, + DEFAULT_VOICE, + OPTIMIZE_STREAMING_LATENCY, + SAMPLE_RATE, + TEXT_NORMALIZATION, + _AuthOptions, + _request_headers, + _resolve_auth_method, + _validate_config_file_auth, +) + +logger = logging.getLogger("xai_pool_proxy") + + +def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default))) + except (TypeError, ValueError): + return default + + +def _env_float(name: str, default: float) -> float: + try: + return float(os.getenv(name, str(default))) + except (TypeError, ValueError): + return default + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _auth_from_env() -> _AuthOptions: + method = _resolve_auth_method(os.getenv("XAI_TTS_AUTH_METHOD", AUTH_METHOD_API_KEY)) + api_key = os.getenv("XAI_API_KEY") + compartment_id = os.getenv("OCI_COMPARTMENT_ID") + config_file = os.getenv("OCI_CONFIG_FILE", "~/.oci/config") + profile = os.getenv("OCI_CONFIG_PROFILE", "DEFAULT") + + if method == AUTH_METHOD_API_KEY: + if not api_key: + raise RuntimeError("XAI_API_KEY is required for xAI pool API_KEY authentication") + return _AuthOptions(method=method, api_key=api_key) + + if not compartment_id: + raise RuntimeError("OCI_COMPARTMENT_ID is required for IAM xAI pool authentication") + if method == AUTH_METHOD_CONFIG_FILE: + _validate_config_file_auth(config_file, profile) + return _AuthOptions( + method=method, + compartment_id=compartment_id, + oci_config_file=config_file if method == AUTH_METHOD_CONFIG_FILE else None, + oci_profile=profile if method == AUTH_METHOD_CONFIG_FILE else None, + ) + + +@dataclass(frozen=True) +class PoolConfig: + region: str + upstream_url: str + voice: str + language: str + size: int + unavailable_free_threshold: int + recover_free_threshold: int + connect_timeout_s: float + connection_ttl_s: float + refresh_jitter_s: float + maintenance_interval_s: float + acquire_timeout_s: float + + @classmethod + def from_env(cls) -> "PoolConfig": + size = max(1, _env_int("XAI_POOL_SIZE", 50)) + unavailable = max(0, _env_int("XAI_POOL_UNAVAILABLE_FREE", 2)) + recover = max(unavailable + 1, _env_int("XAI_POOL_RECOVER_FREE", 5)) + recover = min(size, recover) + return cls( + region=(os.getenv("TIA_XAI_REGION") or "unknown").strip(), + upstream_url=(os.getenv("XAI_POOL_UPSTREAM_URL") or os.getenv("XAI_UPSTREAM_WEBSOCKET_URL") or "").strip(), + voice=(os.getenv("XAI_TTS_VOICE") or DEFAULT_VOICE).strip() or DEFAULT_VOICE, + language=(os.getenv("XAI_TTS_LANGUAGE") or DEFAULT_LANGUAGE).strip() or DEFAULT_LANGUAGE, + size=size, + unavailable_free_threshold=min(size, unavailable), + recover_free_threshold=recover, + connect_timeout_s=max(0.1, _env_float("XAI_POOL_CONNECT_TIMEOUT_S", 3.0)), + connection_ttl_s=max(30.0, _env_float("XAI_POOL_CONNECTION_TTL_S", 540.0)), + refresh_jitter_s=max(0.0, _env_float("XAI_POOL_REFRESH_JITTER_S", 45.0)), + maintenance_interval_s=max(0.5, _env_float("XAI_POOL_MAINTENANCE_INTERVAL_S", 2.0)), + acquire_timeout_s=max(0.1, _env_float("XAI_POOL_ACQUIRE_TIMEOUT_S", 2.0)), + ) + + def upstream_ws_url(self) -> str: + params = { + "voice": self.voice, + "language": self.language, + "codec": "pcm", + "sample_rate": SAMPLE_RATE, + "optimize_streaming_latency": OPTIMIZE_STREAMING_LATENCY, + "text_normalization": str(TEXT_NORMALIZATION).lower(), + } + return f"{self.upstream_url}?{urlencode(params)}" + + +class PoolUnavailable(RuntimeError): + pass + + +class UpstreamSlot: + def __init__(self, slot_id: int, *, config: PoolConfig, session: aiohttp.ClientSession, auth: _AuthOptions) -> None: + self.slot_id = slot_id + self.config = config + self.session = session + self.auth = auth + self.ws: aiohttp.ClientWebSocketResponse | None = None + self.lock = asyncio.Lock() + self.opened_at = 0.0 + self.last_used_at = 0.0 + self.refresh_deadline = 0.0 + self.connect_failures = 0 + + @property + def leased(self) -> bool: + return self.lock.locked() + + @property + def healthy(self) -> bool: + ws = self.ws + return bool(ws is not None and not ws.closed and ws.exception() is None) + + @property + def needs_refresh(self) -> bool: + return bool(self.healthy and not self.leased and self.refresh_deadline > 0 and time.monotonic() >= self.refresh_deadline) + + async def connect(self) -> None: + if self.healthy: + return + await self.close() + url = self.config.upstream_ws_url() + started = time.perf_counter() + try: + self.ws = await asyncio.wait_for( + self.session.ws_connect( + url, + headers=_request_headers(self.auth, url), + heartbeat=None, + autoclose=True, + ), + timeout=self.config.connect_timeout_s, + ) + except Exception: + self.connect_failures += 1 + raise + self.opened_at = time.monotonic() + self.last_used_at = self.opened_at + jitter = random.uniform(0.0, min(self.config.refresh_jitter_s, max(0.0, self.config.connection_ttl_s - 1.0))) + self.refresh_deadline = self.opened_at + self.config.connection_ttl_s - jitter + logger.info( + "XAI_POOL_SLOT_OPENED slot=%s region=%s connect_ms=%s refresh_in_s=%.1f", + self.slot_id, + self.config.region, + round((time.perf_counter() - started) * 1000), + max(0.0, self.refresh_deadline - time.monotonic()), + ) + + async def close(self) -> None: + ws, self.ws = self.ws, None + if ws is not None and not ws.closed: + try: + await ws.close() + except Exception: + logger.debug("failed closing xAI pool slot=%s", self.slot_id, exc_info=True) + + async def refresh(self) -> None: + if self.leased: + return + await self.close() + await self.connect() + + +class RegionalXAIPool: + def __init__(self, config: PoolConfig) -> None: + if not config.upstream_url: + raise RuntimeError("XAI_POOL_UPSTREAM_URL is required") + self.config = config + self.auth = _auth_from_env() + self.session: aiohttp.ClientSession | None = None + self.slots: list[UpstreamSlot] = [] + self._condition = asyncio.Condition() + self._maintenance_task: asyncio.Task[None] | None = None + self._draining = False + self._ready = False + self._started = False + self.total_acquires = 0 + self.total_acquire_timeouts = 0 + self.total_proxy_failures = 0 + + async def start(self) -> None: + if self._started: + return + connector = aiohttp.TCPConnector(limit=max(self.config.size * 2, 100), ttl_dns_cache=300) + self.session = aiohttp.ClientSession(connector=connector) + self.slots = [UpstreamSlot(i + 1, config=self.config, session=self.session, auth=self.auth) for i in range(self.config.size)] + # Prewarm in bounded waves so startup does not create one handshake burst. + concurrency = max(1, min(self.config.size, _env_int("XAI_POOL_PREWARM_CONCURRENCY", 5))) + sem = asyncio.Semaphore(concurrency) + + async def open_slot(slot: UpstreamSlot) -> None: + async with sem: + try: + await slot.connect() + except Exception as exc: + logger.warning("XAI_POOL_PREWARM_FAILED slot=%s error=%s", slot.slot_id, type(exc).__name__) + await asyncio.sleep(max(0.0, _env_float("XAI_POOL_PREWARM_STAGGER_S", 0.05))) + + await asyncio.gather(*(open_slot(slot) for slot in self.slots)) + self._started = True + self._recompute_ready() + self._maintenance_task = asyncio.create_task(self._maintenance_loop(), name="xai-pool-maintenance") + logger.info("XAI_POOL_STARTED region=%s size=%s healthy=%s ready=%s", self.config.region, self.config.size, self.healthy_count, self._ready) + + async def stop(self) -> None: + self._draining = True + self._ready = False + if self._maintenance_task is not None: + self._maintenance_task.cancel() + try: + await self._maintenance_task + except asyncio.CancelledError: + pass + self._maintenance_task = None + for slot in self.slots: + await slot.close() + if self.session is not None: + await self.session.close() + self.session = None + self._started = False + + @property + def healthy_count(self) -> int: + return sum(1 for slot in self.slots if slot.healthy) + + @property + def leased_count(self) -> int: + return sum(1 for slot in self.slots if slot.leased) + + @property + def free_healthy_count(self) -> int: + return sum(1 for slot in self.slots if slot.healthy and not slot.leased) + + @property + def ready(self) -> bool: + self._recompute_ready() + return self._ready + + def _recompute_ready(self) -> None: + if self._draining or not self._started: + self._ready = False + return + free = self.free_healthy_count + if self._ready: + if free <= self.config.unavailable_free_threshold: + self._ready = False + else: + if free >= self.config.recover_free_threshold: + self._ready = True + + async def set_draining(self, draining: bool = True) -> None: + self._draining = draining + self._recompute_ready() + async with self._condition: + self._condition.notify_all() + + async def acquire(self) -> UpstreamSlot: + deadline = time.monotonic() + self.config.acquire_timeout_s + while True: + if self._draining: + raise PoolUnavailable("pool is draining") + for slot in self.slots: + if not slot.healthy or slot.leased: + continue + if not slot.lock.locked(): + await slot.lock.acquire() + if not slot.healthy: + slot.lock.release() + continue + self.total_acquires += 1 + self._recompute_ready() + return slot + remaining = deadline - time.monotonic() + if remaining <= 0: + self.total_acquire_timeouts += 1 + self._recompute_ready() + raise PoolUnavailable("no free healthy xAI connection") + async with self._condition: + try: + await asyncio.wait_for(self._condition.wait(), timeout=min(remaining, 0.25)) + except asyncio.TimeoutError: + pass + + async def release(self, slot: UpstreamSlot, *, healthy: bool = True) -> None: + slot.last_used_at = time.monotonic() + if not healthy: + await slot.close() + if slot.lock.locked(): + slot.lock.release() + self._recompute_ready() + async with self._condition: + self._condition.notify_all() + + async def _maintenance_loop(self) -> None: + while True: + await asyncio.sleep(self.config.maintenance_interval_s) + for slot in self.slots: + if slot.leased: + continue + try: + if slot.needs_refresh or not slot.healthy: + await slot.refresh() + except Exception as exc: + logger.warning("XAI_POOL_SLOT_RECOVERY_FAILED slot=%s error=%s", slot.slot_id, type(exc).__name__) + self._recompute_ready() + async with self._condition: + self._condition.notify_all() + + def status(self) -> dict[str, Any]: + self._recompute_ready() + return { + "status": "ready" if self._ready else "not_ready", + "region": self.config.region, + "draining": self._draining, + "configured": self.config.size, + "healthy": self.healthy_count, + "leased": self.leased_count, + "free": self.free_healthy_count, + "unavailable_free_threshold": self.config.unavailable_free_threshold, + "recover_free_threshold": self.config.recover_free_threshold, + "total_acquires": self.total_acquires, + "total_acquire_timeouts": self.total_acquire_timeouts, + "total_proxy_failures": self.total_proxy_failures, + } + + +POOL: RegionalXAIPool | None = None + + +def _client_query_matches(request: web.Request, config: PoolConfig) -> bool: + # The pool is prewarmed for one voice/language/sample-rate profile. Explicit + # mismatch is rejected instead of silently synthesizing with the wrong voice. + expected = { + "voice": config.voice, + "language": config.language, + "codec": "pcm", + "sample_rate": str(SAMPLE_RATE), + } + for key, value in expected.items(): + incoming = request.query.get(key) + if incoming is not None and incoming != value: + return False + return True + + +async def _relay_upstream_until_boundary(client: web.WebSocketResponse, slot: UpstreamSlot) -> bool: + """Relay one provider response boundary. Return True only after audio.done.""" + ws = slot.ws + if ws is None: + return False + while True: + msg = await ws.receive() + if msg.type == WSMsgType.TEXT: + await client.send_str(msg.data) + try: + payload = json.loads(msg.data) + except Exception: + payload = {} + if payload.get("type") == "audio.done": + return True + if payload.get("type") in {"error", "response.error"}: + return False + elif msg.type == WSMsgType.BINARY: + await client.send_bytes(msg.data) + elif msg.type in {WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.ERROR}: + return False + + +async def websocket_proxy(request: web.Request) -> web.StreamResponse: + pool = POOL + if pool is None: + raise web.HTTPServiceUnavailable(text="pool not initialized") + if not _client_query_matches(request, pool.config): + raise web.HTTPBadRequest(text="voice/language/codec/sample_rate differs from prewarmed pool profile") + + client = web.WebSocketResponse(heartbeat=20.0, max_msg_size=8 * 1024 * 1024) + await client.prepare(request) + leased_slot: UpstreamSlot | None = None + slot_healthy = True + try: + async for msg in client: + if msg.type != WSMsgType.TEXT: + if msg.type in {WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.ERROR}: + break + continue + try: + payload = json.loads(msg.data) + except Exception: + await client.send_str(json.dumps({"type": "error", "message": "invalid json"})) + continue + msg_type = str(payload.get("type") or "") + + if msg_type == "text.clear": + if leased_slot is not None: + await pool.release(leased_slot, healthy=slot_healthy) + leased_slot = None + try: + leased_slot = await pool.acquire() + slot_healthy = True + except PoolUnavailable as exc: + await client.send_str(json.dumps({"type": "error", "message": str(exc), "code": "xai_pool_exhausted"})) + await client.close(code=1013, message=b"xAI pool exhausted") + break + assert leased_slot.ws is not None + try: + await leased_slot.ws.send_str(msg.data) + # text.clear has its own acknowledgement and must be forwarded + # before the client sends text.delta/text.done. + while True: + ack = await leased_slot.ws.receive() + if ack.type != WSMsgType.TEXT: + slot_healthy = False + raise RuntimeError("xAI clear acknowledgement failed") + await client.send_str(ack.data) + try: + ack_payload = json.loads(ack.data) + except Exception: + ack_payload = {} + ack_type = str(ack_payload.get("type") or "") + if ack_type == "audio.clear": + break + if ack_type in {"error", "response.error"}: + slot_healthy = False + raise RuntimeError("xAI clear returned error") + except Exception: + slot_healthy = False + pool.total_proxy_failures += 1 + await client.close(code=1011, message=b"xAI upstream clear failed") + break + continue + + if leased_slot is None: + await client.send_str(json.dumps({"type": "error", "message": "text.clear required before synthesis"})) + continue + + assert leased_slot.ws is not None + try: + await leased_slot.ws.send_str(msg.data) + if msg_type == "text.done": + slot_healthy = await _relay_upstream_until_boundary(client, leased_slot) + await pool.release(leased_slot, healthy=slot_healthy) + leased_slot = None + if not slot_healthy: + pool.total_proxy_failures += 1 + await client.close(code=1011, message=b"xAI upstream failed") + break + except Exception: + slot_healthy = False + pool.total_proxy_failures += 1 + await client.close(code=1011, message=b"xAI upstream failure") + break + finally: + if leased_slot is not None: + await pool.release(leased_slot, healthy=False) + return client + + +async def healthz(_: web.Request) -> web.Response: + pool = POOL + if pool is None or not pool._started: + return web.json_response({"status": "starting"}, status=503) + return web.json_response({"status": "ok", "pool": pool.status()}) + + +async def readyz(_: web.Request) -> web.Response: + pool = POOL + if pool is None: + return web.json_response({"status": "not_ready", "reason": "not_initialized"}, status=503) + payload = pool.status() + return web.json_response(payload, status=200 if pool.ready else 503) + + +async def status(_: web.Request) -> web.Response: + pool = POOL + return web.json_response(pool.status() if pool is not None else {"status": "not_initialized"}) + + +async def drain(_: web.Request) -> web.Response: + pool = POOL + if pool is not None: + await pool.set_draining(True) + return web.json_response({"status": "draining"}) + + +async def metrics(_: web.Request) -> web.Response: + pool = POOL + values = pool.status() if pool is not None else {} + region = str(values.get("region", "unknown")).replace('"', "") + lines = [ + "# TYPE tia_xai_pool_connections gauge", + f'tia_xai_pool_connections{{region="{region}",state="healthy"}} {values.get("healthy", 0)}', + f'tia_xai_pool_connections{{region="{region}",state="leased"}} {values.get("leased", 0)}', + f'tia_xai_pool_connections{{region="{region}",state="free"}} {values.get("free", 0)}', + "# TYPE tia_xai_pool_acquires_total counter", + f'tia_xai_pool_acquires_total{{region="{region}"}} {values.get("total_acquires", 0)}', + "# TYPE tia_xai_pool_acquire_timeouts_total counter", + f'tia_xai_pool_acquire_timeouts_total{{region="{region}"}} {values.get("total_acquire_timeouts", 0)}', + "# TYPE tia_xai_pool_proxy_failures_total counter", + f'tia_xai_pool_proxy_failures_total{{region="{region}"}} {values.get("total_proxy_failures", 0)}', + ] + return web.Response(text="\n".join(lines) + "\n", content_type="text/plain") + + +async def on_startup(app: web.Application) -> None: + global POOL + POOL = RegionalXAIPool(PoolConfig.from_env()) + await POOL.start() + + +async def on_cleanup(app: web.Application) -> None: + global POOL + if POOL is not None: + await POOL.stop() + POOL = None + + +def create_app() -> web.Application: + app = web.Application() + app.router.add_get("/xai/v1/tts", websocket_proxy) + app.router.add_get("/healthz", healthz) + app.router.add_get("/readyz", readyz) + app.router.add_get("/pool/status", status) + app.router.add_post("/drain", drain) + app.router.add_get("/metrics", metrics) + app.on_startup.append(on_startup) + app.on_cleanup.append(on_cleanup) + return app + + +def main() -> None: + logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO")) + web.run_app( + create_app(), + host=os.getenv("XAI_POOL_BIND_HOST", "0.0.0.0"), + port=_env_int("XAI_POOL_PORT", 18100), + access_log=logger if _env_bool("XAI_POOL_ACCESS_LOG", False) else None, + ) + + +if __name__ == "__main__": + main() diff --git a/src/app/livekit/adapters/xai_tts.py b/src/app/livekit/adapters/xai_tts.py new file mode 100644 index 0000000..85c9ad9 --- /dev/null +++ b/src/app/livekit/adapters/xai_tts.py @@ -0,0 +1,1519 @@ +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +import os +import time +import unicodedata +import wave +import weakref +from collections.abc import AsyncIterable +from dataclasses import dataclass, replace +from typing import Literal, cast +from urllib.parse import urlencode + +import aiohttp +import oci +import requests +from livekit.agents import ( + APIConnectionError, + APIConnectOptions, + APIStatusError, + APITimeoutError, + tts, + utils, +) +from livekit.agents.metrics import TTSMetrics +from livekit.agents.metrics.base import Metadata +from livekit.agents.telemetry import trace_types +from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, NotGivenOr +from livekit.agents.utils import is_given + +from app.livekit.adapters.audio_gain import GainEmitter, tts_output_gain_from_env +from app.livekit.runtime.initial_greeting_audio_cache import ( + InitialGreetingAudioCache, + InitialGreetingCacheKey, +) + +logger = logging.getLogger(__name__) + + +SAMPLE_RATE = 24000 +NUM_CHANNELS = 1 +PCM_BYTES_PER_SAMPLE = 2 + +DEFAULT_XAI_WEBSOCKET_URL = ( + "wss://inference.generativeai.us-chicago-1.oci.oraclecloud.com/xai/v1/tts" +) +DEFAULT_VOICE = "c8x2ieiocufs" +DEFAULT_LANGUAGE = "pt-BR" +TTS_EMPTY_FRAME_RETRY_TIMEOUT_ENV = "TTS_EMPTY_FRAME_RETRY_TIMEOUT_S" +TTS_FIRST_FRAME_TIMEOUT_ENV = "TTS_FIRST_FRAME_TIMEOUT_S" +TTS_UNDERFLOW_ERROR_ENV = "TTS_UNDERFLOW_ERROR_MS" +TTS_TOTAL_TIMEOUT_ENV = "TTS_TOTAL_TIMEOUT_S" +_AUDIO_EMITTER_FRAME_SIZE_MS = 40 +_ALLOWED_TTS_PUNCTUATION = frozenset(".,;:?!-()[]{}\"'") | { + "\u2013", + "\u2014", + "\u2018", + "\u2019", + "\u201c", + "\u201d", + "\u2026", +} +OPTIMIZE_STREAMING_LATENCY = 1 +TEXT_NORMALIZATION = True +WEBSOCKET_HEARTBEAT: float | None = None +# Reconnect before the service can silently expire an idle socket, which also +# refreshes IAM authorization at the next WebSocket handshake. +WEBSOCKET_IDLE_TTL_SECONDS = 290.0 + +AUTH_METHOD_API_KEY = "API_KEY" +AUTH_METHOD_CONFIG_FILE = "CONFIG_FILE" +AUTH_METHOD_INSTANCE_PRINCIPAL = "INSTANCE_PRINCIPAL" +AUTH_METHOD_OKE_WORKLOAD_IDENTITY = "OKE_WORKLOAD_IDENTITY" +AuthMethod = Literal[ + "API_KEY", + "CONFIG_FILE", + "INSTANCE_PRINCIPAL", + "OKE_WORKLOAD_IDENTITY", +] +_IAM_AUTH_METHODS = { + AUTH_METHOD_CONFIG_FILE, + AUTH_METHOD_INSTANCE_PRINCIPAL, + AUTH_METHOD_OKE_WORKLOAD_IDENTITY, +} +_AUTH_METHODS = _IAM_AUTH_METHODS | {AUTH_METHOD_API_KEY} + + +def _runtime_logger() -> logging.Logger: + app_logger = logging.getLogger(os.getenv("APP_LOGGER_NAME", "agent_internal_stt")) + return app_logger if app_logger.handlers else logger + + +def _elapsed_ms(started_at: float) -> int: + if started_at <= 0.0: + return 0 + return max(0, round((time.perf_counter() - started_at) * 1000)) + + +def _env_timeout_s(env_name: str, default: float) -> float: + try: + timeout_s = float(str(os.getenv(env_name, str(default)) or str(default))) + except ValueError: + timeout_s = default + return max(0.0, timeout_s) + + +def _first_frame_timeout_s() -> float: + if os.getenv(TTS_FIRST_FRAME_TIMEOUT_ENV) is not None: + return _env_timeout_s(TTS_FIRST_FRAME_TIMEOUT_ENV, 3.0) + return _env_timeout_s(TTS_EMPTY_FRAME_RETRY_TIMEOUT_ENV, 3.0) + + +def _underflow_error_ms() -> int: + """Maximum estimated PCM debt tolerated after playout has started.""" + try: + value = int(str(os.getenv(TTS_UNDERFLOW_ERROR_ENV, "1000") or "1000")) + except ValueError: + value = 1000 + return max(0, value) + + +def _turn_total_timeout_s() -> float: + """Absolute ceiling from ``text.delta`` to ``audio.done`` for one turn.""" + return _env_timeout_s(TTS_TOTAL_TIMEOUT_ENV, 60.0) + + +def _pcm_duration_s(payload_bytes: int) -> float: + return max(0, payload_bytes) / (SAMPLE_RATE * PCM_BYTES_PER_SAMPLE) + + +def _estimated_pcm_balance_s( + *, + pcm_duration_s: float, + first_pcm_released_at: float | None, + now: float, +) -> float: + """PCM received minus playout elapsed; no implicit LiveKit prebuffer.""" + if first_pcm_released_at is None: + return 0.0 + return pcm_duration_s - max(0.0, now - first_pcm_released_at) + + +def _sanitize_tts_text(text: str) -> str: + raw_text = str(text or "") + parts: list[str] = [] + for index, char in enumerate(raw_text): + category = unicodedata.category(char) + if ( + category[0] in {"L", "N"} + or char in _ALLOWED_TTS_PUNCTUATION + or ( + char == "/" + and index > 0 + and index + 1 < len(raw_text) + and raw_text[index - 1].isdigit() + and raw_text[index + 1].isdigit() + ) + ): + parts.append(char) + elif ( + char == "/" + and index > 0 + and index + 1 < len(raw_text) + and raw_text[index - 1].isalpha() + and raw_text[index + 1].isalpha() + ): + parts.append(" ou ") + elif char.isspace(): + parts.append(" ") + else: + parts.append(" ") + return " ".join("".join(parts).split()) + + +@dataclass +class _TTSOptions: + base_url: str + voice: str + language: str + + +@dataclass(frozen=True) +class _AuthOptions: + method: AuthMethod + api_key: str | None = None + compartment_id: str | None = None + oci_config_file: str | None = None + oci_profile: str | None = None + + +@dataclass +class _TurnTiming: + """Internal timing data for one xAI provider turn.""" + + turn_index: int + clear_rtt_ms: float | None + provider_ttfb_ms: float | None + end_to_end_ttfb_ms: float | None + max_audio_delta_gap_ms: int + max_playout_underrun_0ms: int + connection_reused: bool + reconnected: bool + discarded_messages: list[str] + provider_synthesis_ms: float | None = None + xai_underrun_estimado_ms: int = 0 + xai_micro_underflows: int = 0 + xai_avg_underrun_ms: int = 0 + underflow_error_ms: int = 1000 + pcm_bytes: int = 0 + pcm_duration_ms: int = 0 + empty_audio_deltas: int = 0 + attempts: int = 1 + connection_queue_wait_ms: int = 0 + turn_gap_after_previous_ms: int | None = None + clear_discarded_message_count: int = 0 + clear_discarded_audio_bytes: int = 0 + + +@dataclass +class _TurnResult: + audio_emitted: bool + trace_id: str | None = None + provider_ttfb: float = -1.0 + timing: _TurnTiming | None = None + + +class _XAIConnectionClosed(Exception): + def __init__(self, message: str, *, audio_emitted: bool) -> None: + super().__init__(message) + self.audio_emitted = audio_emitted + + +class _XAIEmptyAudioDone(Exception): + """Clean provider boundary with no non-empty PCM; retry once on this socket.""" + + +class _XAIPartialAudioFailure(Exception): + """A response already reached playout and therefore must never be replayed.""" + + +def _resolve_auth_method(value: str) -> AuthMethod: + resolved = value.upper() + if resolved not in _AUTH_METHODS: + raise ValueError( + "xAI TTS auth_method must be one of: " + ", ".join(sorted(_AUTH_METHODS)) + ) + return cast(AuthMethod, resolved) + + +def _validate_config_file_auth(config_file: str, profile: str) -> None: + try: + oci.config.from_file(os.path.expanduser(config_file), profile) + except Exception as exc: + raise ValueError( + "CONFIG_FILE authentication requires a readable OCI config file and " + f"profile (resolved path: {os.path.expanduser(config_file)!r}, " + f"profile: {profile!r})" + ) from exc + + +def _make_iam_signer(auth: _AuthOptions): + if auth.method == AUTH_METHOD_CONFIG_FILE: + assert auth.oci_config_file is not None + assert auth.oci_profile is not None + oci_config = oci.config.from_file( + os.path.expanduser(auth.oci_config_file), auth.oci_profile + ) + if "security_token_file" not in oci_config: + return oci.signer.Signer.from_config(oci_config) + + private_key = oci.signer.load_private_key_from_file(oci_config["key_file"]) + with open(oci_config["security_token_file"], encoding="utf-8") as token_file: + token = token_file.read().strip() + return oci.auth.signers.SecurityTokenSigner(token, private_key) + + if auth.method == AUTH_METHOD_INSTANCE_PRINCIPAL: + return oci.auth.signers.InstancePrincipalsSecurityTokenSigner() + + if auth.method == AUTH_METHOD_OKE_WORKLOAD_IDENTITY: + return oci.auth.signers.get_oke_workload_identity_resource_principal_signer() + + raise ValueError(f"unsupported IAM authentication method: {auth.method}") + + +def _request_headers(auth: _AuthOptions, uri: str) -> dict[str, str]: + if auth.method == AUTH_METHOD_API_KEY: + assert auth.api_key is not None + return {"Authorization": f"Bearer {auth.api_key}"} + + signer = _make_iam_signer(auth) + https_uri = uri.replace("wss://", "https://", 1) + prepared = requests.Request("GET", https_uri).prepare() + signer.do_request_sign(prepared) + headers = dict(prepared.headers) + assert auth.compartment_id is not None + headers["opc-compartment-id"] = auth.compartment_id + return headers + + +class OraclexAITTS(tts.TTS): + def __init__( + self, + *, + api_key: NotGivenOr[str] = NOT_GIVEN, + auth_method: NotGivenOr[AuthMethod] = NOT_GIVEN, + compartment_id: NotGivenOr[str] = NOT_GIVEN, + oci_config_file: NotGivenOr[str] = NOT_GIVEN, + oci_profile: NotGivenOr[str] = NOT_GIVEN, + base_url: NotGivenOr[str] = NOT_GIVEN, + websocket_url: str | None = None, + voice: str = DEFAULT_VOICE, + language: str = DEFAULT_LANGUAGE, + http_session: aiohttp.ClientSession | None = None, + initial_greeting_audio_cache: InitialGreetingAudioCache | None = None, + initial_greeting_agent: str = "", + ) -> None: + """ + Create a new instance of the xAI TTS. + + Args: + voice (str, optional): The voice ID for the desired voice. + language (str, optional): Language code for synthesis. + api_key (str | None, optional): API key used with auth_method=API_KEY. Defaults to XAI_API_KEY. + auth_method (str, optional): API_KEY, CONFIG_FILE, INSTANCE_PRINCIPAL, or OKE_WORKLOAD_IDENTITY. Defaults to XAI_TTS_AUTH_METHOD or API_KEY. + compartment_id (str, optional): OCI compartment OCID required for IAM authentication. Defaults to OCI_COMPARTMENT_ID. + oci_config_file (str, optional): OCI config file for CONFIG_FILE. Defaults to OCI_CONFIG_FILE or ~/.oci/config. + oci_profile (str, optional): OCI profile for CONFIG_FILE. Defaults to OCI_CONFIG_PROFILE or DEFAULT. + base_url (str, optional): WebSocket base URL for xAI TTS. Precedence: explicit argument, XAI_WEBSOCKET_URL environment variable, then the built-in default. + http_session (aiohttp.ClientSession | None, optional): An existing aiohttp ClientSession to use. + """ + super().__init__( + capabilities=tts.TTSCapabilities(streaming=True), + sample_rate=SAMPLE_RATE, + num_channels=NUM_CHANNELS, + ) + + resolved_method = _resolve_auth_method( + str(auth_method) + if is_given(auth_method) + else os.environ.get("XAI_TTS_AUTH_METHOD", AUTH_METHOD_API_KEY) + ) + + resolved_key: str | None = ( + str(api_key) if is_given(api_key) else os.environ.get("XAI_API_KEY") + ) + resolved_compartment_id = ( + str(compartment_id) + if is_given(compartment_id) + else os.environ.get("OCI_COMPARTMENT_ID") + ) + resolved_config_file = ( + str(oci_config_file) + if is_given(oci_config_file) + else os.environ.get("OCI_CONFIG_FILE", "~/.oci/config") + ) + resolved_profile = ( + str(oci_profile) + if is_given(oci_profile) + else os.environ.get("OCI_CONFIG_PROFILE", "DEFAULT") + ) + + if resolved_method == AUTH_METHOD_API_KEY: + if not resolved_key: + raise ValueError( + "xAI API key is required for API_KEY authentication, either as " + "argument or set XAI_API_KEY environment variable" + ) + else: + if is_given(api_key): + raise ValueError("api_key is only valid with auth_method=API_KEY") + if not resolved_compartment_id: + raise ValueError( + "compartment_id is required for IAM authentication; pass it as " + "an argument or set OCI_COMPARTMENT_ID" + ) + if resolved_method == AUTH_METHOD_CONFIG_FILE: + _validate_config_file_auth(resolved_config_file, resolved_profile) + + self._auth = _AuthOptions( + method=resolved_method, + api_key=resolved_key if resolved_method == AUTH_METHOD_API_KEY else None, + compartment_id=( + resolved_compartment_id + if resolved_method in _IAM_AUTH_METHODS + else None + ), + oci_config_file=( + resolved_config_file + if resolved_method == AUTH_METHOD_CONFIG_FILE + else None + ), + oci_profile=( + resolved_profile if resolved_method == AUTH_METHOD_CONFIG_FILE else None + ), + ) + if websocket_url is not None: + if is_given(base_url): + raise ValueError("base_url and websocket_url cannot be used together") + resolved_base_url = str(websocket_url).strip() + if not resolved_base_url: + raise ValueError("xAI TTS websocket_url must be a non-empty string") + elif is_given(base_url): + resolved_base_url = str(base_url).strip() + if not resolved_base_url: + raise ValueError("xAI TTS base_url must be a non-empty string") + else: + resolved_base_url = ( + os.environ.get("XAI_WEBSOCKET_URL") or DEFAULT_XAI_WEBSOCKET_URL + ) + self._opts = _TTSOptions( + base_url=resolved_base_url, + voice=(voice or DEFAULT_VOICE).strip() or DEFAULT_VOICE, + language=(language or DEFAULT_LANGUAGE).strip() or DEFAULT_LANGUAGE, + ) + + self._session = http_session + self._streams = weakref.WeakSet[SynthesizeStream]() + self._connection_lock = asyncio.Lock() + self._connection: _Connection | None = None + self._retired_connections: set[_Connection] = set() + self._prewarm_task: asyncio.Task[None] | None = None + self._output_gain = tts_output_gain_from_env() + self._initial_greeting_audio_cache = initial_greeting_audio_cache + self._initial_greeting_agent = ( + (initial_greeting_agent or "unknown").strip().lower() + ) + self._initial_greeting_capture_key: InitialGreetingCacheKey | None = None + + def _wrap_output_gain(self, emitter: tts.AudioEmitter) -> tts.AudioEmitter: + """Aplica o ganho/limiter configurado a cada frame de saída.""" + if self._output_gain.enabled: + return GainEmitter(emitter, self._output_gain) + return emitter + + @property + def model(self) -> str: + return self._opts.voice + + @property + def provider(self) -> str: + return "xAI" + + def initial_greeting_audio(self, text: str): + """Return cached first-turn audio, or arm capture for the exact text.""" + cache = self._initial_greeting_audio_cache + if cache is None: + return None + key = cache.key_for( + agent=self._initial_greeting_agent, + text=_sanitize_tts_text(text), + provider=self.provider, + voice=self._opts.voice, + language=self._opts.language, + sample_rate=SAMPLE_RATE, + ) + if key is None: + return None + if cache.has(key): + try: + frames = cache.frames(key) + except (OSError, EOFError, ValueError, wave.Error): + cache.discard(key) + _runtime_logger().warning( + "INITIAL_GREETING_AUDIO_CACHE_FALLBACK | key=%s | reason=read_failed", + key.digest, + exc_info=True, + ) + else: + _runtime_logger().info( + "INITIAL_GREETING_AUDIO_CACHE_HIT | key=%s", key.digest + ) + return frames + self._initial_greeting_capture_key = key + _runtime_logger().info( + "INITIAL_GREETING_AUDIO_CACHE_ARMED | key=%s", key.digest + ) + return None + + def _take_initial_greeting_capture( + self, text: str + ) -> InitialGreetingCacheKey | None: + key = self._initial_greeting_capture_key + self._initial_greeting_capture_key = None + normalized = " ".join(_sanitize_tts_text(text).split()) + if key is None or key.text != normalized: + return None + return key + + def _ensure_session(self) -> aiohttp.ClientSession: + if not self._session: + self._session = utils.http_context.http_session() + return self._session + + def prewarm(self) -> None: + if self._prewarm_task is not None and not self._prewarm_task.done(): + return + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + + self._prewarm_task = loop.create_task(self._prewarm_connection()) + + async def connect( + self, timeout: float = DEFAULT_API_CONNECT_OPTIONS.timeout + ) -> None: + """Open (or validate) the reusable WebSocket connection. + + This is intentionally separate from :meth:`prewarm`: callers such as + readiness checks need an awaitable, deterministic connection result. + """ + await self._current_connection(timeout) + + async def prewarm_connection(self, *, timeout: float = 3.0) -> None: + await self.connect(timeout) + + async def _prewarm_connection(self) -> None: + try: + await self._current_connection(DEFAULT_API_CONNECT_OPTIONS.timeout) + except asyncio.CancelledError: + raise + except Exception: + _runtime_logger().debug( + "failed to prewarm xAI TTS websocket", exc_info=True + ) + + async def _current_connection( + self, timeout: float + ) -> tuple[_Connection, float, bool]: + async with self._connection_lock: + if self._connection and self._connection.is_usable: + return self._connection, 0.0, True + + if self._connection is not None: + stale_conn = self._connection + self._connection = None + stale_conn.mark_non_current() + self._retired_connections.add(stale_conn) + if not stale_conn.active: + await stale_conn.aclose() + self._retired_connections.discard(stale_conn) + + session = self._ensure_session() + conn = _Connection( + opts=replace(self._opts), + auth=self._auth, + session=session, + owner=self, + ) + t0 = time.perf_counter() + await conn.connect(timeout=timeout) + acquire_time = time.perf_counter() - t0 + self._connection = conn + return conn, acquire_time, False + + def _retire_current_connection(self) -> None: + if not self._connection: + return + + conn = self._connection + self._connection = None + conn.mark_non_current() + self._retired_connections.add(conn) + + if not conn.active: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + + task = loop.create_task(conn.aclose()) + task.add_done_callback(lambda _: self._retired_connections.discard(conn)) + + def update_options( + self, + *, + voice: str | None = None, + language: str | None = None, + ) -> None: + """Update the xAI TTS configuration options.""" + changed = False + if voice and voice != self._opts.voice: + self._opts.voice = voice + changed = True + if language and language != self._opts.language: + self._opts.language = language + changed = True + + if changed: + self._retire_current_connection() + + def synthesize( + self, + text: str, + *, + conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS, + ) -> tts.ChunkedStream: + return _ChunkedStreamFromStream( + tts=self, + input_text=text, + conn_options=conn_options, + ) + + def stream( + self, *, conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS + ) -> SynthesizeStream: + # The adapter retains the original text and is the only retry owner. + stream = SynthesizeStream( + tts=self, + conn_options=APIConnectOptions( + max_retry=0, + retry_interval=conn_options.retry_interval, + timeout=conn_options.timeout, + ), + ) + self._streams.add(stream) + return stream + + async def aclose(self) -> None: + if self._prewarm_task is not None: + await utils.aio.gracefully_cancel(self._prewarm_task) + self._prewarm_task = None + + for stream in list(self._streams): + await stream.aclose() + self._streams.clear() + + connections = list(self._retired_connections) + if self._connection: + connections.append(self._connection) + self._connection = None + + for conn in connections: + await conn.aclose() + self._retired_connections.clear() + + +class _ChunkedStreamFromStream(tts.ChunkedStream): + async def _run(self, output_emitter: tts.AudioEmitter) -> None: + output_emitter.initialize( + request_id=utils.shortuuid(), + sample_rate=self._tts.sample_rate, + num_channels=self._tts.num_channels, + mime_type="audio/pcm", + frame_size_ms=_AUDIO_EMITTER_FRAME_SIZE_MS, + ) + async with self._tts.stream( + conn_options=APIConnectOptions( + max_retry=0, + timeout=self._conn_options.timeout, + ) + ) as stream: + stream.push_text(self.input_text) + stream.end_input() + async for event in stream: + output_emitter.push(event.frame.data.tobytes()) + output_emitter.flush() + + +class SynthesizeStream(tts.SynthesizeStream): + """Stream-based text-to-speech synthesis using xAI's WebSocket API.""" + + def __init__(self, *, tts: OraclexAITTS, conn_options: APIConnectOptions): + super().__init__(tts=tts, conn_options=conn_options) + self._tts: OraclexAITTS = tts + self._segment_started = False + self._segment_id = "" + self._segment_provider_ttfb = -1.0 + # Internal benchmark data; not part of LiveKit public metrics. + self._turn_timings: list[_TurnTiming] = [] + + async def _run(self, output_emitter: tts.AudioEmitter) -> None: + self._segment_id = utils.shortuuid() + output_emitter.initialize( + request_id=self._segment_id, + sample_rate=SAMPLE_RATE, + num_channels=NUM_CHANNELS, + stream=True, + mime_type="audio/pcm", + frame_size_ms=_AUDIO_EMITTER_FRAME_SIZE_MS, + ) + output_emitter = self._tts._wrap_output_gain(output_emitter) + + try: + await self._run_complete_input(output_emitter) + + if self._segment_started: + output_emitter.end_segment() + except asyncio.TimeoutError: + raise APITimeoutError() from None + except aiohttp.ClientResponseError as e: + raise APIStatusError( + message=e.message, + status_code=e.status, + request_id=self._segment_id, + body=None, + ) from None + + def _note_provider_ttfb(self, provider_ttfb: float) -> None: + if provider_ttfb >= 0.0 and self._segment_provider_ttfb < 0.0: + self._segment_provider_ttfb = provider_ttfb + + async def _metrics_monitor_task( + self, + event_aiter: AsyncIterable[tts.SynthesizedAudio], + ) -> None: + audio_duration = 0.0 + ttfb = -1.0 + request_id = "" + segment_id = "" + + def _emit_metrics(*, cancelled: bool = False) -> None: + nonlocal audio_duration, ttfb, request_id, segment_id + + if not self._started_time or self._current_attempt_has_error: + return + + duration = time.perf_counter() - self._started_time + + if not self._mtc_pending_texts: + return + + text = self._mtc_pending_texts.pop(0) + if not text: + return + + metrics = TTSMetrics( + timestamp=time.time(), + request_id=request_id, + segment_id=segment_id, + ttfb=ttfb, + duration=duration, + characters_count=len(text), + audio_duration=audio_duration, + # A barge-in cancels the synthesis task before the provider can + # produce its final frame. Preserve the partial timings/audio + # accumulated so far and make that distinction explicit. + cancelled=cancelled, + label=self._tts._label, + streamed=True, + metadata=Metadata( + model_name=self._tts.model, + model_provider=self._tts.provider, + ), + ) + if self._tts_request_span: + self._tts_request_span.set_attribute( + trace_types.ATTR_TTS_METRICS, + metrics.model_dump_json(), + ) + self._tts.emit("metrics_collected", metrics) + + audio_duration = 0.0 + ttfb = -1.0 + request_id = "" + self._started_time = 0 + self._segment_provider_ttfb = -1.0 + + async for ev in event_aiter: + if ttfb == -1.0: + if self._segment_provider_ttfb >= 0.0: + ttfb = self._segment_provider_ttfb + else: + ttfb = time.perf_counter() - self._started_time + + audio_duration += ev.frame.duration + request_id = ev.request_id + segment_id = ev.segment_id + + if ev.is_final: + _emit_metrics() + + # The event channel is closed when the synthesis task ends. If it was + # cancelled before an is_final frame (for example, by a barge-in), emit + # one partial metric for the current segment. _emit_metrics resets + # _started_time, so a segment that already emitted normally is never + # emitted a second time here. + if self._task.cancelled(): + _emit_metrics(cancelled=True) + + async def _run_complete_input(self, output_emitter: tts.AudioEmitter) -> None: + chunks: list[str] = [] + async for data in self._input_ch: + if isinstance(data, str): + chunks.append(data) + + text = "".join(chunks) + await self._synthesize_text(text, output_emitter) + + async def _synthesize_text( + self, text: str, output_emitter: tts.AudioEmitter + ) -> None: + normalized_text = _sanitize_tts_text(text) + if not normalized_text: + return + + if not self._segment_started: + output_emitter.start_segment(segment_id=self._segment_id) + self._segment_started = True + + ( + conn, + _, + connection_reused, + ) = await self._tts._current_connection(self._conn_options.timeout) + result = await conn.synthesize_turn( + normalized_text, + output_emitter=output_emitter, + stream=self, + timeout=self._conn_options.timeout, + turn_index=len(self._turn_timings), + connection_reused=connection_reused, + ) + if result.timing is not None: + self._turn_timings.append(result.timing) + self._emit_turn_timing(result.timing) + if result.provider_ttfb >= 0.0: + self._note_provider_ttfb(result.provider_ttfb) + + def _emit_turn_timing(self, timing: _TurnTiming) -> None: + self._tts.emit( + "xai_tts_turn_timing", + { + "segment_id": self._segment_id, + "provider_ttfb_ms": timing.provider_ttfb_ms, + "end_to_end_ttfb_ms": timing.end_to_end_ttfb_ms, + "max_audio_delta_gap_ms": timing.max_audio_delta_gap_ms, + "max_playout_underrun_0ms": timing.max_playout_underrun_0ms, + "provider_synthesis_ms": timing.provider_synthesis_ms, + "xai_underrun_estimado_ms": timing.xai_underrun_estimado_ms, + "xai_micro_underflows": timing.xai_micro_underflows, + "xai_avg_underrun_ms": timing.xai_avg_underrun_ms, + "underflow_error_ms": timing.underflow_error_ms, + "pcm_bytes": timing.pcm_bytes, + "pcm_duration_ms": timing.pcm_duration_ms, + "empty_audio_deltas": timing.empty_audio_deltas, + "attempts": timing.attempts, + "clear_rtt_ms": timing.clear_rtt_ms, + "connection_queue_wait_ms": timing.connection_queue_wait_ms, + "turn_gap_after_previous_ms": timing.turn_gap_after_previous_ms, + "clear_discarded_message_count": timing.clear_discarded_message_count, + "clear_discarded_audio_bytes": timing.clear_discarded_audio_bytes, + "connection_reused": timing.connection_reused, + "reconnected": timing.reconnected, + "discarded_message_count": len(timing.discarded_messages), + }, + ) + + +class _Connection: + """A single reusable xAI WebSocket. xAI TTS turns are serialized FIFO.""" + + def __init__( + self, + *, + opts: _TTSOptions, + auth: _AuthOptions, + session: aiohttp.ClientSession, + owner: OraclexAITTS | None = None, + ) -> None: + self._opts = opts + self._auth = auth + self._session = session + self._owner = owner + self._ws: aiohttp.ClientWebSocketResponse | None = None + self._is_current = True + self._closed = False + self._turn_lock = asyncio.Lock() + self._connection_id = f"xai-ws-{utils.shortuuid()}" + self._opened_at: float | None = None + self._last_activity_at: float | None = None + self._last_turn_finished_at: float | None = None + self._clear_sequence = 0 + + @property + def active(self) -> bool: + return self._turn_lock.locked() + + @property + def idle_seconds(self) -> float: + if self._last_activity_at is None: + return 0.0 + return time.perf_counter() - self._last_activity_at + + @property + def idle_expired(self) -> bool: + return ( + WEBSOCKET_IDLE_TTL_SECONDS > 0 + and self._last_activity_at is not None + and self.idle_seconds >= WEBSOCKET_IDLE_TTL_SECONDS + ) + + @property + def is_usable(self) -> bool: + ws = self._ws + return ( + self._is_current + and not self._closed + and ws is not None + and not ws.closed + and ws.exception() is None + and not self.idle_expired + ) + + def mark_non_current(self) -> None: + self._is_current = False + + def _note_activity(self) -> None: + self._last_activity_at = time.perf_counter() + + async def connect(self, *, timeout: float) -> None: + if self._closed: + raise APIConnectionError("xAI TTS connection is closed") + if self._ws is not None and not self._ws.closed: + return + + params = { + "voice": self._opts.voice, + "language": self._opts.language, + "codec": "pcm", + "sample_rate": SAMPLE_RATE, + "optimize_streaming_latency": OPTIMIZE_STREAMING_LATENCY, + "text_normalization": str(TEXT_NORMALIZATION).lower(), + } + url = f"{self._opts.base_url}?{urlencode(params)}" + connect_started_at = time.perf_counter() + _runtime_logger().info( + "XAI_TTS_WS_OPENING | connection_id=%s | voice=%s | language=%s | timeout_ms=%s", + self._connection_id, + self._opts.voice, + self._opts.language, + round(timeout * 1000), + ) + try: + self._ws = await asyncio.wait_for( + self._session.ws_connect( + url, + headers=_request_headers(self._auth, url), + heartbeat=WEBSOCKET_HEARTBEAT, + ), + timeout, + ) + self._opened_at = time.perf_counter() + self._note_activity() + _runtime_logger().info( + "XAI_TTS_WS_OPENED | connection_id=%s | connect_ms=%s", + self._connection_id, + round((self._opened_at - connect_started_at) * 1000), + ) + except ( + aiohttp.ClientConnectorError, + aiohttp.ClientConnectionResetError, + asyncio.TimeoutError, + ) as e: + # The original text is still owned by _Connection at this point. + raise _XAIConnectionClosed( + "failed to connect to xAI TTS", audio_emitted=False + ) from e + + async def synthesize_turn( + self, + text: str, + *, + output_emitter: tts.AudioEmitter, + stream: SynthesizeStream, + timeout: float, + turn_index: int, + connection_reused: bool, + ) -> _TurnResult: + queued_at = time.perf_counter() + async with self._turn_lock: + connection_queue_wait_ms = round((time.perf_counter() - queued_at) * 1000) + turn_gap_after_previous_ms = ( + round((time.perf_counter() - self._last_turn_finished_at) * 1000) + if self._last_turn_finished_at is not None + else None + ) + try: + return await self._synthesize_turn_with_retry( + text, + output_emitter=output_emitter, + stream=stream, + timeout=timeout, + turn_index=turn_index, + connection_reused=connection_reused, + connection_queue_wait_ms=connection_queue_wait_ms, + turn_gap_after_previous_ms=turn_gap_after_previous_ms, + ) + finally: + self._last_turn_finished_at = time.perf_counter() + if not self._is_current: + await self.aclose() + + async def _synthesize_turn_with_retry( + self, + text: str, + *, + output_emitter: tts.AudioEmitter, + stream: SynthesizeStream, + timeout: float, + turn_index: int, + connection_reused: bool, + connection_queue_wait_ms: int, + turn_gap_after_previous_ms: int | None, + ) -> _TurnResult: + logical_turn_start = time.perf_counter() + reconnected = False + last_error: Exception | None = None + for attempt in range(2): + try: + await self.connect(timeout=timeout) + return await self._synthesize_turn_once( + text, + output_emitter=output_emitter, + stream=stream, + timeout=timeout, + turn_index=turn_index, + connection_reused=connection_reused, + reconnected=reconnected, + logical_turn_start=logical_turn_start, + attempts=attempt + 1, + connection_queue_wait_ms=connection_queue_wait_ms, + turn_gap_after_previous_ms=turn_gap_after_previous_ms, + ) + except _XAIEmptyAudioDone as e: + last_error = e + if attempt > 0: + raise APIConnectionError(str(e)) from e + _runtime_logger().warning("XAI_TTS_AUDIO_DONE_WITHOUT_FRAMES | connection_id=%s | segment_id=%s | turn_index=%s | action=retry_same_socket", self._connection_id, stream._segment_id, turn_index) + except _XAIPartialAudioFailure: + raise + except _XAIConnectionClosed as e: + last_error = e + if e.audio_emitted: + confirmed = await self._resynchronize_after_partial_audio(stream=stream, turn_index=turn_index, reason=str(e)) + raise _XAIPartialAudioFailure(f"xAI TTS failed after partial audio; socket_resynchronized={int(confirmed)}: {e}") from e + reconnected = True + await self._reset_ws(reason="before_audio_reconnect") + if attempt > 0: + raise APIConnectionError(str(e)) from e + _runtime_logger().warning( + "XAI_TTS_WS_RECONNECTING | connection_id=%s | segment_id=%s | turn_index=%s | reason=%s", + self._connection_id, + stream._segment_id, + turn_index, + str(e), + ) + except APITimeoutError: + await self._reset_ws() + raise + except APIStatusError: + await self._reset_ws() + raise + + raise APIConnectionError("xAI TTS websocket disconnected") from last_error + + @staticmethod + def _turn_timing( + *, + turn_index: int, + clear_rtt: float, + provider_ttfb: float, + end_to_end_ttfb: float, + max_audio_delta_gap_ms: int, + max_playout_underrun_0ms: int, + connection_reused: bool, + reconnected: bool, + discarded_messages: list[str], + ) -> _TurnTiming: + return _TurnTiming( + turn_index=turn_index, + clear_rtt_ms=round(clear_rtt * 1000, 3), + provider_ttfb_ms=( + round(provider_ttfb * 1000, 3) if provider_ttfb >= 0 else None + ), + end_to_end_ttfb_ms=( + round(end_to_end_ttfb * 1000, 3) if end_to_end_ttfb >= 0 else None + ), + max_audio_delta_gap_ms=max_audio_delta_gap_ms, + max_playout_underrun_0ms=max_playout_underrun_0ms, + connection_reused=connection_reused, + reconnected=reconnected, + discarded_messages=list(discarded_messages), + ) + + async def _resynchronize_after_partial_audio( + self, *, stream: SynthesizeStream, turn_index: int, reason: str + ) -> bool: + clear_sent = False + try: + ws = self._require_ws() + await ws.send_str(json.dumps({"type": "text.clear"})) + self._note_activity() + clear_sent = True + except Exception as exc: + _runtime_logger().warning( + "XAI_TTS_PARTIAL_AUDIO_CLEAR_WRITE_FAILED | connection_id=%s | " + "segment_id=%s | turn_index=%s | reason=%s | error=%s", + self._connection_id, + stream._segment_id, + turn_index, + reason, + type(exc).__name__, + ) + await self._reset_ws( + reason=( + "partial_audio_after_best_effort_clear" + if clear_sent + else "partial_audio_clear_write_failed" + ) + ) + _runtime_logger().warning( + "XAI_TTS_PARTIAL_AUDIO_SOCKET_DISCARDED | connection_id=%s | " + "segment_id=%s | turn_index=%s | reason=%s | clear_sent=%s | " + "action=reconnect_on_next_synthesis", + self._connection_id, + stream._segment_id, + turn_index, + reason, + int(clear_sent), + ) + return False + + async def _synthesize_turn_once( + self, text: str, *, output_emitter: tts.AudioEmitter, stream: SynthesizeStream, + timeout: float, turn_index: int, connection_reused: bool, reconnected: bool, + logical_turn_start: float, attempts: int, + connection_queue_wait_ms: int, + turn_gap_after_previous_ms: int | None, + ) -> _TurnResult: + cache_key = self._owner._take_initial_greeting_capture(text) if self._owner else None + captured_pcm = bytearray() if cache_key is not None else None + ws = self._require_ws() + audio_emitted = clear_acknowledged = False + trace_id: str | None = None + provider_ttfb = end_to_end_ttfb = clear_rtt = -1.0 + discarded_messages: list[str] = [] + provider_audio_bytes = audio_delta_count = empty_audio_deltas = 0 + pcm_duration_s = 0.0 + first_pcm_released_at: float | None = None + last_audio_at: float | None = None + max_gap_ms = max_underrun_ms = micro_underflows = 0 + underflow_episode_max_ms = underflow_episode_total_ms = 0 + underflow_active = False + underflow_error_ms = _underflow_error_ms() + text_delta_sent_at = total_deadline = 0.0 + clear_sequence = self._clear_sequence + 1 + self._clear_sequence = clear_sequence + clear_discarded_message_count = clear_discarded_audio_bytes = 0 + + def observe_underflow_balance(balance_s: float) -> None: + nonlocal max_underrun_ms, micro_underflows, underflow_active + nonlocal underflow_episode_max_ms, underflow_episode_total_ms + debt_ms = max(0, round(-balance_s * 1000)) + max_underrun_ms = max(max_underrun_ms, debt_ms) + if balance_s < 0: + if not underflow_active: + micro_underflows += 1 + underflow_active = True + underflow_episode_max_ms = 0 + underflow_episode_max_ms = max(underflow_episode_max_ms, debt_ms) + elif underflow_active: + underflow_episode_total_ms += underflow_episode_max_ms + underflow_active = False + underflow_episode_max_ms = 0 + + def finish_open_underflow() -> None: + nonlocal underflow_active, underflow_episode_max_ms, underflow_episode_total_ms + if underflow_active: + underflow_episode_total_ms += underflow_episode_max_ms + underflow_active = False + underflow_episode_max_ms = 0 + + def average_underflow_ms() -> int: + return round(underflow_episode_total_ms / micro_underflows) if micro_underflows else 0 + + async def partial_failure(reason: str) -> None: + finish_open_underflow() + provider_synthesis_ms = round( + (time.perf_counter() - text_delta_sent_at) * 1000 + ) + pcm_duration_ms = round(pcm_duration_s * 1000) + average_underrun_ms = average_underflow_ms() + _runtime_logger().warning( + "XAI_TTS_TURN_FAILED | connection_id=%s | segment_id=%s | turn_index=%s | phase=after_playout | reason=%s | attempts=%s | pcm_bytes=%s | pcm_duration_ms=%s | max_audio_delta_gap_ms=%s | xai_underrun_estimado_ms=%s | xai_micro_underflows=%s | xai_avg_underrun_ms=%s | underflow_error_ms=%s", + self._connection_id, stream._segment_id, turn_index, reason, attempts, + provider_audio_bytes, pcm_duration_ms, max_gap_ms, + max_underrun_ms, micro_underflows, average_underrun_ms, underflow_error_ms, + ) + if self._owner is not None: + self._owner.emit( + "xai_tts_turn_failed", + { + "segment_id": stream._segment_id, + "turn_index": turn_index, + "phase": "after_playout", + "reason": reason, + "max_audio_delta_gap_ms": max_gap_ms, + "max_playout_underrun_0ms": max_underrun_ms, + "xai_micro_underflows": micro_underflows, + "xai_avg_underrun_ms": average_underrun_ms, + "provider_synthesis_ms": provider_synthesis_ms, + "provider_ttfb_ms": ( + round(provider_ttfb * 1000, 3) + if provider_ttfb >= 0 + else None + ), + "end_to_end_ttfb_ms": ( + round(end_to_end_ttfb * 1000, 3) + if end_to_end_ttfb >= 0 + else None + ), + "underflow_error_ms": underflow_error_ms, + "pcm_bytes": provider_audio_bytes, + "pcm_duration_ms": pcm_duration_ms, + "attempts": attempts, + "connection_queue_wait_ms": connection_queue_wait_ms, + "turn_gap_after_previous_ms": turn_gap_after_previous_ms, + "connection_reused": connection_reused, + "reconnected": reconnected, + }, + ) + confirmed = await self._resynchronize_after_partial_audio(stream=stream, turn_index=turn_index, reason=reason) + raise _XAIPartialAudioFailure( + "xAI TTS partial audio failure: " + f"{reason}; socket_resynchronized={int(confirmed)}; " + f"xai_underrun_estimado_ms={max_underrun_ms}; " + f"xai_micro_underflows={micro_underflows}; " + f"xai_avg_underrun_ms={average_underrun_ms}; " + f"underflow_error_ms={underflow_error_ms}; " + f"max_audio_delta_gap_ms={max_gap_ms}; " + f"pcm_duration_ms={pcm_duration_ms}; " + f"pcm_bytes={provider_audio_bytes}" + ) + + try: + try: + clear_started = time.perf_counter() + await ws.send_str(json.dumps({"type": "text.clear"})) + self._note_activity() + _runtime_logger().info( + "XAI_TTS_CLEAR_SENT | connection_id=%s | segment_id=%s | turn_index=%s | clear_sequence=%s | connection_reused=%s | queue_wait_ms=%s | gap_after_previous_turn_ms=%s", + self._connection_id, stream._segment_id, turn_index, clear_sequence, + int(connection_reused), connection_queue_wait_ms, turn_gap_after_previous_ms, + ) + text_delta_sent_at = time.perf_counter() + await ws.send_str(json.dumps({"type": "text.delta", "delta": text})) + self._note_activity() + await ws.send_str(json.dumps({"type": "text.done"})) + self._note_activity() + total_timeout_s = _turn_total_timeout_s() + total_deadline = text_delta_sent_at + total_timeout_s + _runtime_logger().info("XAI_TTS_TURN_START | connection_id=%s | segment_id=%s | turn_index=%s | first_frame_timeout_ms=%s | underflow_error_ms=%s | total_timeout_ms=%s | attempts=%s", self._connection_id, stream._segment_id, turn_index, round(_first_frame_timeout_s() * 1000), underflow_error_ms, round(total_timeout_s * 1000), attempts) + except (aiohttp.ClientConnectionError, aiohttp.ClientError, ConnectionResetError, RuntimeError) as exc: + raise _XAIConnectionClosed("xAI TTS websocket clear or text write failed", audio_emitted=False) from exc + stream._mark_started() + while True: + now = time.perf_counter() + total_remaining_s = total_deadline - now + if total_remaining_s <= 0: + if audio_emitted: + observe_underflow_balance( + _estimated_pcm_balance_s( + pcm_duration_s=pcm_duration_s, + first_pcm_released_at=first_pcm_released_at, + now=now, + ) + ) + await partial_failure("total_timeout") + raise _XAIConnectionClosed("xAI TTS total timeout before audio", audio_emitted=False) + if not audio_emitted: + receive_timeout = min(total_remaining_s, _first_frame_timeout_s()) + else: + balance_s = _estimated_pcm_balance_s(pcm_duration_s=pcm_duration_s, first_pcm_released_at=first_pcm_released_at, now=now) + observe_underflow_balance(balance_s) + remaining_to_error_s = balance_s + underflow_error_ms / 1000 + if remaining_to_error_s <= 0: + await partial_failure("underflow_error") + receive_timeout = min(total_remaining_s, remaining_to_error_s) + try: + msg = await asyncio.wait_for(ws.receive(), timeout=receive_timeout) + except asyncio.TimeoutError: + timeout_now = time.perf_counter() + if timeout_now >= total_deadline: + if audio_emitted: + observe_underflow_balance( + _estimated_pcm_balance_s( + pcm_duration_s=pcm_duration_s, + first_pcm_released_at=first_pcm_released_at, + now=timeout_now, + ) + ) + await partial_failure("total_timeout") + raise _XAIConnectionClosed("xAI TTS total timeout before audio", audio_emitted=False) + if not audio_emitted: + raise _XAIConnectionClosed("xAI TTS timed out before audio", audio_emitted=False) + observe_underflow_balance( + _estimated_pcm_balance_s( + pcm_duration_s=pcm_duration_s, + first_pcm_released_at=first_pcm_released_at, + now=timeout_now, + ) + ) + await partial_failure("underflow_error") + self._note_activity() + if msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSE, aiohttp.WSMsgType.CLOSING, aiohttp.WSMsgType.ERROR): + if audio_emitted: + await partial_failure("websocket_closed") + raise _XAIConnectionClosed("xAI TTS websocket closed unexpectedly", audio_emitted=audio_emitted) + if msg.type != aiohttp.WSMsgType.TEXT: + discarded_messages.append(str(msg.type)) + continue + try: + data = json.loads(msg.data) + except json.JSONDecodeError as exc: + if audio_emitted: + await partial_failure("invalid_json") + raise APIStatusError("xAI TTS returned invalid JSON", status_code=-1, request_id=stream._segment_id, body=str(msg.data)) from exc + msg_type = str(data.get("type") or "") + if not clear_acknowledged: + if msg_type == "audio.clear": + clear_acknowledged = True + clear_rtt = time.perf_counter() - clear_started + _runtime_logger().info( + "XAI_TTS_CLEAR_ACK | connection_id=%s | segment_id=%s | turn_index=%s | clear_sequence=%s | clear_rtt_ms=%s | stale_messages=%s | stale_audio_bytes=%s | stale_message_types=%s", + self._connection_id, stream._segment_id, turn_index, clear_sequence, + round(clear_rtt * 1000), clear_discarded_message_count, + clear_discarded_audio_bytes, discarded_messages, + ) + continue + if msg_type == "error": + raise _XAIConnectionClosed("xAI TTS clear failed", audio_emitted=False) + clear_discarded_message_count += 1 + if msg_type == "audio.delta": + try: + clear_discarded_audio_bytes += len( + base64.b64decode(data.get("delta", ""), validate=True) + ) + except (ValueError, TypeError): + pass + discarded_messages.append(msg_type or "unknown") + continue + if msg_type == "audio.delta": + now = time.perf_counter() + try: + payload = base64.b64decode(data.get("delta", ""), validate=True) + except (ValueError, TypeError) as exc: + if audio_emitted: + await partial_failure("invalid_audio_delta") + raise APIStatusError("xAI TTS returned invalid audio.delta", status_code=-1, request_id=stream._segment_id, body=str(data)) from exc + if not payload: + empty_audio_deltas += 1 + _runtime_logger().warning("XAI_TTS_EMPTY_AUDIO_DELTA | connection_id=%s | segment_id=%s | turn_index=%s", self._connection_id, stream._segment_id, turn_index) + continue + if last_audio_at is not None: + max_gap_ms = max(max_gap_ms, round((now - last_audio_at) * 1000)) + if not audio_emitted: + provider_ttfb = now - text_delta_sent_at + end_to_end_ttfb = now - logical_turn_start + first_pcm_released_at = now + stream._note_provider_ttfb(provider_ttfb) + else: + observe_underflow_balance( + _estimated_pcm_balance_s( + pcm_duration_s=pcm_duration_s, + first_pcm_released_at=first_pcm_released_at, + now=now, + ) + ) + provider_audio_bytes += len(payload) + audio_delta_count += 1 + pcm_duration_s += _pcm_duration_s(len(payload)) + observe_underflow_balance( + _estimated_pcm_balance_s( + pcm_duration_s=pcm_duration_s, + first_pcm_released_at=first_pcm_released_at, + now=now, + ) + ) + if captured_pcm is not None: + captured_pcm.extend(payload) + output_emitter.push(payload) + audio_emitted = True + last_audio_at = now + elif msg_type == "audio.done": + finish_open_underflow() + if not audio_emitted: + raise _XAIEmptyAudioDone("xAI TTS audio.done arrived without non-empty PCM") + trace_id = data.get("trace_id") + if cache_key is not None and captured_pcm and self._owner is not None: + cache = self._owner._initial_greeting_audio_cache + if cache is not None: + await cache.store_pcm(cache_key, bytes(captured_pcm)) + timing = replace( + self._turn_timing(turn_index=turn_index, clear_rtt=clear_rtt, provider_ttfb=provider_ttfb, end_to_end_ttfb=end_to_end_ttfb, max_audio_delta_gap_ms=max_gap_ms, max_playout_underrun_0ms=max_underrun_ms, connection_reused=connection_reused, reconnected=reconnected, discarded_messages=discarded_messages), + provider_synthesis_ms=round((time.perf_counter() - text_delta_sent_at) * 1000, 3), + connection_queue_wait_ms=connection_queue_wait_ms, + turn_gap_after_previous_ms=turn_gap_after_previous_ms, + clear_discarded_message_count=clear_discarded_message_count, + clear_discarded_audio_bytes=clear_discarded_audio_bytes, + xai_underrun_estimado_ms=max_underrun_ms, xai_micro_underflows=micro_underflows, xai_avg_underrun_ms=average_underflow_ms(), underflow_error_ms=underflow_error_ms, pcm_bytes=provider_audio_bytes, pcm_duration_ms=round(pcm_duration_s * 1000), empty_audio_deltas=empty_audio_deltas, attempts=attempts, + ) + _runtime_logger().info("XAI_TTS_TURN_DONE | connection_id=%s | segment_id=%s | turn_index=%s | trace_id=%s | provider_ttfb_ms=%s | synthesis_ms=%s | pcm_bytes=%s | pcm_duration_ms=%s | max_audio_delta_gap_ms=%s | xai_underrun_estimado_ms=%s | xai_micro_underflows=%s | xai_avg_underrun_ms=%s | underflow_error_ms=%s | empty_audio_deltas=%s | attempts=%s | clear_rtt_ms=%s | queue_wait_ms=%s | gap_after_previous_turn_ms=%s | stale_messages_before_clear_ack=%s | stale_audio_bytes_before_clear_ack=%s", self._connection_id, stream._segment_id, turn_index, trace_id or "", timing.provider_ttfb_ms, timing.provider_synthesis_ms, timing.pcm_bytes, timing.pcm_duration_ms, timing.max_audio_delta_gap_ms, timing.xai_underrun_estimado_ms, timing.xai_micro_underflows, timing.xai_avg_underrun_ms, timing.underflow_error_ms, timing.empty_audio_deltas, timing.attempts, timing.clear_rtt_ms, timing.connection_queue_wait_ms, timing.turn_gap_after_previous_ms, timing.clear_discarded_message_count, timing.clear_discarded_audio_bytes) + return _TurnResult(audio_emitted=True, trace_id=trace_id, provider_ttfb=provider_ttfb, timing=timing) + elif msg_type == "audio.clear": + if audio_emitted: + await partial_failure("unexpected_audio_clear") + raise _XAIConnectionClosed("xAI TTS received unexpected audio.clear", audio_emitted=False) + elif msg_type == "error": + if audio_emitted: + await partial_failure("provider_error") + raise _XAIConnectionClosed("xAI TTS provider error before audio", audio_emitted=False) + else: + discarded_messages.append(msg_type or "unknown") + except asyncio.CancelledError: + try: + await self._clear_and_wait(timeout=min(timeout, _first_frame_timeout_s())) + except Exception: + await self._reset_ws(reason="cancel_clear_unconfirmed") + raise + + async def _clear_and_wait(self, *, timeout: float) -> tuple[float, list[str]]: + """Clear provider state and wait for its acknowledged clean boundary.""" + ws = self._require_ws() + discarded_messages: list[str] = [] + started_at = time.perf_counter() + try: + await ws.send_str(json.dumps({"type": "text.clear"})) + self._note_activity() + except ( + aiohttp.ClientConnectionError, + aiohttp.ClientError, + ConnectionResetError, + RuntimeError, + ) as e: + await self._reset_ws() + raise _XAIConnectionClosed( + "xAI TTS websocket clear write failed", audio_emitted=False + ) from e + + try: + while True: + msg = await asyncio.wait_for(ws.receive(), timeout=timeout) + self._note_activity() + if msg.type in ( + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.ERROR, + ): + await self._reset_ws() + raise _XAIConnectionClosed( + "xAI TTS websocket closed while clearing", audio_emitted=False + ) + if msg.type != aiohttp.WSMsgType.TEXT: + discarded_messages.append(str(msg.type)) + continue + try: + data = json.loads(msg.data) + except json.JSONDecodeError: + discarded_messages.append("invalid-json") + continue + msg_type = str(data.get("type", "unknown")) + if msg_type == "audio.clear": + return time.perf_counter() - started_at, discarded_messages + if msg_type == "error": + raise APIStatusError( + data.get("message", "xAI TTS clear failed"), + status_code=-1, + request_id=data.get("trace_id"), + body=str(data), + ) + discarded_messages.append(msg_type) + except asyncio.TimeoutError: + await self._reset_ws() + raise _XAIConnectionClosed( + "xAI TTS websocket timed out waiting for audio.clear", + audio_emitted=False, + ) from None + + def _require_ws(self) -> aiohttp.ClientWebSocketResponse: + if self._ws is None or self._ws.closed: + raise _XAIConnectionClosed( + "xAI TTS websocket is not connected", + audio_emitted=False, + ) + if self._ws.exception() is not None: + raise _XAIConnectionClosed( + "xAI TTS websocket is in an error state", + audio_emitted=False, + ) + return self._ws + + async def _reset_ws(self, *, reason: str = "reset") -> None: + ws = self._ws + self._ws = None + if ws is not None and not ws.closed: + close_started_at = time.perf_counter() + await ws.close() + _runtime_logger().info( + "XAI_TTS_WS_CLOSED | connection_id=%s | reason=%s | close_ms=%s | age_ms=%s", + self._connection_id, + reason, + _elapsed_ms(close_started_at), + _elapsed_ms(self._opened_at or 0.0), + ) + + async def aclose(self) -> None: + if self._closed: + return + self._closed = True + await self._reset_ws() + + +TTS = OraclexAITTS diff --git a/src/app/livekit/assets/comfort/fails/tts_fail_recovery.wav b/src/app/livekit/assets/comfort/fails/tts_fail_recovery.wav new file mode 100644 index 0000000000000000000000000000000000000000..1d55cf086c3337cbdf68b4703c7bc20b61777130 GIT binary patch literal 122444 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%0A_{vz5E-7Naw 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 HKTzP`IO1Z9 literal 0 HcmV?d00001 diff --git a/src/app/livekit/assets/comfort/interruption/01.txt b/src/app/livekit/assets/comfort/interruption/01.txt new file mode 100644 index 0000000..f59bbe4 --- /dev/null +++ b/src/app/livekit/assets/comfort/interruption/01.txt @@ -0,0 +1 @@ +Um instante diff --git a/src/app/livekit/assets/comfort/interruption/01.wav b/src/app/livekit/assets/comfort/interruption/01.wav new file mode 100644 index 0000000000000000000000000000000000000000..33f2d3d27cb99552f8d79606e71e8cc609435a53 GIT binary patch literal 47084 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 H|673{hXwcY literal 0 HcmV?d00001 diff --git a/src/app/livekit/assets/comfort/long/01.txt b/src/app/livekit/assets/comfort/long/01.txt new file mode 100644 index 0000000..4d4d885 --- /dev/null +++ b/src/app/livekit/assets/comfort/long/01.txt @@ -0,0 +1 @@ +Estou verificando as informações para te ajudar. Só um momentinho. \ No newline at end of file diff --git a/src/app/livekit/assets/comfort/long/01.wav b/src/app/livekit/assets/comfort/long/01.wav new file mode 100644 index 0000000000000000000000000000000000000000..9c2c687ae76cf31502751e57d62405185611a062 GIT binary patch literal 133144 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

0>?f(<}JezKA#Z9_e6J1x_Jis=D$lzUNlfgE}B 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 G1^y3OLSx|o literal 0 HcmV?d00001 diff --git a/src/app/livekit/assets/comfort/long/02.txt b/src/app/livekit/assets/comfort/long/02.txt new file mode 100644 index 0000000..d226eed --- /dev/null +++ b/src/app/livekit/assets/comfort/long/02.txt @@ -0,0 +1 @@ +Estou confirmando algumas informações. Um instantinho, por favor. \ No newline at end of file diff --git a/src/app/livekit/assets/comfort/long/02.wav b/src/app/livekit/assets/comfort/long/02.wav new file mode 100644 index 0000000000000000000000000000000000000000..0e917a31af9a978d1ca7e691c977c09b4104098f GIT binary patch literal 129228 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

d1f!DiuVX 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 literal 0 HcmV?d00001 diff --git a/src/app/livekit/assets/comfort/long/03.txt b/src/app/livekit/assets/comfort/long/03.txt new file mode 100644 index 0000000..06375ec --- /dev/null +++ b/src/app/livekit/assets/comfort/long/03.txt @@ -0,0 +1 @@ +Peço apenas mais um instante e já retorno com você \ No newline at end of file diff --git a/src/app/livekit/assets/comfort/long/03.wav b/src/app/livekit/assets/comfort/long/03.wav new file mode 100644 index 0000000000000000000000000000000000000000..9aa44293f0c94123c468b474355594866354298e GIT binary patch literal 117484 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 QX5iZle4By)e`nzT0OR`ey8r+H literal 0 HcmV?d00001 diff --git a/src/app/livekit/assets/comfort/short/01.txt b/src/app/livekit/assets/comfort/short/01.txt new file mode 100644 index 0000000..988f8c9 --- /dev/null +++ b/src/app/livekit/assets/comfort/short/01.txt @@ -0,0 +1 @@ +Um momentinho \ No newline at end of file diff --git a/src/app/livekit/assets/comfort/short/01.wav b/src/app/livekit/assets/comfort/short/01.wav new file mode 100644 index 0000000000000000000000000000000000000000..f3b45549d14453c1d0b5da8c9bf667db3081ddac GIT binary patch literal 34298 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 literal 0 HcmV?d00001 diff --git a/src/app/livekit/assets/comfort/short/02.txt b/src/app/livekit/assets/comfort/short/02.txt new file mode 100644 index 0000000..4e5b9aa --- /dev/null +++ b/src/app/livekit/assets/comfort/short/02.txt @@ -0,0 +1 @@ +Um instantinho \ No newline at end of file diff --git a/src/app/livekit/assets/comfort/short/02.wav b/src/app/livekit/assets/comfort/short/02.wav new file mode 100644 index 0000000000000000000000000000000000000000..d7009847c354e5bba4248694be3647eda1245f93 GIT binary patch literal 34298 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 w|2*=4wdQpj{)Q6Lq2;s<# zh76oIP?qJ{aOMC1`~R>6^7Q_{%m2x!EdS>lSi%3C{hz<)o&P^w|G(e&-|u-f{pXqg zKKZ{X|M%>__hSD&0V=XAK@tBg|M@TFU4&Ns=lI`eF|b?$%72b||MCCD+yAQW|K%i) zN?!c}9P?=Y@3#K;O#Gi~|Kbx46!h(vLC=AHafekwnbAHe6GoFj8^uMHJL)zGg%wt>| zqc*4t{GJMR(DD!YJv@0!z9FBJ56Q>nv+@ynw|oH355TwcP%g<2<=65XXlXXI&HHc3 zm2-j8Pq_9~&VV-3G7H);qC%(=YJ@tY!Ds@Sjh3OcXdPM)Wi?t11IuJI8d}p8HAPi{ zz6-cW!m}Uc=Rp6oyifj9UL`M<7szwusZeIg^W{bIa(Sh^R^AG~9+VHur=UGo*8%fN!nk=5k%Rx||{hqzvhev=iv9lGaL7r9Y%)a6VVs z1})ny-H?!6L~bC@mG6PolmIRN4)xAMOVN1L3Dp71cq{)6l58hem7B=bpyr|S1<;fk z?BE}eNh9nab`?8|`LWV?8N43e7jK1^#LMA@@JHBOtP7^Zj)JXpKq6ShC9s1j@>t+_ z4`^qn{0VBQkEWta$cs|24%j#>9jk@uv3H>5U(gk~t;|T54R1Qc2&%r((9)O3IQtfb~{I z`_UbA4(&t@&^}owPnTYab;Y;BL_s4w=1=o$`6>KFC`0)__{02no)BsYLxg$4Az`av z5+?AK`E`7pza%^o7f1)?i&z74nj)cKl}>eU^$7J+bxHMb)f`np^(9qQ`CVyNbyapz z%pms>M{zaY1uKr0fmM6thVoHqleAJAF13_yh=s)NVohniv_mQny>tNzp=#JQWI^vh zYH!4`LMGplACx?nsGistPmeE&4~ZX)b&0KuO^daUjgPrvE2B@NTjMX2U-*UMAM$V5 zU1Feem%5^+x#p}E)06rqx}dJCK1=sRcUgB^J6`if{X%tJ@rdx@-SKZ|uiRO#4c667 zZYr;qvc$^L1nHBg6AOz6MT4|N3Q282zjo+N)8*4r6{)L;34bN;C5S}j_}Um7Z5DNN zis%Eb8;5fqHpISRpRjY;(d?G!fkY1fOnfERCM?SP>V>+-`XTzUhQg++rlhfm$z|?l zZf-W2tC@}(PUwf~Ua0RWijvKV(^zSgE{~Ku%7f)Ha;nrrY%FF7pZSZ)tw{p3Gf>cp zlf}|fCfL&o@t8meJCdj3tz+Y(tGN5@NH)pLV7f3Rm|l^oVQ1JCo*#M_Y!uoRsTduW z7$)SCkKm7$Gqo}OQ9~W$P19&gEvwnu(>li1$$s5F!M@%$#2Pc_7)gCx9Z~+NcuE8@ zFWQT4f#s*mC8QjoiO`j&6Nlqud|G@$qI;6$H}SKDzG7c-xiFCLn=F)=9J|SFW*;%t znEjDxcxpHjniskk{3oCZtfYI<_X1SpD5p$x7N($7#S3)_{eEc2F7pe^Fk3BqbGzG~ z-;wHUSo@hu>3`MqR2@ldwms&hhLhS8>Z{8A z8R%F zmC_~UiEE!L;+*QJZZBdfW<>gu+8(N@WM$$v;x&F1otAovm4(a6aNHBsa-G@pY=X^U zyK^g|*JD-U$=GkPgVF0;Wwv?bc1R4)4E_~(OP{0<(gymIf2E%YxWfIJU%4u=U{aKO zlM&T@t<`Ya^w?UEdIhbR>8zacI%Rr_0C(#w@EPs|? zN8`b&k&0|ev|qfo@E8pw52{LO8|Xbo%G$%;l**v?IfteAQl6x=PI>3L;B-11_Gy;N zrd9?_cS!wBc||dqEQ0G$d+CPIli!{A8EYA>&J~9Kn9XiukFahwo1MKd?%C} z)CKzlPSb_yH~usJ75<(6Oa8loW|6(@oap3uDPcSE5<%rO&2Zf(!w++3TOWHds(`ab zN>0kdl$j~AOX+IkSY~f))tX7;9^GKgM%5bSZ;E`xS5#1*A!hTvll$V&qsO^r+$F9i z_lnhX^|&}Yn0?56ifj(g49yCz4}7J^(kb+Le;@y5e*yXyy()B-dCbj@ZA{z}_h8*f zyK0=JHC3}(cgf%~m$ja@&9&!JW1TuzKG$65-;RY;Rr^xwaC1-N z5xrM?RsBY}RWYCJg6mO!siSZ&*&uN}Rx9e@F0qf;ajc1b#*AQ+k(QBT;d7y@K}T?G z;40mgUO^wBkJ3Tf5*!l}BKe|=;#ZPqgucj0?osYn-__BsPEJLa-UgqB?xY*_BK5jP~W3Dn2na)gk zW^d$2*bY7aYVdx54D6uW(-r8+^efsH@C1s7`bRc!UE>&EMRcJBL^oxQx}dJLA)o2B z`L%VaeJ(ZL(Z-o{_H})9N{;2!4_i%ZOLIA6mM*R-r|zP>OFD@#@=IxA2fk>cW-KLI zmQ%8bc@y~*c^erM!6QS%7ee|_N^oxA8eN9I?|N8p@Q8olo=>CQ5U$ zqhu%5ea%tbY(sO?NDGWK_6JmDr^Pkc)z)Qk4R_9^(rr5|cC*oVNH<-xSfy94Ah+Ra zWR^M!&64@!!=rz2b=g#AOr%GodgMv?xA3bF5gHaOAKVf+LLc(?^mG2cbUJOPO@WQU z#*t1@S7IiAL|laOla#8jW{~cqeva{nxti^ReJgd-5q17_rKBu(?RS=T46~27?l&(n zX6UWDnd)iE&*XL@9a}1u5cJ6=@iWnu9K&pgI3ukhsgZr*AE73pW5Gn=_rQAkjNk4j z{LTH_{m1-Dka+pvs>q}0`NSc9koX)0$eXIGn&mp&P{4G}g4t84J5)y}OTViFtPvSuAEmxAgA9)(S8NL!O7Tyrb4lW2j z4LAce>Hhwgz8${pzInc*KIBiM|Dn@EO*nPpTQZxEOLL%iFH>L9w1ocs*xbxE+%DLS z&hjbkQ?04ZQx3c0&VL-6?JcYg%`Hqz4OO&bR27x=6$9`s(w{t^NRPLS{>Gk(oB_H= z!|y_#;2fZPG_a5!=6??K8v0Ip7kjh3AARfnE_zGoBv(AqEZK{<%FBq}%BQMV>N(oR zhN)(g?X~Tky}ol_%BECT>VTB8DZ5;ioGNO&HNT~wxvcSpwzRsbN>tRsi%G}$TFD`C zj{C|qkIV=!3s(xKg!sVqz|_DAdWrv}ubwaFec)Z=?de_O-R5iVUmi$jHpP}Eh9)Dz zY0RlusvN0$udV{)$q(~1>m1vBs*p>OIyZH9iXo+7$}rb(M`8PX>tT!4{9NB%`%^tf z^#_@P%8M=d3W>GRiELS>RHQ@rMW|Y+dhl|fL0~7n%fHK4&gb(!@lNx$@DBG5@Hzbb z0zH}Pv4Sw?9TwuqPHt74Q-)M;HTU%UO`|LgtkvvMN6a-h<&JB=3s0%#D(C2BFK1h5 zy<^^J=&pOC8K)klXpR+^zVq?KzS!^FCuT<^GdwrEHZ(7IC{QPGlpgD^>O1b;w|%t+EvHS}4L$X3b)VG%@*;Lp&KCdS=Oq@ztWg!$obAc@!}CII zg7pI(=!*V2zRq5)_oip0XNc#s$KtCWNMq8XlVd01U-?b)dTapx8sARLRUC>B5|o zL1nIEBdHzKUaF4clw+i01SQ%U*y7ehmbIn=#zlr+`T?4_q9f5An=jW8vyyM(dt-Pk zm#e}46}cGR5po6_(QaR+w~cqL=eE1LyMw!xNAgVxEnymS^P}N-b773s33iz!(O=k3 zvaw31>7qTXUu$Y)P1-8kH`tp{AF0jMYa4owY9meqkRCSbljs>QMDaQ9fznI_Jy`=tJ>l)+H?oi z3zbvJwfIYPQxb*qeAnd0c%NvJ`4sjBMf$#v^W1RX%q@~@%NdhBBd5D}Rv>?ZYOx#Ge5nG_`cx5tO`BGEYFwbPSthCOycW^|Y)Nsym)lRA7TInDinN$mV z3ClOb4BZsX9908F7{89H$!XFMAvZB8Hj`_~da)2X}SG< z59xD(YQdx7-E6<;o>-&!iFoDYui}0bz~_?vRh)K|@soLxb)o&Bqm=84D>bD>YVWi~ zsadY?&Q;Dbj$yWu=BERa$t zH9fUQTIGDl(mW~OT-#i2ofGU9%Vc8@{V44;HKpuC@Mw^HN&JN`7eB;RW^RPu(;42c z?xneGcE_yi8Kp9oWIW0;dZzkj`s>jzf-M;%_m=xLT0GV=QBQa!6~y)tHC5-eyrG%p zjjfO))pa3dM`~uOl(s70nY7}m=9Hr@rL(==U}J$-(rOl3x#{K0}m0+ zRMm7zW6a{Tk8m7ywNI5&d!^ONwxR=q{bH+U9&fPghG|}@4l5quZO~WA zF4jtN(OT^F@b|!1-%`(`+^RVrvc_lD%DDGao%tju-CM-}+5bo2M0fyupIaF16Z<_r zD>+J>ioW616f4y|^=am8>p+TklBx03>S?BYiu|MUEl&LcYx@U|MfSIr?xvvrxHd~& zRuv~};dkXM@fNR1=%O{5@u5-lW$#wECU;u)N1>jV~+>>~9?TQxd5+(lXMv<&*Oj%2zwJkE^HiBlW9o zjyY)v=qhQusM{$sh#^=T7z;BJXQQ{6jL<#0yzi6yea@8ZzF9poJ7;|TxhivD?g4L0 zzm0wz*c%p^1Kg2l&sdN6nPf~9QEdW)o^r&{-O|;rb&gFbopw8oPaB+XO}=euf290y zj&blugObSU`FsP|5evL(H=fO*i+I_AJUZyE$RAV@?OEE-q)3DLf*?tPU`(+;GC(#qt^ zpSIui(lMDDY+qr0W||Itv!r&lx|fn7M`5%yUpSrE6CKGc44t8y`0Sp^xxeO2%bu7u zAhShg(dGB6_4g0XUKqKW9=u}Hir-&WFMJBjB+%z@xK|3a_Fy(2fA z9mpD=#bnOQ>Xh5Tx0iku$O^s;*I)}o+s9_duEe@0HVZ0r6t^qts$1w6n%~$0l*f4` zWfaWa*HbT~u1-mJuA!#dE7|5-jsS;Mbd@zfR4bGV$@|z7X{pdVxiL19tsHI}c;MUb zq1;SPN=~orrCE)#D&|b{&Y&*_rU!FEXPG%%CRnODHZ%SUe^nZb9UutRHSH5)GwUlm z>GZm|l#G=3DaBKnlxD6dHNn2sw$=L3T+R4cM`=x(BdYp}{di4ekXG^M;$Jy7@*$|E zPkVp3*W~`lsh_hxyHGZrz1h=&ZXCQ3>=t?+smqOx3eiu|+!&kqB-BN3@fV8n8oR-5 zK4`l_-Eh(_GUcPIT*`zLyXzk8H_~nEtS`*xjMw$ww8J#HstU?XLW%W|QiY?5kI}14 zFf=yc@p(Pl-NoH)bJaN`vJ=^U_Z0t&bP}|65HZ@u!HYWBpHZoC2SRgC$H)JLC zU%G{+&(`5o31@BB0oPL4U+-~ka`vL8+umCTS&LW{CZ(ae?we+{dcM+54#9lVP~loa z8LP);hua1h`&W5~c;32&Tsmi2&c2)s_i2CAVDr%SP@~9swj9vg5j!6%pLoZ&k}>>G z(xASin_)U&#i`SdBhJ0fcFyU}v(Af-rS=!rPOviYffq<&h-n9EnyaTO4-l7-Qz|H! zl1pM2*piX=!4dQ{Uv2MtkI9qBP2{BH4)+|PZK1oNieWku#Pc>Hsc^1(}H34;VbvI=XVmMO4 zUZPBLN9;4(CsIGuJiz!d|8;MQceZD%d#Ss(_d}p|xOVtp_-{szPK`f~Ka0Ze+}J%eaamFM4!o5+S}dp$TO6F96ry8Opx6WTaf%+XeM44+6%*^ zXV^Y6RXImBRErz$n)_QHS$o>1+pb%amJ610mYyce&|KF_TUt{<-B+n1$71c!J$bPd zWHYGLKDE?g*7CYT=BLU*8V`!v1}zFDE>-2Qkp`9%DR780qjPG6{sDP}7P zb-JdCX1PYIWA&?yIi_Rg>*kTBg@%w$t(&Q7qp~Qf5Nfm2b=+Ngj!B zk5=MNvuD_D>^<1^caBtv>u&3}>MComYW!-msu0;5uZHcGNvXNmMCg=k8Lt_89PP>#WxFs-B6q`k z!?z-W-?fD>^Eg zDQl|wtM%HDZnN%z_7Cj@?J@NMMGvAHUJ>0B>kFaepYih1Y1}i;$?atZF*TV5%o64q z+lC9XubF+!G{zFq1gp^M@RMjZUr6q)_@d)XDyxC|?s#m=XJU1*)x0^$texcE%IO-$3Im?l_zSxZ`1 zTHc!q>GmpGViqY08tNVkL=pC*CYmdZ&Zvu+~z35TC zfcI1gOAL}dM1QT>+M8-e8K?#JTIOn+_`jZv>f#8|j2e))GBtwj8eGQJFxH8 zi7T;k@t%pRiCYO}@>|lzS4$p_{~rCp-ex*6wIX+}v zAGgnN6r~>7ci1e3r3weOUAn}Z_*6-OUsVKj2drBiBPq4*H%rubNAnubCnBTo9Q1(4~-0+4PK#X?*^ZXsm14DkCp9>G208OA644k(p*#1 zilF6Q;ynJmun_GbF%50}VS7v+u!-gh#{1eq%E#Dl>9bH=7$9I`F>$vL;I;hD#D%yj z?ugao5=>k63)_?Zh4~O37Ag~(5h(6Y@xG#GMl;2G#8=Hii-{^ib+W^r%8;q7iMTY+xZ@b|4Ph3-xMG8x2zxRF|dkp=GEFRiVAp7d9zqX zx_~WJWT`tE7FrJ4+?Ev6aeY_q1LbwRIw~xGmQG6nX`v(wm-rpYk%<+sOCQO_Sc*Ns zlm(yi6ZU(gQn*pb80_Hx;!)G<*^)vUUO}xf9k+UIE$j)a+GtiEBjA?=cY-JAUcSG4)57!PX|hFeQTMyqY3*j)YI|ocr3)(yD_RikuvyqR@^4iS?Q+9x z^Fr{Ip4Io!n$?9ATW}?IUpC7nWD`0s9~Q^)LSkdQaO@0s5d6b7#vdub%w`5KlOoN) z8&Dwl3!Ts3CwP(D!dtLI$}ReprrYM@ma3M5Mn?0Os)6zVS%?fMKC1uH*3maJ{$567M&7NYM(P(Osp%Y`LLN8(|uVN}PJV`=6F;{b{m*}80b#u}*_-V-DOWdj8w zw_?MEW~dR_UtL;TUe{D#OYhVQsw(QjniiVd8jr4?vAHR1x?o-nd!w$})vC1$AHibz zv6rY9x(w060`eas!Iw^ykC%!0xuM)>PRDIyAG5Q$0T6wuz#NJ^3p1fzp+xvHcQG+l z9D)iFy%YtNxN5tqy((4tNYO~OSba#N(sea-Hcd7cvfMFGHuW<^wR_ZKl$qp8A{QTq zTkvJr0rXVHSfClLo8t0n(SiV=D2FTvXpX|a*i?z ze*ByGVdRn`{MqEe#F6-;*z=e(emRziW^zAS#5QJfBQqmH_+I!M#EV)*MS@Yp7!QP2WbhUVB0NR!eFp ztN&7-C*R*e`IL#)6l_jo5`;4tG5o zi2a&ak!;1c;TQ6C#edLzGN|04KBoO(NSae^`)yaPPb}Rnhb=EHG4nN(!c@U@)|6)I zZqVzNsv9UxWHzjA0>bL#qj-bZN6x~M5nbpB%vtNfPd?t)1H4}&y+-qq+ysw*|x{*Ud6t%04kJ-2?a{9~D6EovQV z-fozyJ*QGA_TaqyLMQ~jMNhOpH;w5To*b+es7fb%C%oG|A$Ljl+Ty+0*bGCVd? zk&zg6^hBKEAMy)?FVaQ4h-#%)(vzl^mRk0=5Rbg%s7qb452dt@*VJeGZd+NKXl-ll zWX{!h(YTb0i2_&)sRO?pW=bo!nmHBT7CcX{^cVBJ@tksh%x#ogCC8b&#FOK92Rnxw zMr@44-h}n_TfUQUMQnf-SC-U1(~pGs<00E_$7I(<@R!Ez?WvWHO^!dQg7y`*Ew&=I zNfx`Yn)a@;7Wo6y$X|Fp_6S6L{KXhz!K5-nODnPvNFGo*DNs6(xvmLXN zmVw5X+ODcOq!Pa&E5-iFdhs#QOKffg3qJ}-{tLca-gO?6`*!ZfT%((IpZ7ka+l9(R zPBNF-j?v%aqxc!(ZD}srNW4`o)m1efF?F*HvCX9%&YI3@j+ge^_5iihafUi%8)V&V zS!5n(Y_D6c$|0s;hat9GjPDq46n(|+WPXOv1xL|OeK%lFRM_Ki=j7hV{mU(Rm4USI zNLUenWcx%H#y=!mi}$5ws5-GiwN~dc-ZB-lc&%Rh5r@NB)=`mqW8X$Kb$p?E+80>U zEoIF$j0JTaR9>P4)vQE3Rx1%!kw_Qus0)NDL>sxaP({RHL zZF|*uVi#&HHH3L!VZ370$Tp6A4%G}6psV`)9?EmeJ>LDhTjwe1Y2zj7tD$Yoa<&Ec zC0Z#li;n`e+4u}{sA{#&Wh!B*W?f|~4%BKv2J!t)C&NZqG2Vk{eCqDvj z-33@h#>+(CGAOb%M1Zg5r+13yy*unKM$`G#YKhC zB-trmHu{*E4)$LwaLMoXt^l1)cHeTpb*Fj`d8T^b`86S5WEWeR%NJXc$l#|*3ACHo zs4%H#>+TwzmLArbwt&5FsIk+2uas9`1SUHPK>lbEF+Rl*=ERp6~)SBet6O zuIQm2rQ2+LZysQsZu@ArJG_n#&PvWNj)4xPV*xe8e&4#)yu&zA|3q_KIfZx#J>ZBi z6~<+K^cnLxOokc;X8WImhvgphGrMQFXTC@91if+p`k*t?lVEw+Wc zuZn038;%>Bn>$#4*qYgm_P+LOR5|BxXIDpY`yUp{)YH&R|6RLX-B7uN4B`V&5z&{J z7u&0wkrEvb|G)e(75P^#NGJzm?d5-c~q>3ogmJVFBL=8 zH?_lcZM40$bM%6FOY`MqNg=h&+mYmL3V8`1bsen-VOuRrd#{iUG{C37Dh}zT31mwU0X;qPt{cM4C^f!g(kd{_a`qWn9ClsrS~hl-NV)m`+*4IT8k+G^U6CcpN) zPH$o@yKMt(B`l?lxPGZ_gzj%`UrnxZHt|fJF3uNv!>%eH_=*zIv+S=-lgO5EOUR#E z5~>w!7idRoXpSx$tRHF?xf%OMnt_+c4#+F9Mv7+Y;o2j*;kvHcY1+-Y>$)Sl9)`co zwXBycv&`d6?T!0^(n#$;>ZoEl5y6V0z2X`EPkslFCvU_qa}8iU+&}Ub*4$r%g@SE^ zRe~&CnZ^QZgHuD-A{7$(@UP?otUPeENik46(QwMpU&p8mXkKeif!~HREwJisgTb%U z(NNM*-dGWAu9Uh0xfl~5!*R59U#uxs=YPa-ZU^iz_dsUQnaKU%W_l`}8>ketLN3aK zz`#J^&;|C1up0l0b(7b~Q;B@4#oBxNlKPk?tgfrAu8ZoB>AWRj8DQRH8e{SqbBtNW zW`?dBt6~Rn4$qG{OVElbupvOTFJ_q`WkN5R$HH=m_xo-)>mUn zE%8(yE*O)4CXOd#i39P&QGaAbuzw&uR5j8iYz&U~zxSGaCi-KrX}A>^Pv!^{lA{wd zrQ>8(^+nBe@QN2xc2rN+Z#FJ4(WX_VS;k4m2NnXx^#PVG`b@1$J4Rbi(@fQmT#fFF zi-huGX>kKTE}q8aL<&XfMoLBog~EYL^g;hoUp4D=uuvizz1WOMS@~ zDxYSvy0LPC@}!2=FEj2jWt%nT*`_qJ)%Fo)VTZ-7o1y)x_38R+A1Yb=n!E-wc(+RL zgw2W6STSxfWarV4llFID0X@yX+ZXlqrccnKudT0FXmV^Z|0=O0{!4O#w1K>!9;E4^ zB$dz9y>*54>Bbcnll7-*y6K~(8kOOY?X%6b^+x?ELvzD%&0NKOyf)So{UP5GfAU8Y z7h)85C~_%$HM~32Ik3>z#;fwh{6lG_@2ID>uTi9EynEvB=$}zO`8zTxE-70pe^-dg z1)8neUivcT`nHpnMMjlbus?F`aH?&sj7~!n({%G#Lwhx)xJg{YgV;q>PAb5kivNh# zEc&HGK?j1$ab?0-88x&-@6^IxXLmgd$nrmDI&>LE&z%px%2Ao?g4hc%i6-nNz8 zmPl@(KV6J&N^kPt@-_8#^>p*(^VXyvL3ZOXrVL|^9^>1g1Q8&9!8;Hym4$Rg4Yf_Z zOq6kn>8W*)<0{O(f7or7^_G^lENi-{tuCfkskF);ihAS&G+s3EO%hjQ$6}YbZsF^6 zKl)pMri=PFdiQzkUc0xOx4C~v_$hlbay^{Grt>q>6aG(I+# zwh%Tg)!Dh+`Pn|sQo)+f-pn@3Tu0wuT}PEve^$>UyC93SSNMzXklY=6&I}1R2>Ajt zXrF(-Z_a}%-Ah;%VuDJQ%O5n5~+uX`{QZA7or(ers(- zEpz;EyrFtgqbN1C)|P3mXH3`2I!U`veHyayPGTxVi(U8?kkgUL6pu^|eGjyvANnqP z$9b1}^LaaX8hX3>jloi(51}KGiQMGGe!i+mNE6X9!m13bu4wf7q#?`n)bh^um8$Ku zIDb+dsixE-d&D}&Ji@3q6wvR~lIp>VE<|-KC>;eK$DUXk7mXxBU4vNQE$mCwu*WOv zE${iqGtf7h-WW&=njw0UFZMXmjXxwXk_q2PR#FC3jkQhn?T!7-ZLQDk#~o?TdXCl9 zDe%D+wZ|<>!3)yH&`qz_8dL$YB+(R0k*7f1t5&QO_cQV^^b5>?od27zh;N|xwdbH` zgZGrrOm7aH5B?c0&U&Mb6S>I+!b@oY!Ln6u}Fi%3{N0mzavH$>5Qap)!H%{wKaVzNe6B^x32I zYJ55Vih=atmhf~Yz;%dsOAL$Yp5oUa*i&J=8nPC zIh)s#VSZ{lW2|n#b??*#RI?QAAmSy7pObCkBcqGiX_1+sZGo2b9>2~%-PhQ=+Oyu1 z>Xjf0Tq1Bc*dd(B+=-?n=wvA&Lz)i}+4l-UovrDnr%X#Nf7&Wgl%t*_?kMdX@0dn) zwSTwHv9vY+ZDjOGZMOP?vOQ_WN6O0tA#o_yfy<3N2;~pXr3b+7f0%cK=cN09JLGQa zJ?C5MHwIEe#Uoj4`B?QteZId~9975nlATp!HQBo5#)jZETtY=1O`VUOMPPJNId6a* zA6n~MZkke!jrD2T)2fP!7m$_lR8;eZcpdIp45py8CJyWOu#owk*= zW|#%-KXKIA)K@*MqzxvjDqn;`d^X1(kPtvP~T+a01;qXhQ zb+l)E4P@;+my6*Rvb6G;dZo68;kD_64zX|7+rWv?*(j$icSY(+K(q-$d^>PY+KyPXSMsr-JXG|F=NvP^HLo z_Rm-{F-h1UokPWl@rq2??Db#7E!l{eSzCJ%c8LDv?B-MOb2WH=se{Z2-LK;s+RhuY<*a3)3@6KC&(~6Ku_?`^NjPe9QMebn75RFCA~U0E4+`vqQ&BClWw7r z{1Rh`8p@{X*4mT$vqqPNx8}F6qvk;5yrt_6%$n<{Sul_7w_Gy)ZZPTcX|j}a$PZXa z`H+yBEE-R6OPQ77{lPfow`=?gUo-C!&lZo@bJe@rH^m>IHwQO|e=vT|1-VwEgqu

%-8KPd@@zG+<|#g38Tmw#}V*E^#gmXXv?q|%@vKDZnx&HYQLf}aSAe? z-|~kOQ)A7zT1?~c&EUGgJjm&O;oIh01)tLXS^he7jXb45h#_5{4!f8;>UpZK ziYlZJS79~fsp1s=ZlXneM|1$!j-ALPA~PVOoiFk({309(caL0+s3B520DMH%W9#F= z#4%nY?v>V|`dAzM3DJ)nqd1}*tIDTttXZe^L#+9U{)FDEKcG+Q7U-(!7HErUnyGy% zhf1w{NNyuO<7cooXp8hkSje|d_DQUdzl{xp+>O35O)MTg5FHcU9bFY&7d;mZM{CC( z#GHxcNiW|^oFxqg?ARvs8N>09*hfr*f5Hj!Fxgm<8@A6ag2u@vZp&d~^PJ zvLwHluO>7VAB(G`6xkyU1eD+s!221IAP+|vHWfPu_^}@NpZHbWi~qpi;}7szco)1T zo`yT%(+JPN)?(YS2AB(QHB(UqlmK+%S$V%aUM?($r4N97y8>9K0n#{WxwJvLEM1dM zO82FN#7Uy`U3vjW1;ij`Ksf+d%7K7w17r@MDC?toP%5FKs3l+ukD>&UP#lHPcf_Nw z=nP;CE27q@58w=2qMCp;?2kskw^o38M1W`uLk#MMd>1f;p8>TP1vFy@pe+6Jcfda` z2jt;WIL?PQ&XZ39y6y)cF>e5_@&(-ULB0vHzXV9guYkym%1S^xHb!lMN?TMN&RU}O z5I3od$^g}7aIG=?uLXR!L(Kv0m;!R+;3+pydI$*1jq)ZyH6E0&1NQMG&^QN@*$on& zFE5if0|xUv;3Ac%EY#T*YVCr$19GwkU`J~LKC3El)f9~cscr&%+bXmMP@V07vJ({s zG^7QdY64G800dteAVCv=5!?VP*|PEpKvLF0Ir2@Z3}o}~ly(7v&xfr81Yj*JD4&xs z>5HfZwBuB{99oXLW1FzuSZC}ADvzGa)8yi^O;*W0fqx8i@eyU9x9AJnj5-2umjE4j z2{d_&n!R`eg`e&;a^SA z+A@G!Z4RyP3Z*|93w*Z)KAHnZjiIy#>}*ZArxsX29@4ZjaA*g;hM|T}pr^-RrB6U| zui@-Dpki-;wjaRvZy@tiK>NLHMM@Ne{}I`SvSbU?lLA=KIM{VQkZ(mqK^sz0DbQ^R z_~6I~?b5@U15lM#qz7cY7U<>SejyYNF}Vz|1~=G45VXs}y>Jm~3j?+{B8T8ENmjvA z8e{;ptr`%!IQ%ZceJo&l!SMz6v;SIC6l^dLi~AAm>?4#fU~xa7xS?JFYV!SSJuKjh zS$K{Diz)&wE(U1N7SJckfKAi}$<>4Jjo?ZPu!mMq>cRD@z)dPLAs(de0}O8#96tbl z_7n7hd!V&LfW18c*xI9De+R)9&&ikNYw~@N;|tJCCP+34+z`Np6)2X1epCnSyCu+Q z1;^H)|Biq|lQKZ6v$0GB^OD)j$QBtcer zR^UJdpf?pj#i5h{`X%954kVrTtuRPa4`3fqP?M0T@^#3GL0ZD+-DWB%DXHAW$d*XZhfNLAYv%Vg*_{xL@(F zOa;(M6l@_6X$}hvKx5|}zyE77ZqRa`uKhrZ`S+eYT6x%Z1eEkpiynB)tC0d4PB=P% zUr6nQGYhmN1%A^(tF-VzKn;QI#G#(pzZAnjGXy0JwdVkp07%#e*L=WL60UMUO8^TO zp^xVw^Kodi2I|s-ModtzxR~)x7sy z=;2NU*hx&*LZ4Q_82uf_gaUw{_CV|NdiocjtASd*pequ^fF?o#pq1B)^R7tH1`^Km z>XG31ACx}zFC_!imp7`JP~JNM@?@X_c|@RQC0x+}*B+p0hNtt!3<0j^Sw(5YKp!!K zy*-efU~$i&j(Wh)8(9H1!NYi#53D^)z5@R>P~%VF*bEqA5_%mD^xw!XxLz7^5{kk& zEkV3`oV=FYE=8qJir?j9d|W($mBp@ucVi7IBQHhI(H@xXp2~-@wlb35qHlmL--I^7 zTtuK~-iRwNfp)C|RPb-|P7JJCGGI$lnp^{|fKg_tTp30fBl;wFlDEKod>7`fZ*moo zs~=kRk6bu!WPo1X720M1UU1-`8uZ?DkYPGnELVpauD_gulBgZXVjpC`tcMmYhCcg1 zo(VJDeCV-Fq0a+e9%X~Ytwb|n^jHCXT`BiNuOUNzs(c^4lD|m^8-ZlW1g+W(`GX6k z7l3o0Eu90Lei+(SOcG@y))8e(7v$#XyWAK)N9B{~Dyj>kUkKhbum_dIu1SmJw;;RP zFmp|m3j%^%hVk)++z@@1y8*9OSY6DOpP(hO7dq1w#7OVp6)72DjqTBCz)m|MkD(I0 z{h%|N1MexY!FcsR&V_ODf^<_(0Ih?PT^@{zV+&9lnBl*o5ppZ(6)J=emamI@(BD`- zlqM;V4WA%C7w4gi*g%w!zJe7Xcx%BE=_5R`97cmCfW|L^EkUvf7KZJWK1(&Q)p%u5 zCC|f8N{fNFJ?N!$9Cg5clNX4uu6R?q9{s;@?_BaXX${v z7#+q-%ALg^>PHq8m+}u`bvGR~kac)p^foyUT_7f7E2a9v8!Uuhl7AQ5$dh1IMPgUL z_MXTrS}!+4+oe)SiyP!g5QW=^?}IhnQTZx11|lN;2L&NCU4BxgtzVx|RKi zAIZTIyi?p6+Y$r#zkL2Z+jVVgb20SwyTZ55u*1x+w5!1!PCbt>oL_si=Y< zLnnnYq8^=BU6t9yUUU#&DV!5c>ON%Q*xUGf#W&4kA%okB_NkqyZE}m`B2MGog;7{8 z_&T-nJ!~VM=ZC06R#qGl?#D`F57ip1SA2<-qRPM?#v6({%_aFlVmr2gm?P~>u2I;C z&cX~yFPVw4cu2~TdkDj&qvT#}xY$k_iLMc?&?d1f*yS%od6WLlt%7lu%p!H))Es zOQvgPl6~SIled%}VkN&)97S%%42kz>lWLjxDwzVUElRHDXC&tWpL(fMycU+Js){Zp z3_=ZMf4m3ZL_UM&VpE7Wf=%*Zx1>^JBXNfKMH+&SgXqR=ftUWkj$u~f5dTPEu*-^r zkiR$xn}dIoGR0KPiG3H}p*GlA=^`2`Yl#%1c;aiaf+CY>B2|P{$u8*?-VwVjq|5z? z$#QF{GvHW@VldxIedMR;1I*8@P)A{tI82q7F?CZODeOU2NeL|gwCxx21#w9jm+T37 zRrTXqNhFF87I^c*NbMe%JzDC8y$hvJdUG@a(ZT#S!7veW@ct~%0Hgp5M=_7u@SM3aS6QJ+14(8 z5c}mJT__F^w$aLLqtwvmWVg&7bfDz5SF)aHcdgfsgCwoS=y7p4d2AIIiIAPXvi=0~ z`8%s8bQCwxp2B?c$eKbXS?%~fn#!ly?ungfP+Tm;>vr>;m8=}4CioFL}Z5^Zg zSqh&AOLSQp`r;~~qd&EUjYhcu^Mgl>GawfXr{ zYd@HAxlwK9Cr-#p))Das+iYf)eNuvGH&?JT@_)iWM6G9Gx&I`M>}Q1N)IC;aXIFNR z^+cTfH~(x-6_3b0_!st%m4*+r(%NT|SYr}@CASw_@S$2hk=RS~W;mz!N!w{n;ZNy5 zJ{M<_hyBePOL@sSYbJY3yW6+hz3K#Yt*x;0Piw1kgJ!V}wV$)n8jD#&@~3o$ETWmT z{`M|5FRRR}@jg~j+eRsudE8t_K!g+fDFJ%IzE$GFPID&pik2-X4AQq7_och`R#r*9 zp|ML69n~ymJQAF?BEm*JoAsKclQN)!NPRt-LuK}nx;vuLyNik%Jb+gGnp0^2RSN8EzJ(fEJ=5_ zXEEAA;fbpd@1bU3<7}5*b%n{{`Q~={Cz&i6+;2RyW`hwDYj&pFB#)$8mh__9km&N;erBgpRfw@R8D6t*nf~JKmOb z^ap#Rw9t(Xft}ne=HeZNOO}nE=SSp3+d8TkbFD>EBQb|~h(Dwk$#_k zZ}n`L$%W1Yo}{#(&z;*mnOXOr)6H7;twa)jqJK5yXUwTxHx>Io?jcgn@Fr~rnc-R> zzX3<O6RPGbi44A_{GAsG0!R9CeN&GY$!YpAGyu*33d5cL`9tiKUqPKvIMDt zV;n!BWwM&scFEJ&V)narKBV8q9z%VKGBig8C=Alu0Vbur&1&mnu@xB6hQuJD!Ob!mktSzcNKb&#^i zpQLwm4EsvjiB(uPdW4jb(~Cc?v}_*hVjUOvTdyePbH%?nI3Wz306U+IGL0V!uT(r~^x2FRJ|PkpRd-*H`v1518@Ro2m3ehj?o4*SEk#c@Nu zs{CP{b3By408wiy`E4EHO&R)PQx{8kE)lOb+}t645R3B8{E2Wu9wAI&gY+r%r@fx@ zUm?5xFC8I`mR_N$*IT zc^#{t=$6tkrd=hi9kT5&jjPq=$8s#rs~x-va|=Ce)vTR#l(9_MEf=G$`9Vi*+f*%? zt+e-+X3~#lkoU3mwvQ)ySU+_P@9d0`RvS6Uduf2RnLm z@6C@0eaI1UgVfrZWWMHu$vhH8DvBknK0x%M1YK-m&1Q#K8}SX7Ue45+WDe?kO%=}Y;nrw!#!8?O)pi|WHrr&BHcPRzV6^>ZTrhT; zU5)elO5;yj$Q%v!%0q3LS%f75&;AR#Ag@sY$`iM7M6bg4LbFm;+idQT1NQd9LE3;6 zwr6(+khSb+QynvW&HZ(~e|m5F9|!zFHBjIG&_C9nTjrpBdWQzu6jl7l(JbZZ)^rHr=;fA z^6Rx>cMIs<)ZdKE#uasTc(?kPh0R8Q#R?Q7<*>Tls2 z7yJ^HH6}4?bjTk)A!-dO;=Bojy)8X;J!hTy>}{kt>5;Gxb;ZWgLtp{ihBCT|x<);l z+9v#0xQTL3X{Kh={!>3{9rXc5U!#w4(wJnH#aa~1bYNfqMe~}QjiLH^eV+ceu4!)- zuIy8Ms$I#hqzf-lHmILX$$Dc>RqvVSow?mvaJKGuj`FAaonWGN^-d4GkFmw&i>eW- z7}X%AeoSiA^T0jm3?92Z?ifdP*&!wh&8_@wBF$#j)~~5`)Sc=wC9iTMwMBTSa$NaM z-H&Qk?@$pa*4&4&&8^=x9H!UYtAEp8Xb1G2MiqUYI!8IEmH*hUUxV~8LrSHOs_0cQpd7#hvO|1si+a%>~I04G-I%;LjtM$^JXlaa= z=D&JRGnOPcc8aU$3UQ?`DOkhpcFpu;4djoR82ckI+*Q$kIaY}+7fSNk{HkA!Iu$JC znPpD{j@EViL&+u#;gwOpE-jCt1N5%y1MPQXul8ICh988xsfqe%-431VAfqQuO9x^c zV~nN772`MawBB0Vq3zcj82R<$`gpA&>MoSf|Amh3mO4dysr|0s(CZt!49)CIYtf4A zFVuqDYWjr#Y+dZ*$T4A~qpEkZuZbtd<#j6Ff1`#3O1lVjD@B57LJI?ur>486&lb$> zYwg@_yCOfcueYz3Mp=LGi&h-4lB{&2egb@g4CWF|Qs0DMsh^Bh<}xV3?B;E{lAWN} zP&aIcafA*x2ScM%UjM2WG8z~cw4B;}t%Dxc@@eO$VYT{@eh(XYw5&=LD$C?G-x}ZaK6-v| zW>(OZsI<0IUurZpR~b{ZG+JlmaR%?N0HI@#R4K|9}c!37Y;R z{y^ZGp9G%x&buc#o4ba)GB}>f*QJtD4{-o#WR0Mk%sSAesd~J23`~Ft#vn7Fnc0{P z#>fYH#B>;c>f?2$w>K{9^R&WRUVW_|r4P~CX??UkT4w!}mPzZVP1I`{{~E`PZ$>rK zG7EzvRm7ZQW~PO)ny#8(%n&_*T6jau3iPohf#U^ypL~}GJF+^DIu_f*_V2Fk-c(Nm zcQH>HU*|x@(C$#5z#ZQ_{|$dmUmMTw?r2vFXEkRYM-`h_j*_yIMC&zn+!OP?k;8E6 zt@L|FJM)!s$0%%;g4)Gx9z$FI)*fr|dL=zs>!Iz}rs;e1W_UE$Gs8l()f?!;^?HVB zq&J(I+sw=6bhDjl8sm+w#tP#AtXwrCk1@!&W<;6&j3N3X{Sd8>N}U`C=`gW}ZJT3@ z^RQ#Jy&))2XBpZ(P$m;*@5``b_bdFTA$aq+P^xgLR?a8ji~*>_)y(Y7~GZR-kI)B zo>*^tx7XR(-a+msZYI5f7UriN&9cVdI0^7QiN@)*uKj%>Cq;#QI<#PS@py_pGWrqz0GbBtNVv@m|l zb+<83pR8UCuMJnl$<zUnxM5;Zz$K5n(BPD1MKm2b(z*s->>h}$LUS= z0=lAo(A;_ny^H>jepJt6%)`p*XXG{B>Sy)Kx(ABidFE`PwG@&bh%MxNj*9M_-Zt)3 zM;BKGUoR;A2M1Px7uqB4a(wH!+fmM_3!&+OSzd8#8Gbz^#wRzf-c zCp~E{Fs^B@v`fYt^9F3Gw6GuqkRS=zfl&^UN6a6IrOD8_z6^|ce=Ax(|?7+MuN7|aAN zQa#k~oar{5GaTFG3uJh36Rt0Ptfrk|C)hxOS7X6{C{k1iN3^t@U*wT7Ca)>kuxho+23>66+lyd<0>{4jMa zD!O!6yvnPv9}gx`AWp1@<}6gj zU2Wbp`@l;~Fn5?0QJL0hrlG5Zcw096NBO;+&auZ$eJQ@-9=GRD|MpP3sKe;T%TR|{ zB|b5}L+tA4)=<@-2#pW^=AYv2=e_D#=i2M&WzR0RCvB0FcxkFwL8H`g_`Fg>+o*j~ zPh+&JDsPnc;VUWsCZhyS%AV9SsZ~;<~d1e$E#BAD)5-KI9YY(S5^))}ogHB{*$es{ znSgxk3NjdWoW^qz^_He*%ycX(0k_#^0kxWd{n5bJ(l^x8$5YNfJD4l3@=gAp_a~PC5yUGZKAEwerQAWj7E~t z!t^5My-#mo+~-n(tz@s*MmB@}U@Q4&u<{8%z;3bZJQg*5Q|Ucgjg4npm=`&f02_*A za}L`c;21saAy+{!@%;`($s%v5K*`{{K;=Lv&^082uXZxJUuYDVwZ8|Z`3HM%y0W|c zuDgzJz_|v92|^cIM{qIZDRy`k?nHkt$I zQyRzOSriLGFEfjs#7taZ8BkX|jg`(if?j-MP5CZ<4{^W(Y9S&hfeOs4=n&e6HAjJI z*%l|)lk?f^&I%r%ccOca>#8T8e@mcK;JLp7l#8k0fj)~G70eR&=`R@A@89k{;Ck<5Jw3$kO{nnSa6$O9d@g{>eMP}S+qu2Nney+@HVO_&r%ygzmpU$p`1iT z*#Y5s$`myo5#lOT8k(sUKyRArJ@pJmteK9^p#5lTx)nQPD|l)vSZ~$|Yv~!wiCWt$ z*=80GZdqOalRh!WoBho$bUB^P{$M@X6job!EM>4Cv@Nn_cT9Dz_YCzt^quk4@SgGC z4HgIrftvm`{`0})s0&e31KjKNj`F5>rn^r$GT0~B&f50NN5!K;N$VhL!B63KMDghm z6V^7S8mEj^h_V01PgSEiDtTwu4lARTg~|gZNy)GD54R3iRua^&N-`=@eN=Vr1FC*3 z*ALS$PIzyMyVl=C{1RjdZ+-eSnk3P;^hS;KraoYHz zpEjl$dD&v&i`>)Q+pW8&xjuR9z9as`fEE}S{DR7#m7{&38vbX#wNUAP3QP)|^DcCM zbzXHAb-a}ssyp`-8>3qNU*xA%ncMj#whe1}KQ+zGI0*{T_wW*z07?05yf8)sSIvw% zId=81GE>Q;6jJ6Z1Jv7UQLQv;WM_aUGS|3d6fy^ydFUMalYW8~xK1g}&2nHBm4v+- zjM#0S@h48q!p12*&OrVHr~MlI%riP0uP}al&9j)Ns`MgVF21+-@-+1B^H=w`^Ct&} z1(iSbk-(W9e&U=QW?!;|!l@E&)Ubsct2asKN_YbzzqCKr$~FE13b7V>G> zv0d=}3+XgO#&O11V;*7~%baFX^yrn58%XkYwK05`aq3#Nfl5&!r>|NNb*A!YyEM1) z!&qUuXm|QMR@!Jfj?SiI=_Oj54P!OfQQDl2Ha{8N;kiuJtHPJ*g8$}(Pk#nu(Hk-N zA;czAPy?nVTf$$lVf+&h$Rl0-{c)(-xd-*g%SHVcx*JUEf91=C>Y=u%pP_BRyMbeV z!*|Uy#iiLjuxTlB9a~QMcj+EECEVfZ`9b=?%xR{jbJ;%Nz!!NRtiN{VbEBLw4gO?` zo?ten*@4!tGXBvrsnwNx;g;dysQhzH?TXdX5ph>*BMa*9KQr=~6U>&hFiU2$z`uOP z-h&Og4rl&))Q6dD?1oQP2%kF?F;tvhPwR$CSc~w%a zZKXWz1EWH}#jJ|$8B;W-eRS?ntgn`Lw$Jjv^k)v<3|hhWfti8YzSgdDsN6OejEYUx z9IF~Qj0E)`G6@O%hxuH8p{F+%AQs#YMEC>W4i1>#`b>*swb#{q{EDhjUrt+q7^9x< z*Q#SBj#nlshVodwq%|_q)9Y*|KLaM|Tpq<2u#A|a8nhn0048fu{JR|daLG)IxcDTh z6zv5zwnR$@kKZ)P<3yd#cCdG_BU$(+o);{*bwE4gq{7ZN-qL}?p_VakV!y;D$Ciri z6IB4{*ASoE*UdY_ThLd~p9p)G&;OUFpks}ci5x*qGaInJUV48c9gvO;{1_X}u3{w) z!%qGk^>?Vz75sozT;tWiz^eh?jcVT3@2dTjeWetM@dwlYX8E=qxyGU(Muw_ zoo~2Jnbu(o_(ChkFQ#8cVGd5AGVp9~!4G{3ziyb`AFD5~u^xV>VKAe-xysB%kD>n$ z*=mlg16UzvNOrLdaOzaCi7mTxpt~4qA?yjh0w26G{2eh=BQOdInU&r)o(Jw^cWX}} z?+$N%ZwGf(XHP_vN#u9l#AK-Vo(#M8MhU6g)KWS#YSGQ`TPUn?8|=6$>;^qco3m0_ zS@-xQeh#-;)I{9hnmz>HI04975p}lGOj)G-p|;WL!3%w795k2F{y1X;JS}g**W)G! z7wWi_!wnt(fzi1gw`1JngYn#ER*K!nw;iX^tUK)dJ(doCf6Vh(1HkcXFId8Ap!@s8 zfYem_S2`-ama@wWWS4D>joOCWsePn_I%+$AcUE^6cII$qbEbECoR-7mEaGhHtm6EM z_&UWt+HTmY+ZxNYrMhAtvIU&1V%B)D7ap=;XA?O8Uaz`9MNnUJw4k6SG^AQrI0 z=J(d?>!lIP|3HkhRXeEN)lv{6SAvB|*1H>bjZ)^?Uo+B}-DDN`S{}4UfV1TQ6E8QI zR8_!f%#CcE0FGE3*abO|gU$z*V|SqQi*cvLe&L>=3L)g8Gmvy7D=7f1JV27c#JvtS z)H{I+QKTs8Ms^XK*jn5os$w<7fIp<_@=W=H?69@8CE0xTR`&V!llD*cfFqkD#_`-f z&tB2~+}7LnMy`oFEWF6%#)-@D4d5~e_rd-3TGP3JnyYp;mqyX)rVY_-8RHY8&EIr4 zkgpra^glv$qykmypl?9&zD%$(D~%MRp1A^^cO}?#JL`uY=jOwaYsii}MNWbNRvfo@ zY!^NY=}A@6g-jr;$suxqT*lA&Uysw|0QrwBC9}wA^r;@WJ6@8CJ6&$#j+eu@(d8m; zGjV{;)`$!tYsoD_aGyy%+`=+l+zDp9DCLqGN@Jvb(swC`+)(Z%PmuqW*UPKr>GB_7 z#*6YLX^K=_x+@MA6T}l}fdy++On7R|v`Se|ar;X~eip4xU`J?s>Y*3FGU@`9Dv0Ri z8%~B)L&Fa9nAyRY?_thBr1aE`rOoJkdX2hSJ+=_{krd}+z-kYH1GO29#ay_LXEHeI zkHA%r0*|C9`rU}MB5m-~9FJ!Btu{FGrATo+R}gcXiKN9HGf~8iw*5qJpJ0}+;1-ut z7=f$!?JKVCiY9sRo@Tg-WdIpL#*?XJHd%mCTSs=16Bw2E#6Yel0om*VPyiGZvtxEe z@iT6w*-2)i*QGJ4&u}}qKM>D+;P3cb*r_+TrQ{`lfyeV-j~BSV=Pm!pzwqxo88`f>&^4Hxa}f-BpA~Cm zfL$vL%T~i`WVONVJbhsUrdadAz}^O4=OwTo--Bx_gE5yCJoifAS+>LdMkB%gTLQ+~ ze&L*O2lpF&h3-KF2Ox%|hb72`eNYIiro^vDWc?JvUviSnSV2*El|&37S@?kaknUi- zaGM6$mWyCjMxy6kgcjg(!(+nt#i0Kt@`NwJn!bcN+lzak)>w<7n4f|>nFd+C(cX5r zMFu%J+$2-cDuY{w3R(HAT)1Z_6UGOnh5zp(9r~FGY|kqbQr=Id?nM#3Du79<%VufAIQwn9l{!c_iZfi>;;Ba%&}266>%Jz&pjK@54-=0P8)% zd4CAr_Ir%E0zRc3Y}z#NhjOFmWqxTMn*Q1+J+McH3Zrq$)pYb@0a&g}g%!doFexLC zmBMn2OCtXM4_3)!VJyaKAa+()teob+kE_97h_LcAV@!i!=l)Nn5K+H8#ynrgOrJ$- z4nr@o8%l|-IB_F4fUUz0So8lMYw`PfJT~CDO?br?yly*Qy$kQzi+3Kx`;X#NPUEv8 zw~<9S_7Oh+gI^r`S74=o#Qy*Ji%D*xrvmud5k9sLEsAi_k}}pb?~+$ob~sZ^XF*aL)fM}XzdNOI&zoVxnCNgQ&|7U(W8_2?d-2d zM5S;Zt2ENP8(6D%@xF+L-~~SC9X>6>h5v%bH}o=c8yvzlJpR{L`PbcaB3R=D<1gX= zk%tXq@c;Pn5q5rrb^pHyN||A7BCPo+Sc2$ZJpBZWQF`z2_=(2nMKl2a?|bZMNrZDBVbZG@lZZay3;zETtmue> z;4?G}?|!MIKA~3;?tg@f{~3RKgFZil{^cn~;TdK(qB{7Fz5?jNhW{OcC00j1jFK0hPz^h2 zs4xNN#w6^dcKEF_UYCM7UWNTT7GB9gI?agHngc5}14i^4p7mgr_Q1}43~S&Pa#$PqA69#EL;Na^1cP8ESVe`o#LM&2 zVCf%+4Nn7!L>-JvdEEInj<@D(5ED#Cd%EI`e*-J_2Uzd@aWh;y?6ulxw~JJSW&Z}% zS6{5|JQ$tG{`rQP%Z9#87n0x`wSmvJ20hX+k|EeWMeyP*u)t~G2X@Fg_JR%O?JyH7 zFpo7bIxnzy>*G{Xa9XLj-L4h-It1PY$2+g%#=IOD;mB^efnDK%IwS{1vMTzS6TOZo zM!sN$+o4w|j@HhEMcM?*{2b@%D%hiqXz5_sq8ZSm^@atT0#1K-w0#I(+X_~_9`<-< z%uZI!ab&-IM^7Sl@H3QS=U^pQ<7}(~tj&d!Gsen{v!*1J2Cbk)Xa*K$dh5rp$YKHR z$r}eOW(7aSpCS@S1rzfgB8U~x7uJW`ttRh=Ti5pUD>#uq;w=8o{nl-q&@Fi(#LpAi zKsJJZv2Kwja&bpZ_XE#c+%$2)8}R(*YUbFB`WwZiVp0X!kjvQ9IdVGM*hfOhHJGO} z@2V@q1yb{+UQ8XMG(vUw-c&;D`5oR*HvWmdWc{FI`C#$bq_mUb|4Tb7tu1azV47!=djM{V zXyrKMyyZ%CkMweHTi0T_5ce71Q||wezFq!0>sz~@@52X;C8${^kd25eVxbSo%5(8F zd;pmszjVa7`npHCTe@N#Mdc1c9@-T5jj{zAa8hGFQ~WIs;DiMN8vQU_iJk(^_ON0 zziJgHYrqA1i1Ys)V){ztcUvaUvtXmR&1pxbOP9WLLdm!#plY;q2ExAtbj zjTx^|!70jJSI);es|8ZN{Am7T+0SmN8`K1I6R?5LtOTuK%+e+!^W!s$Fc*1gTaK#- zs(5A3UrwiOIhn-Ym_g%~e$Xh1sCgk6_A~Jj(hh?sIJLXNh&Ey^!uhf-~Kq_~^JDp{aiEY3V8It?geKNC~bEjtSQDukmbm zQ~|$iof@M&Ngb|a1mD1;-`2Y!W4uPYqNEKk4}Vw67*kOZpqgC6Uc>2jPIsJ@hlw|U zymsfgu(Jl^yqN~R*nS!`il}2#2Pd~oevs@4pFz#SdB%OS5AeqE<~U=qJ_}5SFQ`od z-yI&+P$({%GP{{i@23t^I-|0E4y~y^(ole@%{Ja3?mMg<1*>GV@ycAsePC6T7mG-f z<>iiizW1TO(;SWOk+3&DQ~cK0?$IYuSHH6FnD@T#jlWs2WGGW;Mqs4(q9Z7EX8ZL_ z$}ZfVaasLcuLdMFKln9`lGcOZxHhqC@nr@xP zt!~{>w>U{JZj>{#((lM!Y@{p9V@9%`9b5u1eOV)Dvl4{{_?C&{7?DEp?2?kC4XEm$ z&zCV&E_z^eiO>XJCs$#65nFqE50{SGVmCucp$x%^z8&u8j@hDWsYB zM(iN&6n3#@#sIZ_I6nMJnP$}E*M**>x$us4F`Mas0Kso#tVI=-L?MC9f*Od^)5d45 z4{iV}q&C;CfK^tB_5mW8WHivrX^E)6eO*g5Uejd$H&BApP&w^{;$RaCv(p?JRbdx- zg!@Qu2)nHdJQ=x{flOsv_+;R4kA(t=6)xjcIS!xop4ClgPFjc=q&8A*>7v+3yop$G zB%*AGI7f_@21?tcad<|QHsYSsLSlV!t;of`(st>Dv=uzsCQ`I?U7R460H-fbOfP1| zPqdf?)n1>F`G`|6&TH=fNw&ZQ`F zUxR^%@21JL3Y&?2IfgYrej*FY2{v*X;QRx@Sa#x^7|VyPw{{3K z$W`ch=7XjBH_(Y^IE#l{5BM}*#ri|IKvKj8$kKNZwsI$XNY_D0^T6tcb8iYTr*?=J zok00^^7)9k#{qlV#f#y*-GH+*J$PYN*ex1l#f7orWO6utZ+)a8S_Vw}QP)j!>nWU5Q8CyoVtk@3`%Px4&i-q%KnRrjEj`i|M z>@9s1vx)N%RW^nvc3PND{t`cmXHofJtS|^CdJ#mE^WXz*fTz$6I?D!_nP{GmPsTT2 z;v1m4K95r`#CNdIV7~PN(xB1}yfJ*Re(?3~0VQlnJJAO~7{BoTYz(R#F2hJP16%qA z^e^xUg|b$5R9ILHhW%D1Vy*1vSB10WFxbgC#MX$Ga`H(mfse4tk%eH^P7|Ltp5*(p54+@7*33V&Psc@JLFS^*UJE$hk8 zvEgh2@RgFlTbdvvdl)t-ghypMi}t6d=}kJ4HericE8dsoW|`@0YSQ^^5}U@#@(nx* zHv^l<-ZjI%UdoR0xroM2LoJuh>TZn`K3Q$C{@Wu8+G(W$vey-e+B&eeGm4Ff9jIXk z5-$!Td4%PdqXM?I(mU*TLiPh2aYz}(DCrz|hrN27ycEM?8)>;%0lfW2WVFzoycJ)9 z*INho^4dWGt|8QfZnT@Q5xb-v8BDy`cU^_-uxMKmk(~suxRTXUF!(l%@*&)iJr*s_ z2^(}CXm~p8iMG}lz8Bo<46G(^gIj3FGZ$|T-a{sylV7lw!eg6&`)bc(1R4T^IgcII zffr+Qfzf3`_1ghJGHwDjo&ly-v^50vvUkIqJOf`b2rjY(pZ^k8TY}XY-fKulO$mSxdJSpHhk&Uhza^(<{QF;eZpG{2QdGO z5VbZztY_iY>F(A^9?{N@fP(%O+B*c%#%3TV=b&-CgLq>OC$LxR5Z?&!dZU2Wv;`_V z264$C?Cv4(h41k5KwCB-T5XSL?j#V5ywKsE1ag&%JW&U{c8TD@8Bz+G$r_03zv7PT z(a_e`girn$n$hlvhQ`9?lt$Yk)jH}UPHKqAyd5H&Ina7m2jUWGQ+rs&-a$nFCzYL_F9ZG2C}3W#=Kb7=b>%h8Ctg;s^>Ib$81RE$~R_?LYIJ zh)YCa8NM+Z;}}ts@38VgTblxPUKeW;Y6jc}&h#7NvJX~)Uy)Kz#Oo&!(Ve!gV?C8b zn?@pjiWb&Ey}JWAO+iHD4DlXAds@M6xlu)4hT^LXVz>T?g1-trQUNDb9M*1D^yMB< z$%@dMrbqM@(b6wL-Hc=_H&n&(=w(G<65p-MINN5UUw6P_zXsd&96cKZ!KbZC-m4uflO6L>~Y7%;0lqtgpdl?8V#&_=F_vtQ4RPWrRdTi_yXuoQe?yaV=gG z1C3&JM8)-m2rjS}s9a90zsy+4XOVMwk58Ws1%5kJ_ecfu6ARUP4Pk{f9liR2&&~lW z7K?K~3Ux)wLn#~u?6nhSvnbBFSe#~gFg6G98V^=`q+Ur$Sk;QKc_YxjjnJb{F$^Y3W$ zNqp8V>=YYj`#9!0JvgX$u{syRqj?34<1xl|1{C;q?1rnr8J}Z?GOUZ?nAJSk7Xi%A zO<*ZUprwC>`F)L5ofEx2gh;yyutF%x@%V=MDFftP0@8BGsscQq4*KcKj;dcD!N*R{SSm8cvL?iqm=l>L26~PGfdnq2E8yDlbOpDo%+oMrH!euj}af zLs;~_=uH&XXjP0_6x#dD%8q^R7aHSVPk{XB7@NWvr!rVui?A9VV3%h`pGHA}-WYw4 zf%mZhvw0t$#8jyDuUMI36JyZRJ-|>hqdx~>J8NQg`{C6LV{rrHXu{q_FpiJFN7{>nxdJuxeIEdE`MBjhH-bB`W2y3Do_R=8i%G>yqAkN;pc>giX_!P|GUaY~= zz^s=7y?zb!xECz67yl{(+oEG1=s+0D!Fstc^6{9>SJ>I5uzEk?U199YNcD}87?Tk8 zS)>lmbBx?>jL%^_pj-t5zaZMX2me0?FDp_R3Kf99+`>#914eWREvkk+Zbx;7k5~z% zvF37Nbzi|Myo_0?2@BC3YpM^dQ3tGYXyq_Nb|7-KF=hhhtT0wcTEx)zV3SKD1LQ^K z<^v8y+8n=p#th89@BW%$MmXpq16Lz?*o51J5H+KFh>J%!WGAf*#~{uDXfJ5Y{!YdoMaQblDojSW?Dso zrtL*Fr+=|hhad;D4y`(awRj&^rW($YLHM4W$fOlOb&+ydbI9g&(vZ*6Ij9gf>RdVc?%o#V6v#ynxfs4xBcp;Ii7nFL?pW zFigk*U#mInW*l}BM#x%(DiCXhWU^2A2P-)hm{~ra2fo+=WV4&YQf5aCIu5(=0g#7E zLO*=cU|6#KLPHxfkS}oJy@p*cjdmBn z7}dZC?Z?{rh_mMfbZ=o`!zr-GMPT>(V{`_=D_UnYLT+g{unrMpEa7v{VrHh{o%3Kl zuER?GZY3jn&x@V2%=()zkN{?b%fk3E(hJ@De3o)6Dp7*>&h-`inT+{Cl3fB9ae;hRYCa1Ox+_^~I) zz+PlWL|hY|Y;#23kt!HNfyY+BUi4sw8X$+X0olb6NkM$J3U;j^M(Pq)Try6eI;d`v z5q&$3*fxVDS;_FjJ*ZjH3G>w#`M_+J4Vu_>d?q*fESG)n?w>m)cq9j#}= zK_WqCco(PZNgf6U_kvbpcUf)V41>AMCHBmOuFqJBo4Cdqe?e`Y6)j8xOEMi|@;N~2 zZ1lD{(adXZ0&A_V{y`gt`@vd3(Xm2R9d@IFYBoc?V3`sv1} z-5XQKA9BtWzp_s>3iU&d%SYsbwhs2gwpCJ$FoBlF{W^V(1^gV@BQ3QRbQtzjaB^o_ zb8-JuU5k;NvL<)4rL`qX-N;Jbj%G5Gje0aEuYq&^UBrIz04gVpHrIpq^?>z;NB2s| zK{g0EtuZVutqQJ24zs6u)LaB+*H`_VCaMc?Ls5m)0;$(hiBds*s}=$$zNu!{HmG;h z{@ND3JUA-V%`Jvu9Yajvu!wfD2y28ij=y5ivGP3MD9 zdm|IF@;zWZO0$OMIitPdG_C_l@5yvTu^;&bR4Tk?9x#WR15F(W>nQpO9E@0FnD#^I zt29?KC^1TYwXD`gd!rsu%cy&xflLqG%2+iT8ZFI;rp1|!Z3CY%S#0LNAEQR!if$Ua zKkAt0sI4RF09+D^I6U4|Z>%q$kNB7;(G|2c5iYZV{Jl^^?q#px5S_(b^__`wR$&TF zG`<)sSw?bGDsCHLFX#x{o{EEoN&F?as69~m=00{yc7gJ7^r2o<8vurGTI~~V7Yq@SX?n`s5)#eOf$3xh4vP0f$|IL}pHN=t2c2}$_ z+yW!J1TO^M!zx(CSFF4_4mBzbb-G$Xn{S+;TX|!lEcsJ-2A)<%UKNPb1#>7G9{iarP~G$gTlR(4T=UXy zQcq{tdCb++Q_wTf@l&h=UoW#m3p9)!7k4wwt2iy{y8p3zt6ZNoHAc`(xa~%?UvyM+ zc7|5yiWFtFHwtR!wA*wtQRKFcg)YOL$KAp)M#^XXfVxkB_AVZIpiw~Ht}z97xTUI3 zmG8j4{d6F2AbdbA#8Y5dF18l4^iWFWhQjMQt&f^FeQC0BQ-7eXR!1tml>gLtAmeKc zr}@U{1-;XF=!m{)&$O}nB%=&CuCf_2?rE#kGirS!nRgIZO7(2%QI~Uul+Ieu%FDO? z7h=1{>uD>cGt+F3&J}#?&MjtwhT#uBnY@7Ne;xFTlIyjjmAsE{(`#v6paPpFF10;y z-t%bY74BP7wTwWz)GaXg?LC@hs@)0 z7Ba6Gy`h(@Por2l)`u$Q60@dJLwlnL&?2nVW*_r%okgI$y%|V@PZPWATJK|PqQ&$e8vu0(q`-hHI5L7vaJR; zE`vpd(PENqwzG-zE0|^}(ln;1hrzU9V!f0vx=#A?1fzn(yyG3)rS9Yh?@T+G$IPBoV)=O;9#3y; z*Ol|(wW;e;NNV$NZ?%pQW*daQ#78b7f0|c_vkX?5rBFYc&Q7qhEDtSbt~1{2PqkBO z0Nl$Nn6v6oksZ=%f_L2unjO_pjjCWPf21dH%BIjnGmP1sYZzcx4l{Z}7dal4%ZCZM zT!li-VjHA6p5V_=Jbj^rBJppccly4&sH>H`jjM-qqf>TmciwaCu$?2BX;C#XbzJIC z+zlhDFO9|4M(HG~ba2TEmhd+K7`0-&f-)g{SnA`Ojby;ejlp-mC z)MqeUee|2U!w^j$bS)>L+w2dWNDkJGH$;Tj7krX4;Q4lBYt5d<4b0C=J!E){V#X(9 zJ#YRKz8;Q#Oed=YBLRz)`Z4wK1@ts3a5%(~B z%Hw!_2CFiB^KS42>stkp`@RZaDjnYcH*3q@v;J0R;+3Y!%}~c+sB3_`h^K*PotwCS zx^lUTxf{FhxDR>`c>nP|@wLV+zTaG@9J`Ul{z}Fpw=)2~T4hA3V_@A&g5`Gx-`xS+ z?~Swvc=s{%3iJh~u$H!gr+i-@sF%@g`ZZ{@HbG-9B(*A0R7*c-~%>re={ zL|%F*bW8id`p?VSv7u}oY*HP}(pj$aikRcuh}9A_NOoM~Ji zToc`U-2L1)af{14$0>WFZG`*}YEfMlPm6oR@nTMK2l7oP5Gf_1YF8;f5q?+~`T^Yc zVzAVYp&2d*8{Qga;^$->P0LrrgjJyi)W+F`1o8&b~CaFNq1<{7AfFQm{4YWjL76y^V zV1LGt%48w&i4#%rtCqAv@<1!JRX#4?kUz+o;6**PHL+iYjd42nIkUTpyHcIwP(!+w zW4Zl}EvIdaY)Lzjhbt|;6{m_NajVTR`0HJTi(uj{#hlKA%4ZF@@4C4g`T)V4XQVf_ z;iNhX9r71w9VVjQbSHHPbTv0o^B}v{Nn5Jj*V5~Qp%$uc95{# z&v+@+EdGYbcpLa6?Z^()pe_&f$arxn#`%PJOS~>#6)%bh#SQ3DSFx-ZC923ao+U@g zb)t|=$V4qg&a;p-LOL(WGB^bCEcEuLT+B8JYP`I-o$<8&x&5hqpM97;t^Ge+UfU+Q z7;31ElPXIN>4~@z-=kM@o+jCq+G>Gva^ZG_kkX5bWCAVg@jf%ZTle z0h}%FKo%^mR90#s{VmOrR!fJaN0J@9rf`P{4v)_g4TMNZxq)f0$Dsvhs*qGtTP5cg_{ara-Y5#THHCv z+<${*%5T&{^^1pKWw(J(S{O5XoMuA~`4#d>n|M}4@X$E{sZJKEk!j=#;i&A>TU>w& zS{Km5fRsimAr-{TIwT8mW-8`zi`WZ2$%U#<-^eMfhasc`DMw<#9liq&dJ^K}wcun< z#d=vU90F<_Mqax(DNlxyiKsjHi2NYwkOl9LG24&*@mw^-^ipA|95_XJ@CZmh#JjLm z<4`+2M!bWpTz+y02$_WJ=SVBcO62kU3i7@Un2vm4GpIiom^Baq#F@Hr#Mo&hVg>Xw z<`@^@jYgTdVADE7yL$mz*!46FTigJifCygM6J!W8AbS&ywzda)xf@xVuBg!d9987g ziFJ{|FNmt`O)(E6#JSk1m655B6*;u~kI{pxWI0&n?MX*)(bE$rGJ|_z={CcXKLK`d z2#7*=U=H)Zv_(}T{5Ogm0edVx`1(VEXe41AlF2iYQA{R9#V(lXX5t`Z!`EVc)DbIz zTQCq=)^_4$)HTWve%l-2A(%hIi3?@FmS*#4}%Oqrmdm~03gT9}p!`N_cfUTdF{eUJ~hWhq97zB;@iC-skGky~J z^lXtL{8*R#i`JIAY zbV~4uyM=tBj9h;WpkCFXJ&>^jrXuH^os2*)79sB~<351Tu-q9iQu|0|tb`R{ee}S7 z{|i=K!%YS^VOi|rQRo&JPO_D-DurQxOOT_$g~wyBy$3QH1_pW-*wRF(iuQmhxrZO% z9r!TjV(V!GW>7o+Jqa8}86M3jbE8?D-h~S36rIdWmVLew|^W+}3!gjY~rK7B4 zyPexg+D=0Jy-I3^$g?$Gvqh>SKbIfFe;G_3fFv4(4;6)SX9O|{7P4&n ztkOW?GXgb@Lk^=j?h4ofpJx{sLW!^!MWwOwdYj!b!1>+P*YnML)pyNz%a_$Z+Bd`- zOTJ&zGTfmN}0>zRCIAaRd{2$+vtN4^eizX=`ZWqH+G20n?4Oe6DIDfBTQq+~0ud$ipUf~ww z&wU6H9PMngrC2dQ8UjzwV7)+WyNmrp2boQbJbFGPRX8{n!rNVVpU{>;}A}5cqbo-V<8DI_g_RQO2u{ zfNM23wwhU3BG_uX5F>m*?&LqK3`ehwyP8j36t0+>K4oC?xMVryamv%wG|DK{hw5(} zFz?gbP&l?l_UJTn^gT&hakKbMY%ejXlUz-1E>D)5%1fkFoWobZ&B}~9(6R16<9wV4 z#bOoK8tU;z;F;xTF~Eiw(vGwujmM5kF{4m%VKMOFtFXD{fUkcME|dM@5Ge|u_z$WT z1Z^hNg@9N^{5ws}hd` zU-v3h$gOc^%UH?FQHw*<|3wv`ciJb+XGQomBaLQeF`EAWw0EB2Ra9FapFJ}tCxs-G z&_qHbBJP*k0^S<}}aKD9lNKQ_fv&-6Ruk!z|ZMm%3u!t&ydw54~C8qtpw}$7cYbDiU zzR0PZbNikSPrlmyD!DCPOWfBy6?|>McMq_uqcc0PkNzr(vB$R1JHv8Yh$cUxU((O& zhp?t6VWT_rP3UO{==>bspQFv9(p^)1jb6!UVO+=pJ*;rz1$GXcf4uS`qV+;jDIG1{iADZ!MS3ya)^u>E?dS0Z`*p=Loxy`8l zaF&WdQErzz#9M`U-eCC__`W`(rhZZHgb$U4=M`k$pv^<5|9l)<&CnNNhhD}qh$C}6 zhurKSvWTn6jZM*ti7QlPzHKC%H64Y5#szfPNr_!QO0OV zuHl-1pVNsLisi}jUIn?D3LJS5(`hW!pCaDVN~krO0M2C= zJ7yG!{3Gllo3SmsMhZUKiWJ zgHFK)scJtZJ6h{~SFm!oQxo84*p_X)S>9w{DKt`H&R~5FtvrrvkbIFS&@|2xiXcB& z2X@45k%%VFq@G%EXj)j+@XYWxB9=uIitrWK9#%Y5I=4HLjVYXYUWkg(^QdDq+*1;) z_hJ0Otw{A1>M=N|?{k#BBpjq^QCSy%cZyi1_s4fD0b_IuC&A7UG1^)gZ+%BhxhDE6 zBX?`=?c6c0s>Fcm5>Z@Yt&=i%<1P{dH=2 zXRsG_fze!?dU%_B$9*N~VMiH>Ju*&<5<|hlor#Z%#5E*C%NyGRHT<8|$g$9^-1}8sYlR6-_0pmYz&cFY3dcg(4O(^7(>YN8Bau;^$dF;Iy`#1c7j?{xru{u~c;BwdW-5}n5$Qy>=7>nQJBzNCb zv<1;x#N1-)jv@{h5t{1eLL-8j#@Vc+O5zthps^qihmk`a2NrNO+EC&hyoY@dLv-YK zy*&D_kCB9(bBZ>1FuX96ujs?!;c}Xrs7zcc+HM1i@gr*PKcA(Or@*UnAWHAm?uWznrhNlw|y0O%oiX{T@IP2tyS6J(1h-hFQbXE;}EMj26 zwS_!X^?I8#x_=Cv=v-^I(_htU+e`5Nm&i)+e`eTQWFJeSXScxOUO^=Jgr~AqQEO{N z29*q+8d5v-LP(3?pB))`x?SGVywlukvC7h2m)wQC!T1;>$=nWv3%-x47g?^>*dRUN zMf{{))0djZ95sTDI~JJR^~u@*`MIyXH`y}?p386E1Tf5(;S?0Wi#`Xo>Nbdsw?UPF zAH!bRW{fc}nS0IR<|VzB=wOex{_uX|Dd@TF?n?dFp}s>_C}@r2S`)ma_wf{l;WHf* zEp)HGlamLo5;=1^W|-@Z_w;AQb*$SAqLIVFmnLBw3Nd@026HE%8N7d}g7h~thrp}oRaMVOK45d*@vg&uO=FpKIHiHmI2PJruf zZFD9^VT(Vg3jLbr6>4F8m7C_C>x;L`>Wv&9JI7Mn;Ip8@W}>KNAF;B0yQr{~YI(qu z#%oROW!7x(YwokutBR+>Uw`*4?@3rb1B{o=C(Js=H8Dzywj;?XEwEO}GI*(>dU3tC zh}J%pan^Bakd1*Qaglb$3ZGujOf%1#4b29|UGXIm=1KUHUC4x%Wqy}|s#z>!t&QF> zo?GtnRKBR|+hA3+U+096QhFy4;$PtBg%D9$DGI@=xTxF25F6+t#H*YOvj7Xc6J8$d zG{${3(Sr&4EPb#Z4{kYu2*NGBt})9vX`G?PK|v!yZz9^GCxh&*__wq1a!Y|RUkbAD z6%Z3;i6%89rnCyQQ5B?a7P$RreDx-v%$G6)iL$M12a8}446YS?ex_VNH2zQ4$xvkT zNg~%1iEexV4(KruIvMzYvsgbfx%=y!idGLlx&YHr5R^?@M&Jmzu%|$Qv>}$$8dOX( zes|&O0cJ_@trNgmoW}o3AR0dm7V$>nQZ>L&=#WUB#t%rUDQy}u2c{TZ2Ptsw`p7>}wV*ortY z$V_o&hlGa~4oP*s6V$?yWZpC;v9}dAcbG@ba%PhLns%DF)d?zdcq~o^z-oR9ujvr2 zP6M;nm^RKOsn=Up(t;oy(DVo{wW$8Zr%*GSH1FpTjJr?%EV7zXH zwHXX@wgc#)cKCW#h-Q@lhvotC`yKepf>d)zC#rQ04*4j2&ADWk`pIPW^LfZxTYCe{ z#}urHdg!3Z@FmN#Li)2le#Yyqfn`;cNNF03^>F(Y_%!W6^K63|ItE*Rvo=9A(oO8< zH_bMVT0uL4<_7)Y5RQuGK-fU3`Y|k?3;MTuSN$gBknd3Sqk@^E(X`9zxb5G(5?uh_H2Vi*p6^?{-~NX2Eu%i+n06XIFD z5t*fBAX9a0_s(QFd~hhf5$RycFR=P+utOw?1R|SP!NRX5X4n>-OBPa9mbs}emIz&( zgY{d2DgwWPU|s}6rY$*>8`Rs}PyXzZT*}Eh8{sW{E^AvCeIu-ivJ1VxjGPQ*y^OWa z5aB(}`}Tk)o^4OXtH0!PQ$Z(EPNBle3Q)Q)QS<#M)?$P;&02{~x*Vp%Gva6Dtphsi z3F7{oU_?x}v1l!Bu$v9Xc4>~-9^1@aQ5P3eXP;a zaEtk%{k{Wz89_9)AhR3+0LC$r~OW2ZB)Rd=B$%i*Kdvu45T zDJFZ6?KH_jc7k!V(t3+pfn($;`vfuM!FC)`n2D_XvLJugq5q%MD%k8sc6Y5SF_Af- z{eJ)_T!OWvsy19@?c~Z8*xlJ+ty9tRxx{(b65DIdnmqtMvnE*76ReL8VB4;uSL=vv z#AYMJ2#~zp$ZAEy(fYu+YZNxx5qs+or?8#g40~d%nWZ-pH_4iwv1f{_oK=%;rt1mX zPQ0A!cCt9FS251(*Qw1voV8n?)?K0oNvM{KXL3xoXC@}Gem|jhP$F`jB&V~o!tGtu zcdP9igRMOW*3vO~26>LR`uRG85HRqaDza*u$q$HareX(`_83N|B06CFj#K8Nr zGyMhvH%v={H?y1g@+DBJEAS>GiBjIN8-V&0WMxLvmUZkU(d;aTh*6tV0*PkcQ}C67 z^o8scwa7M%(z=P3`V}$}x5a6E-o?Z@YQTDFM}A^1k-3-IIR|szPNLor-#C?>%8jmR zgMO{W-ZULF{cd)j4d}_MNOv|`syun?b@*iSS`k)L8ktwIJSf)ZZ=Ij}K z#mQ>!!dWO8>Q%Xnv2CsONAqq3IXm6v^dqpIbF>xcwhLNu{e6*+6wbu6Uqys-o;FzT zA$qd|Z9>Z*vva_6UIWL_NN*^bXc2ZX>fLO#=a{r{GIYPfaBDx&QYFB_eVYT9#**(e#O zEfCeI;!{^}J{UODZAidE`?3~~HMLaOWDi#7p-T57dy!%qIh}X4Y+^N~k@Wg_<6E^L zavO={TV4<)v~qS6*%gmzuzf~Ia>@VFer10c0miVqR-gMlj^#H~CbJuVBI{^VMPn(> zwaIRUw)K*iiPh4mt(Reqw{tnCC>NW)g;vXY-#f(05Z%mT#$BpTPGSxDtesj(qpuOJ z3p)rlS$}qsMcPQRa_M;U>!`dC#eTbqe0>aDFb$b~3u~dTEv*)^k6leP*Vk|&#}?~# z@JEjuJM>;4UA9{}zAvoZoW#*c9JWWv=2$C7<@eeeP`okVpR0pZ$+FkOg1E+vcEr!S zC{M}Ga4+s)Kh#A#4$Wgx6|+0x%dfP{B1`b{?C-5lt>yA5A`j!ptL)br!-{RJeK*S(j~DsV%W0KC|Bf0gyvq zHn6kphI{psJZHz!?@Z#RswI1jHSo+!#`auMg{W7s{tvG$gNF+al&f0ueo8q9?t@@%EWSdi8u_;xoI z_Zm3BeZZYO&m7F)=V5uqim?lWXDbIod$WBH|9`HGA}6kiQk+bA3g2Khy8b9RnmVEi zc0gB{#uumnwTRPLo=5lJvklg%F3Z__U=PC47uD>0*e}cQbbUne)37Hs?G#L=6vj&N zzjuhO+CuD}>EOi|V{Iw^Tp~Q!wpg^wU>&_gUa-4(8+ObMSQv4{D|%B$W1PKTzR4`a zh)jIY&#_aU#Qzw^}iO7^!!5PvljE^(JHYVhjJ%|>$LzpfnS};lg;hG8mC+C1 zYAW9A^E^8bhC%~u=}bJ43s~J6UR6nKi^29|?41$#GuzPXeVF@etUNDL6@?{s0^Y@a zSP|-k+iVyY+tE{-$=-emzu+uhOHrng!gkw9-wWXv zRDf@hOawZKJ#Qzon!~p@!(ShZ#_Y?CuEnpf3b$hcUg2!~ksNeVB|PQb><^!zM=Ij6 ztBP7=C1}Nuti^9&VHIRt4Ay2A>o$U(4S@LpZCuDF^1)ssY=+svG{k{%;{NV z@h*3C;SDR6ZyJ3o1LnL42s(8x^L?0f>bzn#6UX_EyF|;XV{N^}-HXC~+>igaffe^B zdMKJGgo)HsWtICU(1SQU(F5qCnMm{*WIm4es5pa(6sS|fe_<`CYCoZ9Rf`!?XKJ6M zod)f!LBu19zNI5A8F*c#_}-^^KA7CV1>X4^ZBysI7v^1&d7sb8>xl!o3Pw|9a`%ha z@v7d`CHi%NaSWkv6==7Q=hGO=`}jKQr0fD{Q+58emo}<8OmU1*GB+nG(Q8?cbjz^=;rIuI* z>Wuo^d3{&)xC+q^gXcX&nAPP%ZnAiXdXJAb`TJ;)&B&lnXX(ooUc1GG9mII0)0Zps z=?cD|VsV~AvQE>(i~OzXk)1`G{K%#JnDdN_|MYOhD$C>!sybIT*Bxe2)#y^EZmW~2 zRWJOPzt)(lF&216)fscr$1tuC9;6(e6#<1{NEgCoobmn`#U)A)u6nV zs=w7YstRao9v;5bz0~#Kb@kZ)d3AqBUe5rOaj72p`-G}xZYciD#eoffyZo~l_~~Lk z1DAhx{eP(nY3jQF50_$jv z4S(bZ2FZVT{r!V^AfLbPt@BK-UIFx%;(#m3cA&lQvJ#a1YCO(Gdxfw@)VcE>G+q$x^fFo! zezudxs;{bUuhJ$s+Wc11?yvEuWH~tRHPu#Cdr!67|H$8)ki2)R)vEq>MAlDBh;o;HqKYnz+AIwWY`UA79beHQdxeiEoKw|xJ9{B4&`5ln&z;FMz1mws6 z+b=o(7O9^PWFjE_57t0HKK`%Y|8oQ$edfVv`K3!~hKI+~FLC~#e#v|Er@vmKde;Bw z|NryRJF34QZA0MihuizVJ$|&G|GmHeStI@TzVDyztmNRIp3i?Se+2SJAb$k%M<9O$ t@<$+l1oB59e+2SJAb$k%M<9O$@<$+l1oB59e+2SJAb$k%N8q1F;NK|utw8_) literal 0 HcmV?d00001 diff --git a/src/app/livekit/assets/comfort/short/04.txt b/src/app/livekit/assets/comfort/short/04.txt new file mode 100644 index 0000000..235b933 --- /dev/null +++ b/src/app/livekit/assets/comfort/short/04.txt @@ -0,0 +1 @@ +Um instantinho, por favor. \ No newline at end of file diff --git a/src/app/livekit/assets/comfort/short/04.wav b/src/app/livekit/assets/comfort/short/04.wav new file mode 100644 index 0000000000000000000000000000000000000000..47b3ec3c7c0511bd620a962ee99e4c8cb66ba3da GIT binary patch literal 49956 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 OkRp&GkRtGZK;SQpS!_c9 literal 0 HcmV?d00001 diff --git a/src/app/livekit/azure_speech.py b/src/app/livekit/azure_speech.py new file mode 100644 index 0000000..f179524 --- /dev/null +++ b/src/app/livekit/azure_speech.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import os +from typing import Any, Mapping +from urllib.parse import urlsplit, urlunsplit + +AZURE_SPEECH_TTS_SAMPLE_RATE = 16_000 + + +def _pick(*values: Any) -> str: + for value in values: + if value is None: + continue + text = str(value).strip() + if text: + return text + return "" + + +def _normalize_speech_endpoint(value: str) -> str: + endpoint = (value or "").strip() + if not endpoint: + return "" + + parsed = urlsplit(endpoint) + path = (parsed.path or "").strip().rstrip("/") + host = (parsed.hostname or "").strip().lower() + is_custom_domain = host.endswith(".cognitiveservices.azure.com") + + if path in {"", "/"}: + path = "/cognitiveservices/v1" + + if is_custom_domain and path == "/cognitiveservices/v1": + path = "/tts/cognitiveservices/v1" + + return urlunsplit(parsed._replace(path=path)).rstrip("/") + + +def _is_custom_domain_tts_endpoint(value: str) -> bool: + endpoint = (value or "").strip() + if not endpoint: + return False + + parsed = urlsplit(endpoint) + host = (parsed.hostname or "").strip().lower() + path = (parsed.path or "").rstrip("/") + return host.endswith(".cognitiveservices.azure.com") and path in { + "/cognitiveservices/v1", + "/tts/cognitiveservices/v1", + } + + +def resolve_azure_speech_tts_config( + tts_overrides: Mapping[str, Any] | None = None, + *, + environ: Mapping[str, str] | None = None, +) -> tuple[dict[str, str | None], list[str]]: + overrides = dict(tts_overrides or {}) + env = os.environ if environ is None else environ + + speech_key = _pick(env.get("AZURE_SPEECH_KEY")) + speech_auth_token = _pick(env.get("AZURE_SPEECH_AUTH_TOKEN")) + speech_region = _pick(env.get("AZURE_SPEECH_REGION")) + speech_endpoint = _normalize_speech_endpoint( + _pick(env.get("AZURE_SPEECH_ENDPOINT"), env.get("AZURE_SPEECH_HOST")) + ) + voice = _pick(overrides.get("voice"), overrides.get("voice_id"), env.get("AZURE_SPEECH_VOICE")) + language = _pick(overrides.get("language"), env.get("AZURE_SPEECH_LANGUAGE")) + deployment_id = _pick( + overrides.get("deployment_id"), + overrides.get("model_id"), + env.get("AZURE_SPEECH_DEPLOYMENT_ID"), + ) + + missing: list[str] = [] + if not voice: + missing.append("AZURE_SPEECH_VOICE") + if not (speech_endpoint or speech_region): + missing.append("AZURE_SPEECH_ENDPOINT|AZURE_SPEECH_HOST|AZURE_SPEECH_REGION") + if not (speech_key or speech_auth_token): + missing.append("AZURE_SPEECH_KEY|AZURE_SPEECH_AUTH_TOKEN") + + if missing: + return {}, missing + + if speech_endpoint and deployment_id: + parsed = urlsplit(speech_endpoint) + if (parsed.path or "").rstrip("/") == "/tts/cognitiveservices/v1": + speech_endpoint = urlunsplit( + parsed._replace(path="/voice/cognitiveservices/v1") + ).rstrip("/") + + return ( + { + "voice": voice, + "language": language or None, + "speech_key": speech_key or None, + "speech_region": speech_region or None, + "speech_endpoint": speech_endpoint or None, + "deployment_id": deployment_id or None, + "speech_auth_token": speech_auth_token or None, + }, + [], + ) diff --git a/src/app/livekit/call_config.py b/src/app/livekit/call_config.py new file mode 100644 index 0000000..6390a2f --- /dev/null +++ b/src/app/livekit/call_config.py @@ -0,0 +1,10 @@ +from app.common.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, +) diff --git a/src/app/livekit/compat.py b/src/app/livekit/compat.py new file mode 100644 index 0000000..63a65c1 --- /dev/null +++ b/src/app/livekit/compat.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from importlib import import_module + + +def patch_inference_executor_is_alive() -> bool: + """ + Guard LiveKit's health check against a closed multiprocessing.Process. + + livekit-agents==1.3.10 calls InferenceProcExecutor.is_alive() from the + aiohttp health route. After shutdown, multiprocessing raises + ValueError("process object is closed"), which turns a normal unhealthy + state into a 500. + """ + + try: + module = import_module("livekit.agents.ipc.inference_proc_executor") + except ImportError: + return False + + executor_cls = getattr(module, "InferenceProcExecutor", None) + if executor_cls is None: + return False + + current_is_alive = getattr(executor_cls, "is_alive", None) + if current_is_alive is None: + return False + + if getattr(current_is_alive, "__tim_closed_process_guard__", False): + return False + + def _safe_is_alive(self) -> bool: + try: + return current_is_alive(self) + except ValueError as exc: + if str(exc) != "process object is closed": + raise + return False + + _safe_is_alive.__tim_closed_process_guard__ = True + executor_cls.is_alive = _safe_is_alive + return True diff --git a/src/app/livekit/main.py b/src/app/livekit/main.py new file mode 100644 index 0000000..3c7bb59 --- /dev/null +++ b/src/app/livekit/main.py @@ -0,0 +1,1507 @@ +from __future__ import annotations + +import asyncio +import json +import os +import threading +import time +from pathlib import Path +from typing import Any, Callable, Dict, Optional, Tuple + +import httpx +from dotenv import load_dotenv + +from livekit.agents import ( + Agent, + AgentServer, + AgentSession, + JobContext, + JobProcess, + cli, + tts as livekit_tts, + vad as agents_vad, +) +from livekit.plugins import silero, elevenlabs +from livekit.plugins.turn_detector.multilingual import MultilingualModel + +from app.livekit.adapters.agent_backend import AgentBackend, BackendReply +from app.livekit.adapters.azure_rest_tts import AzureRESTTTS +from app.livekit.adapters.backend_factory import build_agent_backend +from app.livekit.runtime.initial_greeting_audio_cache import InitialGreetingAudioCache +from app.livekit.adapters.fake_tts import FakeTTS as FakeSessionTTS +from app.livekit.adapters.xai_tts import OraclexAITTS +from app.livekit.compat import patch_inference_executor_is_alive +from app.livekit.call_config import ( + resolve_agent_backend_name, + resolve_fake_agent_overrides, + resolve_stt_overrides, + resolve_tts_overrides, + resolve_vad_logging_overrides, + resolve_vad_overrides, +) +from app.livekit.azure_speech import ( + AZURE_SPEECH_TTS_SAMPLE_RATE, + resolve_azure_speech_tts_config, +) +from app.livekit.adapters.bridge_gateway import BridgeGateway +from app.livekit.adapters.export_service import ExportService +from app.livekit.adapters.speech_service import ( + SpeechService, + normalize_text, +) +from app.livekit.policies.finalization_policy import FinalizationPolicy +from app.livekit.policies.idle_policy import IdlePolicy, IdlePolicyConfig +from app.livekit.policies.interrupt_policy import InterruptPolicy +from app.livekit.runtime.command_executor import RuntimeCommandExecutor +from app.livekit.runtime.call_runtime import CallRuntime +from app.livekit.runtime.state import RuntimeConfig +from app.livekit.vad_dynamic_threshold import ( + DynamicVADThresholdConfig, + DynamicVADThresholdController, +) +from app.providers.stt_internal_livekit import ( + InternalHTTPSTT, + InternalSTTConfig, + extract_text_from_transcript, +) +from app.providers.stt_fake import FakeSTT +from app.providers.stt_vosk import VoskSTT, DEFAULT_GRAMMAR +from app.utils.call_timeline import CallTimeline +from app.utils.logging import ( + get_call_logger, + log_flow_event, + setup_minimal_logging, + set_log_session_id, + structured_context_from_metadata, +) + +logger = setup_minimal_logging() +load_dotenv() +patch_inference_executor_is_alive() + +# --- ENV / AMBIENTE --- +APP_ENV = (os.getenv("APP_ENV") or os.getenv("ENV") or "dev").strip().lower() +AGENT_BASE_NAME = os.getenv("AGENT_BASE_NAME", "ws-voice-agent").strip() +AGENT_NAME = os.getenv("AGENT_NAME", f"{AGENT_BASE_NAME}-{APP_ENV}").strip() +AGENT_SERVER_PORT = int(os.getenv("AGENT_SERVER_PORT", "18081")) + +server = AgentServer( + num_idle_processes=int(os.getenv("NUM_IDLE_PROCESSES", "5")), + port=AGENT_SERVER_PORT, +) + +CALL_END_GRACE_S = float(os.getenv("CALL_END_GRACE_S", "2")) +EXPORT_SESSION_ID_ENV = os.getenv("EXPORT_SESSION_ID", "").strip() + +# --- TUNING (interrupção nativa) --- +MIN_INTERRUPT_S = float(os.getenv("MIN_INTERRUPT_S", "1.6")) +MIN_ENDPOINTING_DELAY = 1.0 +MAX_ENDPOINTING_DELAY = 1.2 +FINAL_GRACE_S = float(os.getenv("INTERRUPT_FINAL_GRACE_S", "0.12")) +DISCARD_AUDIO_IF_UNINTERRUPTIBLE = ( + os.getenv("DISCARD_AUDIO_IF_UNINTERRUPTIBLE", "1").strip().lower() + in {"1", "true", "yes", "on"} +) +FLOW_LOG_VAD_DECISIONS = ( + os.getenv("FLOW_LOG_VAD_DECISIONS", "0").strip().lower() + in {"1", "true", "yes", "on"} +) +FLOW_LOG_VAD_ACTIVITY = ( + os.getenv("FLOW_LOG_VAD_ACTIVITY", "0").strip().lower() + in {"1", "true", "yes", "on"} +) +FLOW_LOG_VAD_ACTIVITY_MIN_PROB = float(os.getenv("FLOW_LOG_VAD_ACTIVITY_MIN_PROB", "0.03")) +VAD_MIN_SPEECH_DURATION = float(os.getenv("VAD_MIN_SPEECH_DURATION", "0.15")) +VAD_MIN_SILENCE_DURATION = float( + os.getenv("VAD_MIN_SILENCE_DURATION") + or os.getenv("LIVEKIT_VAD_MIN_SILENCE_DURATION_S") + or "0.35" +) +VAD_ACTIVATION_THRESHOLD = float(os.getenv("VAD_ACTIVATION_THRESHOLD", "0.35")) +VAD_DEACTIVATION_THRESHOLD = float( + os.getenv("VAD_DEACTIVATION_THRESHOLD", str(max(VAD_ACTIVATION_THRESHOLD - 0.15, 0.01))) +) +VAD_PREFIX_PADDING_DURATION = float(os.getenv("VAD_PREFIX_PADDING_DURATION", "1.5")) +VAD_PREFIX_PADDING_MIN_DURATION = float(os.getenv("VAD_PREFIX_PADDING_MIN_DURATION", "1.5")) + +DEFAULT_VAD_CONFIG = { + "min_speech_duration": VAD_MIN_SPEECH_DURATION, + "min_silence_duration": VAD_MIN_SILENCE_DURATION, + "activation_threshold": VAD_ACTIVATION_THRESHOLD, + "deactivation_threshold": VAD_DEACTIVATION_THRESHOLD, + "prefix_padding_duration": max(VAD_PREFIX_PADDING_DURATION, VAD_PREFIX_PADDING_MIN_DURATION), +} + +DEFAULT_VAD_LOGGING_CONFIG = { + "log_decisions": FLOW_LOG_VAD_DECISIONS, + "log_activity": FLOW_LOG_VAD_ACTIVITY, + "activity_min_probability": FLOW_LOG_VAD_ACTIVITY_MIN_PROB, +} + +# --- IDLE NUDGE / FECHAMENTO --- +IDLE_NUDGE_ENABLED = (os.getenv("IDLE_NUDGE_ENABLED", "0").strip().lower() in {"1", "true", "yes", "on"}) +IDLE_NUDGE_DELAY_S = float(os.getenv("IDLE_NUDGE_DELAY_S", "30")) +IDLE_NUDGE_JOIN_DELAY_S = float(os.getenv("IDLE_NUDGE_JOIN_DELAY_S", str(IDLE_NUDGE_DELAY_S))) +IDLE_NUDGE_CLOSE_DELAY_S = float(os.getenv("IDLE_NUDGE_CLOSE_DELAY_S", str(IDLE_NUDGE_DELAY_S))) +IDLE_NUDGE_TEXT_ENV = (os.getenv("IDLE_NUDGE_TEXT", "Alô, você está aí?") or "").strip() +IDLE_NUDGE_MAX_TRIES = int(os.getenv("IDLE_NUDGE_MAX_TRIES", "3")) +IDLE_NUDGE_END_REASON = (os.getenv("IDLE_NUDGE_END_REASON", "no_user_response") or "").strip() +AGENT_WAIT_TIMEOUT_RETRY_VAD_THRESHOLD_ENABLED = ( + os.getenv("AGENT_WAIT_TIMEOUT_RETRY_VAD_THRESHOLD_ENABLED", "0").strip().lower() + in {"1", "true", "yes", "on"} +) +AGENT_WAIT_TIMEOUT_RETRY_VAD_ACTIVATION_THRESHOLD = float( + os.getenv("AGENT_WAIT_TIMEOUT_RETRY_VAD_ACTIVATION_THRESHOLD", "0.20") +) +_AGENT_WAIT_TIMEOUT_RETRY_VAD_DEACTIVATION_THRESHOLD_RAW = ( + os.getenv("AGENT_WAIT_TIMEOUT_RETRY_VAD_DEACTIVATION_THRESHOLD", "") or "" +).strip() +AGENT_WAIT_TIMEOUT_RETRY_VAD_DEACTIVATION_THRESHOLD = ( + float(_AGENT_WAIT_TIMEOUT_RETRY_VAD_DEACTIVATION_THRESHOLD_RAW) + if _AGENT_WAIT_TIMEOUT_RETRY_VAD_DEACTIVATION_THRESHOLD_RAW + else None +) +REMOTE_AGENT_INFLIGHT_WAIT_INTERVAL_S = float(os.getenv("REMOTE_AGENT_INFLIGHT_WAIT_INTERVAL_S", "12")) +REMOTE_AGENT_INFLIGHT_WAIT_TIMEOUT_S = float(os.getenv("REMOTE_AGENT_INFLIGHT_WAIT_TIMEOUT_S", "180")) +# Zero means unlimited notices. The total wait is bounded exclusively by the +# backend timeout above, preventing a silent gap between a notice-count limit +# and termination. +REMOTE_AGENT_INFLIGHT_WAIT_MAX_NOTICES = int(os.getenv("REMOTE_AGENT_INFLIGHT_WAIT_MAX_NOTICES", "0")) +REMOTE_AGENT_INFLIGHT_WAIT_TEXT = ( + os.getenv("REMOTE_AGENT_INFLIGHT_WAIT_TEXT", "Um momento, ainda estou consultando para te ajudar.") + or "" +).strip() +PRE_BACKEND_WAIT_NOTICE_FAST_ON_VAD_PAUSE = ( + os.getenv("PRE_BACKEND_WAIT_NOTICE_FAST_ON_VAD_PAUSE", "0").strip().lower() + in {"1", "true", "yes", "on"} +) +LIVEKIT_APP_DIR = Path(__file__).resolve().parent +DEFERRED_INTERRUPTION_MIN_AUDIO_MS = max( + 0, + int(os.getenv("DEFERRED_INTERRUPTION_MIN_AUDIO_MS", "1000")), +) +DEFERRED_INTERRUPTION_ENABLED = ( + os.getenv("DEFERRED_INTERRUPTION_ENABLED", "1").strip().lower() + in {"1", "true", "yes", "on"} +) +DEFERRED_INTERRUPTION_STT_SETTLE_TIMEOUT_S = max( + 0.0, + float(os.getenv("DEFERRED_INTERRUPTION_STT_SETTLE_TIMEOUT_S", "3.0")), +) +DEFERRED_INTERRUPTION_USER_TURN_TIMEOUT_S = max( + 0.0, + float(os.getenv("DEFERRED_INTERRUPTION_USER_TURN_TIMEOUT_S", "10.0")), +) +REMOTE_AGENT_INFLIGHT_WAIT_AUDIO_BASE_DIR = LIVEKIT_APP_DIR / "assets" / "comfort" +REMOTE_AGENT_INFLIGHT_WAIT_SHORT_AUDIO_DIR = ( + os.getenv( + "REMOTE_AGENT_INFLIGHT_WAIT_SHORT_AUDIO_DIR", + str(REMOTE_AGENT_INFLIGHT_WAIT_AUDIO_BASE_DIR / "short"), + ) + or "" +).strip() +REMOTE_AGENT_INFLIGHT_WAIT_LONG_AUDIO_DIR = ( + os.getenv( + "REMOTE_AGENT_INFLIGHT_WAIT_LONG_AUDIO_DIR", + str(REMOTE_AGENT_INFLIGHT_WAIT_AUDIO_BASE_DIR / "long"), + ) + or "" +).strip() +REMOTE_AGENT_INFLIGHT_WAIT_LONG_AUDIO_PATH = ( + os.getenv( + "REMOTE_AGENT_INFLIGHT_WAIT_LONG_AUDIO_PATH", + "", + ) + or "" +).strip() +REMOTE_AGENT_INFLIGHT_WAIT_AUDIO_ENABLED = bool( + REMOTE_AGENT_INFLIGHT_WAIT_SHORT_AUDIO_DIR + or REMOTE_AGENT_INFLIGHT_WAIT_LONG_AUDIO_DIR + or REMOTE_AGENT_INFLIGHT_WAIT_LONG_AUDIO_PATH +) + + +def _float_override( + value: Any, + default: float, + *, + min_value: Optional[float] = None, + max_value: Optional[float] = None, +) -> float: + if value in (None, ""): + return default + try: + resolved = float(value) + except (TypeError, ValueError): + return default + if min_value is not None and resolved < min_value: + return default + if max_value is not None and resolved > max_value: + return default + return resolved + + +def _bool_override(value: Any, default: bool) -> bool: + if value in (None, ""): + return default + raw = str(value).strip().lower() + if raw in {"1", "true", "yes", "on"}: + return True + if raw in {"0", "false", "no", "off"}: + return False + return default + + +def _has_any_override(overrides: Dict[str, Any]) -> bool: + return any(value not in (None, "") for value in overrides.values()) + + +def resolve_vad_runtime_config(call_config: Optional[Dict[str, Any]]) -> Tuple[Dict[str, float], bool]: + overrides = resolve_vad_overrides(call_config) + vad_tuning_overrides = { + key: overrides.get(key) + for key in ( + "min_speech_duration", + "min_silence_duration", + "activation_threshold", + "deactivation_threshold", + "prefix_padding_duration", + ) + } + return ( + { + "min_speech_duration": _float_override( + overrides.get("min_speech_duration"), + DEFAULT_VAD_CONFIG["min_speech_duration"], + min_value=0.0, + ), + "min_silence_duration": _float_override( + overrides.get("min_silence_duration"), + DEFAULT_VAD_CONFIG["min_silence_duration"], + min_value=0.0, + ), + "activation_threshold": _float_override( + overrides.get("activation_threshold"), + DEFAULT_VAD_CONFIG["activation_threshold"], + min_value=0.0, + max_value=1.0, + ), + "deactivation_threshold": _float_override( + overrides.get("deactivation_threshold"), + DEFAULT_VAD_CONFIG["deactivation_threshold"], + min_value=0.0, + max_value=1.0, + ), + "prefix_padding_duration": _float_override( + overrides.get("prefix_padding_duration"), + DEFAULT_VAD_CONFIG["prefix_padding_duration"], + min_value=VAD_PREFIX_PADDING_MIN_DURATION, + ), + }, + _has_any_override(vad_tuning_overrides), + ) + + +def resolve_vad_logging_runtime_config(call_config: Optional[Dict[str, Any]]) -> Dict[str, Any]: + overrides = resolve_vad_logging_overrides(call_config) + return { + "log_decisions": _bool_override( + overrides.get("log_decisions"), + DEFAULT_VAD_LOGGING_CONFIG["log_decisions"], + ), + "log_activity": _bool_override( + overrides.get("log_activity"), + DEFAULT_VAD_LOGGING_CONFIG["log_activity"], + ), + "activity_min_probability": _float_override( + overrides.get("activity_min_probability"), + DEFAULT_VAD_LOGGING_CONFIG["activity_min_probability"], + min_value=0.0, + max_value=1.0, + ), + } + + +def resolve_agent_runtime_config(call_config: Optional[Dict[str, Any]]) -> Tuple[Dict[str, Any], bool]: + overrides = resolve_vad_overrides(call_config) + fast_on_vad_pause_override = overrides.get("pre_backend_wait_notice_fast_on_vad_pause") + deferred_interruption_min_audio_ms_override = overrides.get( + "deferred_interruption_min_audio_ms" + ) + deferred_interruption_enabled_override = overrides.get( + "deferred_interruption_enabled" + ) + return ( + { + "pre_backend_wait_notice_fast_on_vad_pause": _bool_override( + fast_on_vad_pause_override, + PRE_BACKEND_WAIT_NOTICE_FAST_ON_VAD_PAUSE, + ), + "deferred_interruption_min_audio_ms": int( + _float_override( + deferred_interruption_min_audio_ms_override, + float(DEFERRED_INTERRUPTION_MIN_AUDIO_MS), + min_value=0.0, + ) + ), + "deferred_interruption_enabled": _bool_override( + deferred_interruption_enabled_override, + DEFERRED_INTERRUPTION_ENABLED, + ), + }, + any( + value not in (None, "") + for value in ( + fast_on_vad_pause_override, + deferred_interruption_min_audio_ms_override, + deferred_interruption_enabled_override, + ) + ), + ) + + +def load_silero_vad(vad_config: Dict[str, float]) -> agents_vad.VAD: + return silero.VAD.load( + min_speech_duration=vad_config["min_speech_duration"], + min_silence_duration=vad_config["min_silence_duration"], + activation_threshold=vad_config["activation_threshold"], + deactivation_threshold=vad_config["deactivation_threshold"], + prefix_padding_duration=vad_config["prefix_padding_duration"], + ) + + +def vad_config_cache_key(vad_config: Dict[str, float]) -> Tuple[float, float, float, float, float]: + return ( + vad_config["min_speech_duration"], + vad_config["min_silence_duration"], + vad_config["activation_threshold"], + vad_config["deactivation_threshold"], + vad_config["prefix_padding_duration"], + ) + + +def require_env(name: str, *, context: str, timeline: Optional[CallTimeline] = None) -> str: + value = (os.getenv(name, "") or "").strip() + if value: + return value + + if timeline is not None: + timeline.emit( + "config_missing", + key=name, + context=context, + ) + + raise RuntimeError( + f"Missing required env var {name} for {context}. " + f"Add {name} to .env.dev before starting the agent." + ) + + +def create_task_logged(coro, *, name: str): + task = asyncio.create_task(coro, name=name) + + def _done(t: asyncio.Task): + try: + exc = t.exception() + except asyncio.CancelledError: + return + except Exception: + logger.exception("[task:%s] erro lendo exception()", t.get_name()) + return + if exc: + logger.exception("[task:%s] exception: %r", t.get_name(), exc) + + task.add_done_callback(_done) + return task + + +class FlowLoggingVAD(agents_vad.VAD): + def __init__( + self, + inner: agents_vad.VAD, + *, + call_logger: Any, + min_interrupt_s: float, + vad_config: Dict[str, float], + log_decisions: bool, + log_activity: bool, + activity_min_prob: float, + threshold_provider: Optional[Callable[[], Dict[str, Any]]] = None, + on_pre_backend_wait_pause: Optional[Callable[[str], None]] = None, + on_speech_end: Optional[Callable[[int], None]] = None, + ) -> None: + super().__init__(capabilities=inner.capabilities) + self._inner = inner + self._call_logger = call_logger + self._min_interrupt_s = min_interrupt_s + self._vad_config = dict(vad_config) + self._log_decisions = log_decisions + self._log_activity = log_activity + self._activity_min_prob = activity_min_prob + self._threshold_provider = threshold_provider + self._on_pre_backend_wait_pause = on_pre_backend_wait_pause + self._on_speech_end = on_speech_end + self._stream_lock = threading.Lock() + self._next_stream_id = 0 + self._logging_stream_id: Optional[int] = None + + def _claim_logging_stream(self) -> Tuple[int, bool]: + with self._stream_lock: + stream_id = self._next_stream_id + self._next_stream_id += 1 + if self._logging_stream_id is None: + self._logging_stream_id = stream_id + return stream_id, True + return stream_id, False + + def _release_logging_stream(self, stream_id: int) -> None: + with self._stream_lock: + if self._logging_stream_id == stream_id: + self._logging_stream_id = None + + @property + def model(self) -> str: + return self._inner.model + + @property + def provider(self) -> str: + return self._inner.provider + + def stream(self): + stream_id, should_log = self._claim_logging_stream() + return FlowLoggingVADStream( + self._inner.stream(), + stream_id=stream_id, + should_log=should_log, + release_logging_stream=self._release_logging_stream, + call_logger=self._call_logger, + min_interrupt_s=self._min_interrupt_s, + vad_config=self._vad_config, + log_decisions=self._log_decisions, + log_activity=self._log_activity, + activity_min_prob=self._activity_min_prob, + threshold_provider=self._threshold_provider, + on_pre_backend_wait_pause=self._on_pre_backend_wait_pause, + on_speech_end=self._on_speech_end, + ) + + +class FlowLoggingVADStream: + def __init__( + self, + inner: Any, + *, + stream_id: int, + should_log: bool, + release_logging_stream: Callable[[int], None], + call_logger: Any, + min_interrupt_s: float, + vad_config: Dict[str, float], + log_decisions: bool, + log_activity: bool, + activity_min_prob: float, + threshold_provider: Optional[Callable[[], Dict[str, Any]]] = None, + on_pre_backend_wait_pause: Optional[Callable[[str], None]] = None, + on_speech_end: Optional[Callable[[int], None]] = None, + ) -> None: + self._inner = inner + self._stream_id = stream_id + self._should_log = should_log + self._release_logging_stream = release_logging_stream + self._call_logger = call_logger + self._min_interrupt_s = min_interrupt_s + self._vad_config = dict(vad_config) + self._log_decisions = log_decisions + self._log_activity = log_activity + self._activity_min_prob = activity_min_prob + self._threshold_provider = threshold_provider + self._on_pre_backend_wait_pause = on_pre_backend_wait_pause + self._on_speech_end = on_speech_end + self._last_activity_log_at = 0.0 + self._reset_speech_log_state() + + def push_frame(self, frame): + return self._inner.push_frame(frame) + + def flush(self) -> None: + return self._inner.flush() + + def end_input(self) -> None: + return self._inner.end_input() + + async def aclose(self) -> None: + try: + return await self._inner.aclose() + finally: + self._release_logging_stream(self._stream_id) + + def __aiter__(self): + return self + + async def __anext__(self): + ev = await self._inner.__anext__() + self._log_vad_decision(ev) + return ev + + def _reset_speech_log_state(self) -> None: + self._threshold_logged = False + self._activity_logged = False + self._pause_logged = False + self._pre_backend_wait_pause_notified = False + self._speech_seen = False + self._max_speech_ms = 0 + self._max_silence_ms = 0 + + @staticmethod + def _duration_ms(value: Any) -> int: + return round(float(value or 0.0) * 1000) + + def _pre_backend_wait_min_speech_ms(self) -> int: + return max(1, round(float(self._vad_config["min_speech_duration"]) * 1000)) + + def _current_thresholds(self) -> Dict[str, Any]: + provider = self._threshold_provider + if provider is not None: + try: + thresholds = provider() + except Exception: + logger.exception("[vad] failed reading dynamic threshold state") + else: + if isinstance(thresholds, dict): + return { + "mode": thresholds.get("mode") or "dynamic", + "activation_threshold": float( + thresholds.get("activation_threshold", self._vad_config["activation_threshold"]) + ), + "deactivation_threshold": float( + thresholds.get( + "deactivation_threshold", + self._vad_config["deactivation_threshold"], + ) + ), + } + return { + "mode": "baseline", + "activation_threshold": self._vad_config["activation_threshold"], + "deactivation_threshold": self._vad_config["deactivation_threshold"], + } + + def _observe_speech_ms(self, *, speech_ms: int, raw_speech_ms: int) -> int: + self._max_speech_ms = max(self._max_speech_ms, speech_ms, raw_speech_ms) + return self._max_speech_ms + + def _mark_speech_seen_if_meaningful( + self, + *, + speaking: bool, + speech_ms: int, + raw_speech_ms: int, + ) -> None: + observed_speech_ms = self._observe_speech_ms( + speech_ms=speech_ms, + raw_speech_ms=raw_speech_ms, + ) + if observed_speech_ms >= self._pre_backend_wait_min_speech_ms(): + self._speech_seen = True + + def _observed_silence_ms(self, *, raw_silence_ms: int, silence_ms: int) -> int: + self._max_silence_ms = max(self._max_silence_ms, raw_silence_ms, silence_ms) + return self._max_silence_ms + + def _log_user_pause( + self, + *, + decision: str, + silence_ms: int, + pause_min_ms: int, + speech_ms: int, + min_ms: int, + eligible: bool, + probability: float, + raw_speech_ms: int, + raw_silence_ms: int, + ) -> None: + self._pause_logged = True + log_flow_event( + self._call_logger, + "vad_user_pause", + decision=decision, + silence_ms=silence_ms, + pause_min_ms=pause_min_ms, + speech_duration_ms=speech_ms, + min_interrupt_ms=min_ms, + eligible=eligible, + probability=f"{probability:.3f}", + raw_speech_ms=raw_speech_ms, + raw_silence_ms=raw_silence_ms, + ) + + def _maybe_notify_pre_backend_wait_end_of_speech( + self, + *, + silence_ms: int, + speech_ms: int, + pause_min_ms: int, + ) -> None: + callback = self._on_pre_backend_wait_pause + if ( + callback is None + or self._pre_backend_wait_pause_notified + or not self._speech_seen + or self._max_speech_ms < self._pre_backend_wait_min_speech_ms() + ): + return + + self._pre_backend_wait_pause_notified = True + log_flow_event( + self._call_logger, + "pre_backend_wait_end_of_speech", + silence_ms=silence_ms, + pause_min_ms=pause_min_ms, + speech_duration_ms=speech_ms, + ) + try: + callback("vad_end_of_speech") + except Exception: + logger.exception("[vad] failed notifying pre backend wait pause") + + def _log_vad_decision(self, ev: agents_vad.VADEvent) -> None: + if not self._should_log: + return + + min_ms = round(self._min_interrupt_s * 1000) + speech_ms = self._duration_ms(getattr(ev, "speech_duration", 0.0)) + eligible = speech_ms >= min_ms + probability = float(getattr(ev, "probability", 0.0) or 0.0) + raw_speech_ms = self._duration_ms(getattr(ev, "raw_accumulated_speech", 0.0)) + silence_ms = self._duration_ms(getattr(ev, "silence_duration", 0.0)) + raw_silence_ms = self._duration_ms(getattr(ev, "raw_accumulated_silence", 0.0)) + current_silence_ms = max(raw_silence_ms, silence_ms) + pause_min_ms = round(float(self._vad_config["min_silence_duration"]) * 1000) + + if ev.type == agents_vad.VADEventType.START_OF_SPEECH: + self._reset_speech_log_state() + self._mark_speech_seen_if_meaningful( + speaking=True, + speech_ms=speech_ms, + raw_speech_ms=raw_speech_ms, + ) + if self._log_decisions: + log_flow_event( + self._call_logger, + "vad_speech_start", + speech_duration_ms=speech_ms, + min_interrupt_ms=min_ms, + ) + return + + if ev.type == agents_vad.VADEventType.INFERENCE_DONE: + speaking = bool(getattr(ev, "speaking", False)) + self._mark_speech_seen_if_meaningful( + speaking=speaking, + speech_ms=speech_ms, + raw_speech_ms=raw_speech_ms, + ) + if current_silence_ms < 200: + self._pause_logged = False + self._max_silence_ms = 0 + if not self._speech_seen: + self._max_speech_ms = 0 + observed_silence_ms = self._observed_silence_ms( + raw_silence_ms=raw_silence_ms, + silence_ms=silence_ms, + ) + if self._log_activity: + activity_detected = speaking or raw_speech_ms > 0 or probability >= self._activity_min_prob + should_log_activity = activity_detected and ( + not self._activity_logged + or eligible + or (time.monotonic() - self._last_activity_log_at) >= 0.25 + ) + if should_log_activity: + thresholds = self._current_thresholds() + self._activity_logged = True + self._last_activity_log_at = time.monotonic() + log_flow_event( + self._call_logger, + "vad_activity", + speaking=speaking, + speech_duration_ms=speech_ms, + min_interrupt_ms=min_ms, + eligible=eligible, + probability=f"{probability:.3f}", + threshold_mode=thresholds["mode"], + activation_threshold=f"{thresholds['activation_threshold']:.3f}", + deactivation_threshold=f"{thresholds['deactivation_threshold']:.3f}", + activity_log_min_prob=f"{self._activity_min_prob:.3f}", + silence_ms=observed_silence_ms, + pause_min_ms=pause_min_ms, + silence_remaining_ms=max(0, pause_min_ms - observed_silence_ms), + raw_speech_ms=raw_speech_ms, + raw_silence_ms=raw_silence_ms, + ) + elif not activity_detected and raw_silence_ms >= 200: + self._activity_logged = False + + if ( + self._speech_seen + and observed_silence_ms >= pause_min_ms + ): + if not self._pause_logged: + self._log_user_pause( + decision="pause_threshold_reached", + silence_ms=observed_silence_ms, + pause_min_ms=pause_min_ms, + speech_ms=speech_ms, + min_ms=min_ms, + eligible=eligible, + probability=probability, + raw_speech_ms=raw_speech_ms, + raw_silence_ms=raw_silence_ms, + ) + self._maybe_notify_pre_backend_wait_end_of_speech( + silence_ms=observed_silence_ms, + speech_ms=speech_ms, + pause_min_ms=pause_min_ms, + ) + + if self._log_decisions and speaking and eligible and not self._threshold_logged: + self._threshold_logged = True + log_flow_event( + self._call_logger, + "vad_interrupt_check", + decision="eligible_by_duration", + speech_duration_ms=speech_ms, + min_interrupt_ms=min_ms, + probability=f"{probability:.3f}", + raw_speech_ms=raw_speech_ms, + raw_silence_ms=raw_silence_ms, + ) + return + + if ev.type == agents_vad.VADEventType.END_OF_SPEECH: + self._observe_speech_ms( + speech_ms=speech_ms, + raw_speech_ms=raw_speech_ms, + ) + if self._on_speech_end is not None: + try: + # Silero's final speech_duration excludes the terminal + # silence used to detect the endpoint. Do not use the + # maximum INFERENCE_DONE duration (which still includes + # that silence) or ev.frames (which include VAD padding). + self._on_speech_end(speech_ms) + except Exception: + logger.exception("[vad] failed notifying speech duration") + observed_silence_ms = self._observed_silence_ms( + raw_silence_ms=raw_silence_ms, + silence_ms=silence_ms, + ) + self._maybe_notify_pre_backend_wait_end_of_speech( + silence_ms=observed_silence_ms, + speech_ms=speech_ms, + pause_min_ms=pause_min_ms, + ) + if self._speech_seen and not self._pause_logged: + self._log_user_pause( + decision="end_of_speech", + silence_ms=observed_silence_ms, + pause_min_ms=pause_min_ms, + speech_ms=speech_ms, + min_ms=min_ms, + eligible=eligible, + probability=probability, + raw_speech_ms=raw_speech_ms, + raw_silence_ms=raw_silence_ms, + ) + if self._log_decisions: + log_flow_event( + self._call_logger, + "vad_speech_end", + decision="eligible_by_duration" if eligible else "too_short", + speech_duration_ms=speech_ms, + min_interrupt_ms=min_ms, + eligible=eligible, + silence_ms=observed_silence_ms, + raw_silence_ms=raw_silence_ms, + pause_min_ms=pause_min_ms, + ) + self._reset_speech_log_state() + + +class PreBackendWaitNoticeBridge: + def __init__(self) -> None: + self._runtime: Optional[CallRuntime] = None + + def bind(self, runtime: CallRuntime) -> None: + self._runtime = runtime + + def notify(self, reason: str) -> None: + runtime = self._runtime + if runtime is None: + return + runtime.schedule_pre_backend_wait_notice(reason=reason) + + def notify_speech_end(self, speech_duration_ms: int) -> None: + runtime = self._runtime + if runtime is None: + return + runtime.note_vad_speech_end(speech_duration_ms) + + +def prewarm(proc: JobProcess): + proc.userdata["vad"] = load_silero_vad(DEFAULT_VAD_CONFIG) + proc.userdata["vad_cache"] = { + vad_config_cache_key(DEFAULT_VAD_CONFIG): proc.userdata["vad"], + } + proc.userdata["initial_greeting_audio_cache"] = InitialGreetingAudioCache() + wait_audio_loaded, wait_audio_failed = CallRuntime.prewarm_wait_audio_cache( + short_audio_dir=REMOTE_AGENT_INFLIGHT_WAIT_SHORT_AUDIO_DIR, + long_audio_dir=REMOTE_AGENT_INFLIGHT_WAIT_LONG_AUDIO_DIR, + long_audio_path=REMOTE_AGENT_INFLIGHT_WAIT_LONG_AUDIO_PATH, + logger=logger, + ) + if wait_audio_loaded or wait_audio_failed: + logger.info( + "WAIT_AUDIO_PREWARM | loaded=%s | failed=%s", + wait_audio_loaded, + wait_audio_failed, + ) + + proc.userdata["http"] = httpx.AsyncClient( + limits=httpx.Limits(max_connections=50, max_keepalive_connections=20), + timeout=httpx.Timeout(30.0), + ) + + vosk_model_path = os.getenv("VOSK_MODEL_PATH", "").strip() + if vosk_model_path: + use_grammar = os.getenv("VOSK_USE_GRAMMAR", "1") == "1" + grammar = DEFAULT_GRAMMAR if use_grammar else None + + proc.userdata["vosk"] = VoskSTT( + model_path=vosk_model_path, + sample_rate=int(os.getenv("VOSK_SAMPLE_RATE", "16000")), + grammar_json=grammar, + ) + proc.userdata["vosk_lock"] = threading.Lock() + else: + proc.userdata["vosk"] = None + proc.userdata["vosk_lock"] = None + logger.debug("[prewarm] VOSK_MODEL_PATH vazio; vosk desativado") + + +server.setup_fnc = prewarm + +class PipelineVoiceAgent(Agent): + def __init__( + self, + *, + elegibility: bool, + protocol: str, + intro: str, + backend_name: str = "", + remote_agent_context: Optional[Dict[str, Any]] = None, + timeline: Optional[CallTimeline] = None, + ) -> None: + super().__init__(instructions=( + "Você é um agente de voz em português (pt-BR). " + "Responda curto, direto e educado. " + "Não use emojis, markdown ou caracteres especiais." + )) + self._elegibility = elegibility + self._protocol = protocol + self._intro = intro + self._backend_name = backend_name + self._remote_agent_context = remote_agent_context or {} + self._timeline = timeline + + self.pipeline: Optional[AgentBackend] = None + self._ready = asyncio.Event() + self._prepare_task: Optional[asyncio.Task] = None + self._run_lock = asyncio.Lock() + + self._end_lock = asyncio.Lock() + self._ended = False + self._end_reply: Optional[BackendReply] = None + + self._pending_interrupt: Optional[Tuple[str, str, bool]] = None + self._pending_lock = asyncio.Lock() + + async def set_pending_interrupt( + self, + *, + listened_text: str = "", + skipped: bool = False, + speech_id: str = "", + ) -> None: + listened_text = normalize_text(listened_text or "") + speech_id = str(speech_id or "").strip() + async with self._pending_lock: + self._pending_interrupt = (speech_id, listened_text, bool(skipped)) + + async def consume_pending_interrupt(self) -> Tuple[Optional[str], str, bool]: + async with self._pending_lock: + v = self._pending_interrupt + self._pending_interrupt = None + if not v: + return None, "", False + speech_id, listened_text, skipped = v + return (normalize_text(listened_text or ""), speech_id, bool(skipped)) + + async def on_enter(self): + if self._timeline is not None: + self._timeline.emit( + "agent_backend_build_started", + backend=self._backend_name or "remote_ws", + ) + self.pipeline = build_agent_backend( + intro=self._intro, + backend_name=self._backend_name, + remote_agent_context=self._remote_agent_context, + timeline=self._timeline, + streaming=False, + ) + + self._prepare_task = asyncio.create_task( + self.pipeline.prepare(self._elegibility, self._protocol) + ) + + prepare_ok = False + try: + await self._prepare_task + prepare_ok = True + except Exception: + logger.exception("[pipeline] prepare() falhou") + if self._timeline is not None: + self._timeline.emit( + "agent_backend_prepare_failed", + backend=self._backend_name or "remote_ws", + ) + finally: + if self._timeline is not None and prepare_ok: + self._timeline.emit( + "agent_backend_ready", + backend=self._backend_name or "remote_ws", + ) + self._ready.set() + + async def end_service_once(self) -> BackendReply: + async with self._end_lock: + if self._ended: + return self._end_reply or BackendReply(stage="DONE", done=True, export_payload=[]) + + self._ended = True + if self.pipeline is None: + self._end_reply = BackendReply(stage="DONE", done=True, export_payload=[]) + return self._end_reply + + try: + self._end_reply = await self.pipeline.end_service_once() + except Exception: + logger.exception("[pipeline] end_service() falhou") + self._end_reply = BackendReply(stage="DONE", done=True, export_payload=[]) + + return self._end_reply + + +@server.rtc_session(agent_name=AGENT_NAME) +async def entrypoint(ctx: JobContext): + ctx.log_context_fields = {"room": ctx.room.name} + + http_client: httpx.AsyncClient = ctx.proc.userdata["http"] + default_vad = ctx.proc.userdata["vad"] + vad = default_vad + vosk = ctx.proc.userdata.get("vosk") + initial_greeting_audio_cache = ctx.proc.userdata["initial_greeting_audio_cache"] + vosk_lock = ctx.proc.userdata.get("vosk_lock") + + try: + md = json.loads(ctx.job.metadata or "{}") + except Exception: + md = {} + + session_data = md.get("session_data") or {} + elegibility = bool(md.get("elegibility", True)) + protocol = str(md.get("protocol", os.getenv("DEFAULT_PROTOCOL", "PRT-2025-0000123456"))) + intro = md.get("intro", "") + agent_starts_conversation = bool(md.get("agent_starts_conversation", False)) + bridge_identity = str(md.get("bridge_identity", "")).strip() + call_config = md.get("call_config") or {} + remote_agent_context = dict(md.get("remote_agent") or {}) + fake_agent_overrides = resolve_fake_agent_overrides(call_config) + if fake_agent_overrides.get("delay_ms") is not None: + remote_agent_context["_fake_agent_delay_ms"] = fake_agent_overrides["delay_ms"] + if fake_agent_overrides.get("responses"): + remote_agent_context["_fake_agent_responses"] = fake_agent_overrides["responses"] + debug_events_enabled = bool(md.get("debug_events_enabled", False)) + timeline_origin_ms = int(md.get("timeline_origin_ms") or round(time.time() * 1000)) + structured_context = structured_context_from_metadata(md) + session_id = str( + md.get("session_id") + or session_data.get("session_id") + or session_data.get("sessionId") + or remote_agent_context.get("session_id") + or remote_agent_context.get("sessionId") + or "" + ).strip() + set_log_session_id(session_id) + ctx.log_context_fields = {"room": ctx.room.name, "session_id": session_id} + + phone_number = ( + session_data.get("gsm") + or session_data.get("msisdn") + or session_data.get("phone") + or remote_agent_context.get("GSM") + or "" + ) + + call_logger = get_call_logger( + phone_number=phone_number, + session_id=session_id, + ) + timeline = CallTimeline( + logger=call_logger, + component="agent", + timeline_id=str(md.get("timeline_id") or ctx.room.name), + protocol=protocol, + room=ctx.room.name, + session_id=session_id, + phone_number=phone_number, + origin_unix_ms=timeline_origin_ms, + ) + + vad_config, has_vad_overrides = resolve_vad_runtime_config(call_config) + vad_logging_config = resolve_vad_logging_runtime_config(call_config) + agent_runtime_config, has_agent_runtime_overrides = resolve_agent_runtime_config(call_config) + vad_config_source = "call_config" if has_vad_overrides else "env" + pre_backend_wait_notice_fast_source = ( + "call_config" if has_agent_runtime_overrides else "env" + ) + if has_vad_overrides: + try: + vad_cache = ctx.proc.userdata.setdefault("vad_cache", {}) + cache_key = vad_config_cache_key(vad_config) + vad = vad_cache.get(cache_key) + if vad is None: + vad = load_silero_vad(vad_config) + vad_cache[cache_key] = vad + timeline.emit( + "vad_config_override_applied", + min_speech_duration=vad_config["min_speech_duration"], + min_silence_duration=vad_config["min_silence_duration"], + activation_threshold=vad_config["activation_threshold"], + deactivation_threshold=vad_config["deactivation_threshold"], + prefix_padding_duration=vad_config["prefix_padding_duration"], + ) + except Exception: + logger.exception("[vad] failed loading call_config override; falling back to env VAD") + call_logger.exception("[vad] failed loading call_config override; falling back to env VAD") + vad = default_vad + vad_config = dict(DEFAULT_VAD_CONFIG) + vad_config_source = "env_fallback" + timeline.emit("vad_config_override_failed") + + agent_wait_timeout_retry_vad_threshold_enabled = ( + AGENT_WAIT_TIMEOUT_RETRY_VAD_THRESHOLD_ENABLED + and AGENT_WAIT_TIMEOUT_RETRY_VAD_ACTIVATION_THRESHOLD + < vad_config["activation_threshold"] + ) + agent_wait_timeout_retry_vad_deactivation_threshold = min( + AGENT_WAIT_TIMEOUT_RETRY_VAD_ACTIVATION_THRESHOLD, + max( + 0.01, + ( + AGENT_WAIT_TIMEOUT_RETRY_VAD_DEACTIVATION_THRESHOLD + if AGENT_WAIT_TIMEOUT_RETRY_VAD_DEACTIVATION_THRESHOLD is not None + else vad_config["deactivation_threshold"] + ), + ), + ) + + if agent_wait_timeout_retry_vad_threshold_enabled: + try: + vad = load_silero_vad(vad_config) + vad_config_source = f"{vad_config_source}_dedicated" + timeline.emit( + "vad_dynamic_threshold_dedicated_instance", + activation_threshold=vad_config["activation_threshold"], + deactivation_threshold=vad_config["deactivation_threshold"], + ) + except Exception: + logger.exception("[vad] failed loading dedicated VAD for dynamic threshold") + call_logger.exception("[vad] failed loading dedicated VAD for dynamic threshold") + agent_wait_timeout_retry_vad_threshold_enabled = False + timeline.emit("vad_dynamic_threshold_dedicated_instance_failed") + + nudge_text = (str(md.get("nudge") or IDLE_NUDGE_TEXT_ENV or "Alô, você está aí?") or "").strip() + + call_t0 = time.monotonic() + + await ctx.connect() + call_logger.info( + "CALL_START | room=%s | protocol=%s | session_id=%s | bridge=%s", + ctx.room.name, + protocol, + session_id, + bridge_identity or "-", + ) + log_flow_event( + call_logger, + "call_start", + room=ctx.room.name, + protocol=protocol, + session_id=session_id, + bridge=bridge_identity or "-", + ) + log_flow_event( + call_logger, + "agent_runtime_config", + min_interrupt_s=MIN_INTERRUPT_S, + discard_audio_if_uninterruptible=DISCARD_AUDIO_IF_UNINTERRUPTIBLE, + min_endpointing_delay=MIN_ENDPOINTING_DELAY, + max_endpointing_delay=MAX_ENDPOINTING_DELAY, + stt_min_audio_ms=os.getenv("STT_MIN_AUDIO_MS", "160"), + stt_min_dbfs=os.getenv("STT_MIN_DBFS", "-50.0"), + vad_config_source=vad_config_source, + vad_decision_logging=vad_logging_config["log_decisions"], + vad_activity_logging=vad_logging_config["log_activity"], + vad_activity_min_prob=vad_logging_config["activity_min_probability"], + vad_min_speech_duration=vad_config["min_speech_duration"], + vad_min_silence_duration=vad_config["min_silence_duration"], + vad_activation_threshold=vad_config["activation_threshold"], + vad_deactivation_threshold=vad_config["deactivation_threshold"], + vad_prefix_padding_duration=vad_config["prefix_padding_duration"], + agent_wait_timeout_retry_vad_threshold_enabled=( + agent_wait_timeout_retry_vad_threshold_enabled + ), + agent_wait_timeout_retry_vad_activation_threshold=( + AGENT_WAIT_TIMEOUT_RETRY_VAD_ACTIVATION_THRESHOLD + ), + agent_wait_timeout_retry_vad_deactivation_threshold=( + agent_wait_timeout_retry_vad_deactivation_threshold + ), + inflight_wait_interval_s=REMOTE_AGENT_INFLIGHT_WAIT_INTERVAL_S, + inflight_wait_timeout_s=REMOTE_AGENT_INFLIGHT_WAIT_TIMEOUT_S, + inflight_wait_max_notices=REMOTE_AGENT_INFLIGHT_WAIT_MAX_NOTICES, + pre_backend_wait_notice_fast_on_vad_pause=( + agent_runtime_config["pre_backend_wait_notice_fast_on_vad_pause"] + ), + pre_backend_wait_notice_fast_on_vad_pause_source=( + pre_backend_wait_notice_fast_source + ), + inflight_wait_short_audio_dir=REMOTE_AGENT_INFLIGHT_WAIT_SHORT_AUDIO_DIR, + inflight_wait_long_audio_dir=REMOTE_AGENT_INFLIGHT_WAIT_LONG_AUDIO_DIR, + inflight_wait_long_audio_path=REMOTE_AGENT_INFLIGHT_WAIT_LONG_AUDIO_PATH, + ) + timeline.emit( + "call_start", + backend=resolve_agent_backend_name(call_config, os.getenv("AGENT_BACKEND", "remote_ws")), + bridge_identity=bridge_identity, + remote_agent=remote_agent_context.get("agent", ""), + timeline_file=str(timeline.path), + ) + bridge_gateway = BridgeGateway( + room=ctx.room, + bridge_identity=bridge_identity, + protocol=protocol, + timeline=timeline, + stress_test=bool(fake_agent_overrides.get("responses")), + ) + export_service = ExportService() + runtime_ref: dict[str, CallRuntime | None] = {"runtime": None} + + def _structured_interruption_flag() -> bool: + runtime = runtime_ref["runtime"] + return bool(runtime and runtime.structured_recebimento_interruption_flag()) + + stt_overrides = resolve_stt_overrides(call_config) + stt_provider = (stt_overrides.get("provider") or os.getenv("STT_PROVIDER", "internal_http")).strip().lower() + if stt_provider not in {"", "internal_http", "fake"}: + raise RuntimeError(f"Unsupported STT provider: {stt_provider}") + + if stt_provider == "fake": + stt = FakeSTT( + language=stt_overrides.get("language") or os.getenv("STT_LANG", "pt-BR"), + timeline=timeline, + structured_log_context=structured_context, + structured_logger=call_logger, + structured_interruption_flag=_structured_interruption_flag, + ) + else: + disable_vosk = str(stt_overrides.get("disable_vosk") or "").strip().lower() in { + "1", + "true", + "yes", + "on", + } + stt_cfg = InternalSTTConfig( + url=require_env("STT_URL", context="STT provider internal_http", timeline=timeline), + api_key=( + stt_overrides.get("api_key") + or stt_overrides.get("STT_KEY") + or os.getenv("STT_KEY", "") + or "unknow" + ), + language=stt_overrides.get("language", "portuguese") or os.getenv("STT_LANG", ""), + min_prob_single_word=float( + stt_overrides.get("min_prob_single_word") or os.getenv("STT_MIN_PROB_SINGLE_WORD", "0.10") + ), + initial_prompt=stt_overrides.get("initial_prompt") or os.getenv("STT_INITIAL_PROMPT", ""), + config_override=stt_overrides.get("config_override") or os.getenv("STT_CONFIG_OVERRIDE", ""), + ) + stt = InternalHTTPSTT( + stt_cfg, + client=http_client, + vosk=None if disable_vosk else vosk, + vosk_lock=None if disable_vosk else vosk_lock, + timeline=timeline, + structured_log_context=structured_context, + structured_logger=call_logger, + structured_interruption_flag=_structured_interruption_flag, + ) + + tts_overrides = resolve_tts_overrides(call_config) + tts_provider = (tts_overrides.get("provider") or os.getenv("TTS_PROVIDER", "elevenlabs")).strip().lower() + if tts_provider not in {"", "elevenlabs", "azure", "fake", "xai"}: + raise RuntimeError(f"Unsupported TTS provider: {tts_provider}") + + if tts_provider == "fake": + tts = livekit_tts.StreamAdapter(tts=FakeSessionTTS()) + elif tts_provider in {"", "elevenlabs"}: + tts = elevenlabs.TTS( + model=tts_overrides.get("model_id") or os.getenv("ELEVENLABS_MODEL_ID", ""), + voice_id=tts_overrides.get("voice_id") or os.getenv("ELEVENLABS_VOICE_ID", ""), + api_key=os.getenv("ELEVENLABS_API_KEY", ""), + voice_settings=elevenlabs.VoiceSettings( + stability=0.35, + speed=1.02, + similarity_boost=0.75, + use_speaker_boost=True, + style=0.7, + ), + language="pt", + ) + elif tts_provider == "xai": + xai_auth_method = (os.getenv("XAI_TTS_AUTH_METHOD", "API_KEY") or "API_KEY").strip().upper() + xai_auth_options: dict[str, str] = {"auth_method": xai_auth_method} + if xai_auth_method == "API_KEY": + xai_auth_options["api_key"] = require_env( + "XAI_API_KEY", context="TTS provider xai", timeline=timeline + ) + tts = OraclexAITTS( + voice=tts_overrides.get("voice_id") or os.getenv("XAI_TTS_VOICE", ""), + language=tts_overrides.get("language") or os.getenv("XAI_TTS_LANGUAGE", "pt-BR"), + initial_greeting_audio_cache=initial_greeting_audio_cache, + initial_greeting_agent=str(remote_agent_context.get("agent") or AGENT_NAME), + **xai_auth_options, + ) + else: + azure_tts_config, missing_keys = resolve_azure_speech_tts_config(tts_overrides) + for key in missing_keys: + if timeline is not None: + timeline.emit( + "config_missing", + key=key, + context="TTS provider azure", + ) + if missing_keys: + missing = ", ".join(missing_keys) + raise RuntimeError( + f"Missing required Azure Speech TTS config: {missing}. " + "Add the Azure Speech variables to .env.dev before starting the agent." + ) + + azure_impl = (os.getenv("AZURE_TTS_IMPLEMENTATION", "plugin") or "plugin").strip().lower() + if azure_impl not in {"plugin", "rest"}: + raise RuntimeError( + f"Unsupported AZURE_TTS_IMPLEMENTATION: {azure_impl}. " + "Use 'plugin' or 'rest'." + ) + + if azure_impl == "rest": + tts = AzureRESTTTS( + sample_rate=AZURE_SPEECH_TTS_SAMPLE_RATE, + **azure_tts_config, + ) + else: + try: + from livekit.plugins import azure as livekit_azure + except ModuleNotFoundError as exc: + raise RuntimeError( + "Azure Speech plugin is not installed. " + "Run make setup or reinstall requirements.txt before using TTS_PROVIDER=azure." + ) from exc + + tts = livekit_azure.TTS( + sample_rate=AZURE_SPEECH_TTS_SAMPLE_RATE, + **azure_tts_config, + ) + + if isinstance(tts, OraclexAITTS): + async def _prewarm_initial_tts_connection() -> None: + timeout_s = max(0.1, float(os.getenv("XAI_TTS_PREWARM_TIMEOUT_S", "3"))) + if timeline is not None: + timeline.emit("initial_tts_prewarm_started", timeout_ms=round(timeout_s * 1000)) + try: + await tts.prewarm_connection(timeout=timeout_s) + except Exception as exc: + logger.info("INITIAL_TTS_PREWARM_FAILED | error=%s", type(exc).__name__) + if timeline is not None: + timeline.emit("initial_tts_prewarm_failed", error=type(exc).__name__) + else: + if timeline is not None: + timeline.emit("initial_tts_prewarm_completed") + + create_task_logged(_prewarm_initial_tts_connection(), name="initial_tts_prewarm") + + vad_threshold_controller = ( + DynamicVADThresholdController( + vad, + config=DynamicVADThresholdConfig( + enabled=agent_wait_timeout_retry_vad_threshold_enabled, + baseline_activation_threshold=vad_config["activation_threshold"], + baseline_deactivation_threshold=vad_config["deactivation_threshold"], + retry_activation_threshold=AGENT_WAIT_TIMEOUT_RETRY_VAD_ACTIVATION_THRESHOLD, + retry_deactivation_threshold=agent_wait_timeout_retry_vad_deactivation_threshold, + ), + call_logger=call_logger, + timeline=timeline, + ) + if AGENT_WAIT_TIMEOUT_RETRY_VAD_THRESHOLD_ENABLED + else None + ) + + pre_backend_wait_notice_bridge = PreBackendWaitNoticeBridge() + # The deferred-interruption threshold is decided from END_OF_SPEECH events, + # so this wrapper is required even when verbose VAD logging is disabled. + wrap_vad = True + session_vad = ( + FlowLoggingVAD( + vad, + call_logger=call_logger, + min_interrupt_s=MIN_INTERRUPT_S, + vad_config=vad_config, + log_decisions=vad_logging_config["log_decisions"], + log_activity=vad_logging_config["log_activity"], + activity_min_prob=vad_logging_config["activity_min_probability"], + threshold_provider=( + vad_threshold_controller.current_thresholds + if vad_threshold_controller is not None + else None + ), + on_pre_backend_wait_pause=pre_backend_wait_notice_bridge.notify, + on_speech_end=pre_backend_wait_notice_bridge.notify_speech_end, + ) + if wrap_vad + else vad + ) + + session = AgentSession( + stt=stt, + tts=tts, + vad=session_vad, + discard_audio_if_uninterruptible=DISCARD_AUDIO_IF_UNINTERRUPTIBLE, + min_interruption_duration=MIN_INTERRUPT_S, + min_endpointing_delay=MIN_ENDPOINTING_DELAY, + max_endpointing_delay=MAX_ENDPOINTING_DELAY, + use_tts_aligned_transcript=True, + user_away_timeout=None, + ) + speech_service = SpeechService(session) + + agent = PipelineVoiceAgent( + elegibility=elegibility, + protocol=protocol, + intro=intro, + backend_name=resolve_agent_backend_name(call_config, os.getenv("AGENT_BACKEND", "remote_ws")), + remote_agent_context=remote_agent_context, + timeline=timeline, + ) + command_executor = RuntimeCommandExecutor( + agent=agent, + bridge_gateway=bridge_gateway, + export_service=export_service, + session=session, + speech_service=speech_service, + ) + interrupt_policy = InterruptPolicy() + idle_policy = IdlePolicy( + IdlePolicyConfig( + enabled=IDLE_NUDGE_ENABLED, + delay_s=IDLE_NUDGE_DELAY_S, + join_delay_s=IDLE_NUDGE_JOIN_DELAY_S, + close_delay_s=IDLE_NUDGE_CLOSE_DELAY_S, + max_tries=IDLE_NUDGE_MAX_TRIES, + end_reason=IDLE_NUDGE_END_REASON, + ) + ) + finalization_policy = FinalizationPolicy(call_end_grace_s=CALL_END_GRACE_S) + runtime = CallRuntime( + ctx=ctx, + session=session, + agent=agent, + command_executor=command_executor, + call_logger=call_logger, + protocol=protocol, + session_id=session_id, + bridge_identity=bridge_identity, + agent_starts_conversation=agent_starts_conversation, + nudge_text=nudge_text, + call_t0=call_t0, + extract_text_from_transcript=extract_text_from_transcript, + interrupt_policy=interrupt_policy, + idle_policy=idle_policy, + finalization_policy=finalization_policy, + create_task_logged=create_task_logged, + vad_threshold_controller=vad_threshold_controller, + initial_greeting_tts=tts, + config=RuntimeConfig( + call_end_grace_s=CALL_END_GRACE_S, + final_grace_s=FINAL_GRACE_S, + idle_nudge_delay_s=IDLE_NUDGE_DELAY_S, + idle_nudge_join_delay_s=IDLE_NUDGE_JOIN_DELAY_S, + idle_nudge_close_delay_s=IDLE_NUDGE_CLOSE_DELAY_S, + idle_nudge_max_tries=IDLE_NUDGE_MAX_TRIES, + idle_nudge_end_reason=IDLE_NUDGE_END_REASON, + inflight_backend_wait_interval_s=REMOTE_AGENT_INFLIGHT_WAIT_INTERVAL_S, + inflight_backend_wait_timeout_s=REMOTE_AGENT_INFLIGHT_WAIT_TIMEOUT_S, + inflight_backend_wait_max_notices=REMOTE_AGENT_INFLIGHT_WAIT_MAX_NOTICES, + inflight_backend_wait_text=REMOTE_AGENT_INFLIGHT_WAIT_TEXT, + inflight_backend_wait_short_audio_dir=REMOTE_AGENT_INFLIGHT_WAIT_SHORT_AUDIO_DIR, + inflight_backend_wait_long_audio_dir=REMOTE_AGENT_INFLIGHT_WAIT_LONG_AUDIO_DIR, + deferred_interruption_min_audio_ms=agent_runtime_config[ + "deferred_interruption_min_audio_ms" + ], + deferred_interruption_enabled=agent_runtime_config[ + "deferred_interruption_enabled" + ], + deferred_interruption_stt_settle_timeout_s=DEFERRED_INTERRUPTION_STT_SETTLE_TIMEOUT_S, + deferred_interruption_user_turn_timeout_s=DEFERRED_INTERRUPTION_USER_TURN_TIMEOUT_S, + inflight_backend_wait_long_audio_path=REMOTE_AGENT_INFLIGHT_WAIT_LONG_AUDIO_PATH, + pre_backend_wait_notice_fast_on_vad_pause=( + agent_runtime_config["pre_backend_wait_notice_fast_on_vad_pause"] + ), + ), + logger=logger, + timeline=timeline, + structured_log_context=structured_context, + debug_event_publisher=( + bridge_gateway.publish_debug_event if debug_events_enabled else None + ), + ) + runtime_ref["runtime"] = runtime + empty_transcript_handler = getattr(stt, "set_empty_transcript_handler", None) + if callable(empty_transcript_handler): + empty_transcript_handler( + lambda: runtime.handle_empty_stt_final(source="stt_provider") + ) + stt_metrics_handler = getattr(stt, "set_metrics_handler", None) + if callable(stt_metrics_handler): + stt_metrics_handler(runtime.publish_stt_provider_metrics) + pre_backend_wait_notice_bridge.bind(runtime) + await runtime.run() + + +if __name__ == "__main__": + cli.run_app(server) diff --git a/src/app/livekit/policies/agent_finalization.py b/src/app/livekit/policies/agent_finalization.py new file mode 100644 index 0000000..ddb4abb --- /dev/null +++ b/src/app/livekit/policies/agent_finalization.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping + + +AGENT_FINAL_RESULT_STOP_STATUS = { + "resolvido": "stop_resolvido_e_finalizado", + "nao_resolvido": "stop_nao_resolvido", + "resolvido_outros_assuntos": "stop_outro_assunto", + "outros_assuntos": "stop_outro_assunto", + "erro_falha_sistema": "stop_falha_sistema", + "erro_no_match": "stop_no_match", +} + + +@dataclass(frozen=True, slots=True) +class AgentFinalStop: + result_type: str + status: str + reason: str + phase: str = "in_session" + + +def normalize_agent_result_type(value: Any) -> str: + return str(value or "").strip().lower() + + +def stop_status_for_agent_result_type(result_type: Any) -> str: + return AGENT_FINAL_RESULT_STOP_STATUS.get(normalize_agent_result_type(result_type), "") + + +def final_stop_from_agent_result(result: Any) -> AgentFinalStop | None: + if not isinstance(result, Mapping): + return None + + result_type = normalize_agent_result_type(result.get("type")) + status = stop_status_for_agent_result_type(result_type) + if not status: + return None + + return AgentFinalStop( + result_type=result_type, + status=status, + reason=result_type, + ) diff --git a/src/app/livekit/policies/finalization_policy.py b/src/app/livekit/policies/finalization_policy.py new file mode 100644 index 0000000..8dd5c56 --- /dev/null +++ b/src/app/livekit/policies/finalization_policy.py @@ -0,0 +1,15 @@ +from __future__ import annotations + + +class FinalizationPolicy: + def __init__(self, *, call_end_grace_s: float) -> None: + self.call_end_grace_s = call_end_grace_s + + def should_skip(self, *, finalized: bool) -> bool: + return finalized + + def should_finalize_room_empty(self, *, remote_participants: int, finalized: bool) -> bool: + return remote_participants == 0 and not finalized + + def is_done_stage(self, stage: str) -> bool: + return (stage or "").upper() == "DONE" diff --git a/src/app/livekit/policies/idle_policy.py b/src/app/livekit/policies/idle_policy.py new file mode 100644 index 0000000..0a30476 --- /dev/null +++ b/src/app/livekit/policies/idle_policy.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True, slots=True) +class IdlePolicyConfig: + enabled: bool + delay_s: float + join_delay_s: float + close_delay_s: float + max_tries: int + end_reason: str + + +class IdlePolicy: + def __init__(self, config: IdlePolicyConfig) -> None: + self._config = config + + @property + def close_delay_s(self) -> float: + return self._config.close_delay_s + + @property + def max_tries(self) -> int: + return self._config.max_tries + + @property + def end_reason(self) -> str: + return self._config.end_reason or "no_user_response" + + @property + def enabled(self) -> bool: + return bool(self._config.enabled) + + @property + def join_delay_s(self) -> float: + return self._config.join_delay_s + + def resolve_delay(self, delay_s: Optional[float] = None) -> float: + return self._config.delay_s if delay_s is None else float(delay_s) + + def should_arm_nudge(self, *, delay_s: float, nudge_text: str) -> bool: + if not self.enabled: + return False + if delay_s <= 0: + return False + if not nudge_text: + return False + if self._config.max_tries <= 0: + return False + return True + + def should_fire_nudge( + self, + *, + token_is_current: bool, + seq_matches: bool, + speaking: bool, + gap_active: bool, + finalized: bool, + current_stage: str, + ) -> bool: + if not self.enabled: + return False + if not token_is_current: + return False + if not seq_matches: + return False + if speaking or gap_active: + return False + if finalized: + return False + if (current_stage or "").upper() == "DONE": + return False + return True + + def should_arm_close_before_fire(self, *, nudge_count: int) -> bool: + if not self.enabled: + return False + return nudge_count >= self._config.max_tries + + def should_fire_close( + self, + *, + token_is_current: bool, + seq_matches: bool, + finalized: bool, + current_stage: str, + ) -> bool: + if not self.enabled: + return False + if not token_is_current: + return False + if not seq_matches: + return False + if finalized: + return False + if (current_stage or "").upper() == "DONE": + return False + return True + + def should_arm_close_after_nudge(self, *, stage: str, nudge_count: int) -> bool: + if not self.enabled: + return False + return (stage or "").upper() == "IDLE_NUDGE" and nudge_count >= self._config.max_tries diff --git a/src/app/livekit/policies/interrupt_policy.py b/src/app/livekit/policies/interrupt_policy.py new file mode 100644 index 0000000..89bdd8b --- /dev/null +++ b/src/app/livekit/policies/interrupt_policy.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from typing import Iterable, Mapping + + +DEFAULT_STAGE_POLICY = { + "ARGUMENTATION": True, + "DATA_CONFIRMATION": False, + "FORMALIZATION": False, + "PRESENTATION": True, + "DONE": False, + "INTRO": True, + "IDLE_NUDGE": False, + "AGENT_WAIT_TIMEOUT_RETRY": True, +} + +DEFAULT_BACKCHANNEL_TOKENS = { + "uhum", + "aham", + "hm", + "hmm", + "ok", + "certo", + "tá", + "ta", + "sim", +} + + +class InterruptPolicy: + def __init__( + self, + *, + stage_policy: Mapping[str, bool] | None = None, + backchannel_tokens: Iterable[str] | None = None, + ) -> None: + self._stage_policy = { + str(key).strip().upper(): bool(value) + for key, value in (stage_policy or DEFAULT_STAGE_POLICY).items() + } + self._backchannel_tokens = { + str(token).strip().lower() + for token in (backchannel_tokens or DEFAULT_BACKCHANNEL_TOKENS) + if str(token).strip() + } + + def allow_stage(self, stage: str) -> bool: + key = (stage or "").strip().upper() + return bool(self._stage_policy.get(key, True)) + + def should_ignore_backchannel(self, *, speaking: bool, text: str) -> bool: + if not speaking: + return False + if len(text) <= 2: + return True + lowered = (text or "").strip().lower() + return lowered in self._backchannel_tokens diff --git a/src/app/livekit/runtime/call_runtime.py b/src/app/livekit/runtime/call_runtime.py new file mode 100644 index 0000000..fa72a76 --- /dev/null +++ b/src/app/livekit/runtime/call_runtime.py @@ -0,0 +1,6016 @@ +from __future__ import annotations + +import asyncio +import inspect +import json +import os +import random +import re +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Iterable, Mapping, Optional + +from livekit.agents import UserInputTranscribedEvent, room_io + +from app.livekit.adapters.agent_backend import BackendReply +from app.livekit.policies.agent_finalization import AgentFinalStop, final_stop_from_agent_result +from app.livekit.runtime.commands import ( + EndServiceOnce, + ExportSession, + ExtractSpokenText, + InterruptSpeech, + InjectIdleNudge, + NotifyBridgeDone, + NotifyBridgeStop, + RunPipelineInput, + SetPendingInterrupt, + SetPipelineInterruption, + SetPipelineProcessingInterruption, + StartSession, + StartSpeech, + WaitForSpeechPlayout, +) +from app.livekit.runtime.scheduler import TimerScheduler +from app.livekit.runtime.state import ( + CallState, + DeferredVADUtterance, + PendingUserTurn, + RuntimeConfig, +) +from app.livekit.runtime.wav_audio import wav_audio_frames, wav_duration_ms +from app.utils.logging import ( + EVENT_ENVIO_MSG, + EVENT_RECEBIMENTO_MSG, + error_message_from_resource, + event_latency_ms, + log_flow_event, + log_structured_event, +) +from app.utils.turn_ids import ( + clear_started_turn_message_id, + consume_transcribed_turn, + next_turn_message_id, + peek_started_turn_message_id, +) + +POST_USER_FINAL_SPEECH_GRACE_S = 0.35 +PRE_BACKEND_WAIT_NOTICE_GUARD_S = 0.15 +PROTECTED_SPEECH_POST_PLAYOUT_DROP_GRACE_S = 0.250 +USER_AUDIO_INPUT_SETUP_TIMEOUT_S = 15.0 +AGENT_WAIT_TIMEOUT_STOP_STATUS = "stop_silencio_longo" +TTS_FAILURE_DETAIL = "falha de reproducao do TTS" +IN_SESSION_STOP_STATUS_BY_RESOURCE = { + "agent_runtime": "stop_agent_runtime_unavailable", + "agent_backend": "stop_agent_backend_unavailable", + "stt": "stop_stt_unavailable", + "tts": "stop_tts_unavailable", + "bridge": "stop_bridge_failed", +} +INFLIGHT_BACKEND_WAIT_STAGE = "AGENT_BACKEND_WAIT" +AGENT_WAIT_TIMEOUT_RETRY_VAD_STAGES = {"AGENT_WAIT_TIMEOUT_RETRY"} +EMPTY_STT_FINAL_DEDUP_WINDOW_S = 1.0 +PENDING_VAD_STT_MAX_AGE_S = 15.0 +PENDING_VAD_STT_MAX_UTTERANCES = 32 +DEFERRED_INTERRUPTION_COMFORT_TEXT = "Um instante" +DEFERRED_INTERRUPTION_COMFORT_AUDIO_PATH = ( + Path(__file__).resolve().parents[1] + / "assets" + / "comfort" + / "interruption" + / "01.wav" +) + + +class InflightBackendWaitTimedOut(Exception): + pass + + +@dataclass(frozen=True, slots=True) +class InflightBackendWaitNoticeResult: + started: bool + interrupted_by_user: bool = False + + def __bool__(self) -> bool: + return self.started and not self.interrupted_by_user + + +@dataclass(frozen=True, slots=True) +class AgentWaitTimeoutConfig: + timeout_s: float + retry_messages: tuple[str, ...] + + +@dataclass(frozen=True, slots=True) +class _TTSPartialFailure: + detail: str + provider_synthesis_ms: int | None + pcm_duration_ms: int | None + + +def _http_status_details_from_exception(exc: Exception) -> tuple[int | str | None, str | None]: + response = getattr(exc, "response", None) + status = getattr(exc, "status_code", None) + if status is None: + status = getattr(exc, "status", None) + if status is None and response is not None: + status = getattr(response, "status_code", None) + if status in ("", "NA"): + status = None + + desc = None + if response is not None: + desc = str(getattr(response, "reason_phrase", "") or "").strip() or None + if desc is None: + desc = str(getattr(exc, "message", "") or "").strip() or None + if desc is None and status is not None: + desc = str(exc).strip() or type(exc).__name__ + return status, desc + + +class CallRuntime: + def __init__( + self, + *, + ctx: Any, + session: Any, + agent: Any, + command_executor: Any, + call_logger: Any, + protocol: str, + session_id: str, + bridge_identity: str, + agent_starts_conversation: bool, + nudge_text: str, + call_t0: float, + extract_text_from_transcript, + interrupt_policy: Any, + idle_policy: Any, + finalization_policy: Any, + create_task_logged: Callable[..., asyncio.Task], + config: RuntimeConfig, + logger: Any, + vad_threshold_controller: Any | None = None, + initial_greeting_tts: Any | None = None, + timeline: Any | None = None, + structured_log_context: Any | None = None, + debug_event_publisher: Callable[..., Any] | None = None, + ) -> None: + self._ctx = ctx + self._session = session + self._agent = agent + self._command_executor = command_executor + self._call_logger = call_logger + self._protocol = protocol + self._session_id = session_id + self._bridge_identity = bridge_identity + self._agent_starts_conversation = bool(agent_starts_conversation) + self._nudge_text = nudge_text + self._call_t0 = call_t0 + self._extract_text_from_transcript = extract_text_from_transcript + self._interrupt_policy = interrupt_policy + self._idle_policy = idle_policy + self._finalization_policy = finalization_policy + self._create_task_logged = create_task_logged + self._initial_greeting_tts = initial_greeting_tts + self._vad_threshold_controller = vad_threshold_controller + self._config = config + self._logger = logger + self._timeline = timeline + self._structured_log_context = structured_log_context + self._debug_event_publisher = debug_event_publisher + self._scheduler = TimerScheduler(create_task_logged=create_task_logged, logger=logger) + + self.state = CallState() + self._initial_agent_turn_requested = False + self._user_audio_input_enabled = True + self._user_audio_input_gate_released = False + self._say_stage_seq = 0 + self._last_vad_speech_end_at = 0.0 + self._last_vad_speech_duration_ms: int | None = None + + self.speaking = asyncio.Event() + self.say_lock = asyncio.Lock() + self.pending_lock = asyncio.Lock() + self.finalize_lock = asyncio.Lock() + self.finalized = asyncio.Event() + self._backend_push_task: asyncio.Task[Any] | None = None + self._terminal_stop_lock = asyncio.Lock() + self._terminal_stop_sent = False + self._last_interrupt_task: asyncio.Task[Any] | None = None + self._inflight_backend_activity_at = 0.0 + self._pre_backend_wait_notice_task: asyncio.Task[Any] | None = None + self._pre_backend_wait_notice_generation = 0 + self._pre_backend_wait_notice_reserved = False + self._agent_wait_timeout_config: AgentWaitTimeoutConfig | None = None + self._last_agent_wait_timeout_config: AgentWaitTimeoutConfig | None = None + self._last_agent_wait_timeout_user_seq = 0 + self._last_agent_wait_timeout_source = "" + self._last_agent_wait_timeout_message_id = "" + self._last_agent_message_id = "" + self._empty_stt_recovery_pending = False + self._agent_state_name = "" + self._agent_speaking_seq = 0 + self._agent_speaking_at = 0.0 + self._agent_speaking_event = asyncio.Event() + self._user_state_name = "listening" + self._user_not_speaking = asyncio.Event() + self._user_not_speaking.set() + self._tts_tffb_ms_by_speech_handle_id: dict[str, int] = {} + self._tts_duration_ms_by_speech_handle_id: dict[str, int] = {} + self._tts_audio_duration_ms_by_speech_handle_id: dict[str, int] = {} + self._tts_turn_health_by_segment_id: dict[str, tuple[int, int, int, int]] = {} + self._tts_turn_health_by_speech_handle_id: dict[str, tuple[int, int, int, int]] = {} + self._tts_metric_events_by_speech_handle_id: dict[str, asyncio.Event] = {} + self._retryable_tts_errors_by_speech_handle_id: dict[str, str] = {} + self._tts_partial_failures_by_speech_handle_id: dict[str, _TTSPartialFailure] = {} + self._structured_error_event_keys: set[tuple[str, str, str, str, str, str]] = set() + self._structured_interruption_pending = False + self._deferred_long_stt_settled = asyncio.Event() + self._deferred_long_stt_settled.set() + self._deferred_empty_final_latched_at = 0.0 + self._drop_next_user_final_context: dict[str, Any] | None = None + self._backend_processing_drop_context: dict[str, Any] | None = None + self._post_playout_drop_context: dict[str, Any] | None = None + self._post_playout_drop_expires_at = 0.0 + self._inflight_backend_wait_audio_candidates_cache = ( + self._inflight_backend_wait_audio_candidates_from_config(config) + ) + self._inflight_backend_wait_audio_duration_cache, self._inflight_backend_wait_audio_failures = ( + self._preload_wait_audio_paths( + self._unique_wait_audio_candidates( + self._inflight_backend_wait_audio_candidates_cache + ), + logger=logger, + ) + ) + self._inflight_backend_wait_audio_text_cache: dict[tuple[Path, str], str] = {} + self._preload_inflight_backend_wait_audio_texts() + subscribe_tts_event = getattr(self._initial_greeting_tts, "on", None) + if callable(subscribe_tts_event): + subscribe_tts_event("xai_tts_turn_timing", self._record_xai_tts_turn_timing) + subscribe_tts_event("xai_tts_turn_failed", self._record_xai_tts_turn_failed) + + async def execute(self, command: Any) -> Any: + return await self._command_executor.execute(command) + + def _emit_debug_event(self, event: str, **data: Any) -> None: + if self._debug_event_publisher is None: + return + try: + self._create_task_logged( + self._publish_debug_event(event, **data), + name=f"debug_event_{str(event).replace('.', '_')}", + ) + except Exception: + self._logger.debug("failed scheduling debug event %s", event, exc_info=True) + + async def _publish_debug_event(self, event: str, **data: Any) -> None: + if self._debug_event_publisher is None: + return + try: + result = self._debug_event_publisher(event, **data) + if inspect.isawaitable(result): + await result + except Exception: + self._logger.debug("failed publishing debug event %s", event, exc_info=True) + + def publish_stt_provider_metrics(self, fields: dict[str, Any]) -> None: + data = dict(fields) + outcome = str(data.pop("event", "completed") or "completed").strip().lower() + self._emit_debug_event(f"stt.sofya.{outcome}", **data) + + def execute_now(self, command: Any) -> Any: + return self._command_executor.execute_now(command) + + def _activate_agent_wait_timeout_retry_vad_threshold( + self, + *, + attempt: int, + reason: str = "agent_wait_timeout_retry", + ) -> None: + controller = self._vad_threshold_controller + activate = getattr(controller, "activate_agent_wait_timeout_retry", None) + if not callable(activate): + return + try: + activate(attempt=attempt, reason=reason) + except Exception: + self._logger.exception("[vad] failed activating agent wait timeout retry threshold") + + def _restore_agent_wait_timeout_retry_vad_threshold(self, *, reason: str) -> None: + controller = self._vad_threshold_controller + restore = getattr(controller, "restore", None) + if not callable(restore): + return + try: + restore(reason=reason) + except Exception: + self._logger.exception("[vad] failed restoring agent wait timeout retry threshold") + + @staticmethod + def _speech_handle_id(handle: Any) -> str: + return str(getattr(handle, "id", "") or getattr(handle, "_id", "") or "").strip() + + @staticmethod + def _seconds_to_ms(value: Any) -> int | None: + try: + seconds = float(value) + except (TypeError, ValueError): + return None + if seconds < 0: + return None + return max(0, round(seconds * 1000)) + + @classmethod + def _positive_seconds_to_ms(cls, value: Any) -> int | None: + ms = cls._seconds_to_ms(value) + if ms is None or ms <= 0: + return None + return ms + + @staticmethod + def _nonnegative_int(value: Any) -> int | None: + try: + parsed = int(value) + except (TypeError, ValueError): + return None + return max(0, parsed) + + def _record_xai_tts_turn_timing( + self, event: Any, *, publish_debug: bool = True + ) -> None: + """Keep provider-stream health data until its LiveKit metric arrives.""" + if not isinstance(event, dict): + return + segment_id = str(event.get("segment_id") or "").strip() + gap_ms = self._nonnegative_int(event.get("max_audio_delta_gap_ms")) + underrun_ms = self._nonnegative_int(event.get("max_playout_underrun_0ms")) + if not segment_id or gap_ms is None or underrun_ms is None: + return + underflow_count = self._nonnegative_int(event.get("xai_micro_underflows")) or 0 + avg_underflow_ms = self._nonnegative_int(event.get("xai_avg_underrun_ms")) or 0 + ( + previous_gap_ms, + previous_underrun_ms, + previous_underflow_count, + previous_underflow_total_ms, + ) = self._tts_turn_health_by_segment_id.get(segment_id, (0, 0, 0, 0)) + self._tts_turn_health_by_segment_id[segment_id] = ( + max(previous_gap_ms, gap_ms), + max(previous_underrun_ms, underrun_ms), + previous_underflow_count + underflow_count, + previous_underflow_total_ms + underflow_count * avg_underflow_ms, + ) + if publish_debug: + self._emit_debug_event("tts.xai.completed", **event) + + def _record_xai_tts_turn_failed(self, event: Any) -> None: + """Bind a provider partial failure to the active speech immediately. + + LiveKit does not emit ``metrics_collected`` when a streamed response + fails after PCM was released. This event is the authoritative signal + for the structured error path in that case. + """ + if not isinstance(event, dict): + return + segment_id = str(event.get("segment_id") or "").strip() + reason = str(event.get("reason") or "provider_partial_failure").strip() + gap_ms = self._nonnegative_int(event.get("max_audio_delta_gap_ms")) + underrun_ms = self._nonnegative_int(event.get("max_playout_underrun_0ms")) + if not segment_id or gap_ms is None or underrun_ms is None: + return + + self._record_xai_tts_turn_timing(event, publish_debug=False) + self._emit_debug_event("tts.xai.failed", **event) + handle = self.state.current_speech.handle + speech_handle_id = self._speech_handle_id(handle) + if not speech_handle_id: + log_flow_event( + self._call_logger, + "tts_partial_failure_without_active_handle", + segment_id=segment_id, + reason=reason, + ) + return + + health = self._tts_turn_health_by_segment_id.get(segment_id) + if health is not None: + self._tts_turn_health_by_speech_handle_id[speech_handle_id] = health + underflow_count = self._nonnegative_int(event.get("xai_micro_underflows")) or 0 + avg_underflow_ms = self._nonnegative_int(event.get("xai_avg_underrun_ms")) or 0 + provider_synthesis_ms = self._nonnegative_int(event.get("provider_synthesis_ms")) + pcm_duration_ms = self._nonnegative_int(event.get("pcm_duration_ms")) + detail = ( + f"xAI TTS partial audio failure: {reason}; " + f"xai_underrun_estimado_ms={underrun_ms}; " + f"xai_micro_underflows={underflow_count}; " + f"xai_avg_underrun_ms={avg_underflow_ms}; " + f"max_audio_delta_gap_ms={gap_ms}" + ) + self._tts_partial_failures_by_speech_handle_id[speech_handle_id] = ( + _TTSPartialFailure( + detail=detail, + provider_synthesis_ms=provider_synthesis_ms, + pcm_duration_ms=pcm_duration_ms, + ) + ) + log_flow_event( + self._call_logger, + "tts_partial_failure_observed", + stage=self.state.current_speech.stage or self.state.current_stage, + speech_id=self.state.current_speech.speech_id, + speech_handle_id=speech_handle_id, + segment_id=segment_id, + reason=reason, + max_gap_ms=gap_ms, + max_underrun_0ms=underrun_ms, + underflow_count=underflow_count, + avg_underflow_ms=avg_underflow_ms, + ) + + def _pop_tts_partial_failure(self, handle: Any) -> _TTSPartialFailure | None: + speech_handle_id = self._speech_handle_id(handle) + if not speech_handle_id: + return None + return self._tts_partial_failures_by_speech_handle_id.pop( + speech_handle_id, None + ) + + def _resolve_tts_turn_health_metrics( + self, handle: Any + ) -> tuple[int | None, int | None, int | None, int | None]: + speech_handle_id = self._speech_handle_id(handle) + if not speech_handle_id: + return None, None, None, None + health = self._tts_turn_health_by_speech_handle_id.get(speech_handle_id) + if health is None: + return None, None, None, None + gap_ms, max_underrun_ms, underflow_count, underflow_total_ms = health + return ( + gap_ms, + max_underrun_ms, + underflow_count, + round(underflow_total_ms / underflow_count) if underflow_count else 0, + ) + + def _record_tts_metric(self, ev: Any) -> None: + metrics = getattr(ev, "metrics", ev) + if str(getattr(metrics, "type", "") or "") != "tts_metrics": + return + + speech_handle_id = str(getattr(metrics, "speech_id", "") or "").strip() + if not speech_handle_id: + speech_handle_id = self._speech_handle_id(self.state.current_speech.handle) + if not speech_handle_id: + return + + segment_id = str(getattr(metrics, "segment_id", "") or "").strip() + segment_health = self._tts_turn_health_by_segment_id.get(segment_id) + if segment_health is not None: + self._tts_turn_health_by_speech_handle_id.setdefault( + speech_handle_id, segment_health + ) + duration_ms = self._seconds_to_ms(getattr(metrics, "duration", None)) + if duration_ms is not None and duration_ms > 0: + self._tts_duration_ms_by_speech_handle_id.setdefault( + speech_handle_id, + duration_ms, + ) + + audio_ms = self._positive_seconds_to_ms(getattr(metrics, "audio_duration", None)) + if audio_ms is not None: + self._tts_audio_duration_ms_by_speech_handle_id.setdefault( + speech_handle_id, + audio_ms, + ) + + ttfb_ms = self._positive_seconds_to_ms(getattr(metrics, "ttfb", None)) + if ttfb_ms is None: + metric_event = self._tts_metric_events_by_speech_handle_id.get(speech_handle_id) + if metric_event is not None: + metric_event.set() + log_flow_event( + self._call_logger, + "tts_metric_invalid", + speech_handle_id=speech_handle_id, + tffb_seconds=getattr(metrics, "ttfb", None), + duration_ms=duration_ms, + audio_ms=audio_ms, + provider=str(getattr(metrics, "label", "") or ""), + ) + return + + self._tts_tffb_ms_by_speech_handle_id.setdefault(speech_handle_id, ttfb_ms) + metric_event = self._tts_metric_events_by_speech_handle_id.get(speech_handle_id) + if metric_event is not None: + metric_event.set() + log_flow_event( + self._call_logger, + "tts_metric", + speech_handle_id=speech_handle_id, + tffb_ms=ttfb_ms, + duration_ms=duration_ms, + audio_ms=audio_ms, + provider=str(getattr(metrics, "label", "") or ""), + ) + self._emit_debug_event( + "tts.completed", + stage=self.state.current_speech.stage or self.state.current_stage, + speech_id=speech_handle_id, + duration_ms=duration_ms, + ttfb_ms=ttfb_ms, + audio_duration_ms=audio_ms, + provider=str(getattr(metrics, "label", "") or ""), + max_audio_delta_gap_ms=( + segment_health[0] if segment_health is not None else None + ), + max_playout_underrun_ms=( + segment_health[1] if segment_health is not None else None + ), + underflow_count=( + segment_health[2] if segment_health is not None else None + ), + avg_underflow_ms=( + round(segment_health[3] / segment_health[2]) + if segment_health is not None and segment_health[2] + else (0 if segment_health is not None else None) + ), + ) + + async def _wait_for_tts_metric_event(self, speech_handle_id: str) -> None: + wait_s = self._tts_metric_wait_s() + if wait_s <= 0: + return + + metric_event = self._tts_metric_events_by_speech_handle_id.setdefault( + speech_handle_id, + asyncio.Event(), + ) + try: + await asyncio.wait_for(metric_event.wait(), timeout=wait_s) + except asyncio.TimeoutError: + pass + finally: + if self._tts_metric_events_by_speech_handle_id.get(speech_handle_id) is metric_event: + self._tts_metric_events_by_speech_handle_id.pop(speech_handle_id, None) + + def _has_tts_metric_for_speech_handle_id(self, speech_handle_id: str) -> bool: + return any( + metrics.get(speech_handle_id) is not None + for metrics in ( + self._tts_tffb_ms_by_speech_handle_id, + self._tts_duration_ms_by_speech_handle_id, + self._tts_audio_duration_ms_by_speech_handle_id, + ) + ) + + @staticmethod + def _tts_metric_wait_s() -> float: + try: + wait_ms = int(str(os.getenv("TTS_TTFB_METRIC_WAIT_MS", "250") or "250")) + except ValueError: + wait_ms = 250 + return max(0.0, wait_ms / 1000.0) + + @staticmethod + def _float_env_s(env_name: str, default: float) -> float: + try: + timeout_s = float(str(os.getenv(env_name, str(default)) or str(default))) + except ValueError: + timeout_s = default + return max(0.0, timeout_s) + + @classmethod + def _tts_first_frame_timeout_s(cls) -> float: + if os.getenv("TTS_FIRST_FRAME_TIMEOUT_S") is not None: + return cls._float_env_s("TTS_FIRST_FRAME_TIMEOUT_S", 3.0) + return cls._float_env_s("TTS_EMPTY_FRAME_RETRY_TIMEOUT_S", 3.0) + + @classmethod + def _tts_connection_start_grace_s(cls) -> float: + return cls._float_env_s("TTS_CONNECTION_START_GRACE_TIMEOUT_S", 3.0) + + @classmethod + def _tts_playout_start_timeout_s(cls) -> float: + if os.getenv("TTS_PLAYOUT_START_TIMEOUT_S") is not None: + return cls._float_env_s("TTS_PLAYOUT_START_TIMEOUT_S", 6.0) + return cls._tts_first_frame_timeout_s() + cls._tts_connection_start_grace_s() + + @classmethod + def _tts_empty_frame_retry_timeout_s(cls) -> float: + return cls._tts_playout_start_timeout_s() + + @staticmethod + def _exception_chain_text(exc: Exception) -> str: + parts: list[str] = [] + current: BaseException | None = exc + seen: set[int] = set() + while current is not None and id(current) not in seen: + seen.add(id(current)) + parts.append(type(current).__name__) + parts.append(str(current)) + current = current.__cause__ or current.__context__ + return " ".join(parts).lower() + + @staticmethod + def _tts_failure_metric(detail: str | None, key: str) -> int | None: + match = re.search(rf"(?:^|;\s*){re.escape(key)}=(\d+)(?:;|$)", str(detail or "")) + return int(match.group(1)) if match else None + + @classmethod + def _is_retryable_tts_playout_error(cls, exc: Exception) -> bool: + return cls._is_retryable_tts_error_text(cls._exception_chain_text(exc)) + + @staticmethod + def _retryable_tts_error_markers() -> tuple[str, ...]: + return ( + "audio frame gap", + "no audio frames", + "empty frame", + "apitimeouterror", + "request timed out", + "xai tts partial audio failure", + "xai tts total timeout", + "xai tts failed before audio", + "failed to connect to xai tts", + ) + + @classmethod + def _is_retryable_tts_error_text(cls, details: str) -> bool: + details = str(details or "").lower() + return any(marker in details for marker in cls._retryable_tts_error_markers()) + + @classmethod + def _retryable_tts_error_detail(cls, error: Any) -> str | None: + underlying = getattr(error, "error", error) + if isinstance(underlying, BaseException): + details = cls._exception_chain_text(underlying) + else: + details = f"{type(underlying).__name__} {underlying}".lower() + if not cls._is_retryable_tts_error_text(details): + return None + return details[:500] + + def _record_retryable_tts_session_error(self, error: Any) -> None: + error_type = str(getattr(error, "type", "") or "").strip().lower() + if error_type != "tts_error": + return + + detail = self._retryable_tts_error_detail(error) + if detail is None: + return + + speech_handle_id = self._speech_handle_id(self.state.current_speech.handle) + if not speech_handle_id: + return + + self._retryable_tts_errors_by_speech_handle_id[speech_handle_id] = detail + log_flow_event( + self._call_logger, + "tts_retryable_error_observed", + stage=self.state.current_speech.stage or self.state.current_stage, + speech_id=self.state.current_speech.speech_id, + speech_handle_id=speech_handle_id, + recoverable=bool(getattr(error, "recoverable", False)), + detail=detail, + ) + + def _pop_retryable_tts_error_detail(self, handle: Any) -> str | None: + speech_handle_id = self._speech_handle_id(handle) + if not speech_handle_id: + return None + return self._retryable_tts_errors_by_speech_handle_id.pop(speech_handle_id, None) + + def _agent_speaking_seen_after( + self, + *, + started_monotonic: float, + seq_at_start: int, + ) -> bool: + session_state = str(getattr(self._session, "agent_state", "") or "").lower() + session_state_name = session_state.rsplit(".", 1)[-1] + if session_state_name == "speaking" and self._agent_speaking_at >= started_monotonic: + return True + return ( + self._agent_speaking_seq > seq_at_start + and self._agent_speaking_at >= started_monotonic + ) + + async def _wait_for_agent_speaking_after( + self, + *, + started_monotonic: float, + seq_at_start: int, + timeout_s: float, + ) -> bool: + deadline = time.monotonic() + timeout_s + while True: + if self._agent_speaking_seen_after( + started_monotonic=started_monotonic, + seq_at_start=seq_at_start, + ): + return True + + remaining_s = deadline - time.monotonic() + if remaining_s <= 0: + return False + + self._agent_speaking_event.clear() + try: + await asyncio.wait_for( + self._agent_speaking_event.wait(), + timeout=min(remaining_s, 0.05), + ) + except asyncio.TimeoutError: + pass + + async def _wait_for_tts_playout_or_empty_frame_retry( + self, + handle: Any, + *, + started_monotonic: float, + agent_speaking_seq_at_start: int, + timeout_s: float, + stage: str, + message_id: str, + ) -> bool: + if timeout_s <= 0: + await self.execute(WaitForSpeechPlayout(handle)) + return False + + playout_task = asyncio.create_task(self.execute(WaitForSpeechPlayout(handle))) + speaking_task = asyncio.create_task( + self._wait_for_agent_speaking_after( + started_monotonic=started_monotonic, + seq_at_start=agent_speaking_seq_at_start, + timeout_s=timeout_s, + ) + ) + try: + done, _ = await asyncio.wait( + {playout_task, speaking_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if speaking_task in done: + first_audio_seen = await speaking_task + if first_audio_seen: + await playout_task + return False + + if playout_task.done() and playout_task.exception() is not None: + await playout_task + + elif playout_task in done: + await playout_task + return False + + speech_handle_id = self._speech_handle_id(handle) + log_flow_event( + self._call_logger, + "tts_empty_frame_retry", + stage=stage, + message_id=message_id, + speech_handle_id=speech_handle_id, + timeout_ms=round(timeout_s * 1000), + timeout_kind="playout_start", + first_frame_timeout_ms=round(self._tts_first_frame_timeout_s() * 1000), + connection_grace_ms=round(self._tts_connection_start_grace_s() * 1000), + action="resend_text", + ) + try: + await self.execute(InterruptSpeech(handle, force=True)) + except Exception as exc: + log_flow_event( + self._call_logger, + "tts_empty_frame_retry_interrupt_error", + stage=stage, + message_id=message_id, + speech_handle_id=speech_handle_id, + error=type(exc).__name__, + ) + + playout_task.cancel() + await asyncio.gather(playout_task, return_exceptions=True) + return True + finally: + if not speaking_task.done(): + speaking_task.cancel() + await asyncio.gather(speaking_task, return_exceptions=True) + + async def _resolve_tts_tffb_ms( + self, + handle: Any, + ) -> int | None: + speech_handle_id = self._speech_handle_id(handle) + if not speech_handle_id: + return None + + ttfb_ms = self._tts_tffb_ms_by_speech_handle_id.get(speech_handle_id) + if ttfb_ms is not None and ttfb_ms > 0: + return ttfb_ms + + if self._has_tts_metric_for_speech_handle_id(speech_handle_id): + return None + + await self._wait_for_tts_metric_event(speech_handle_id) + ttfb_ms = self._tts_tffb_ms_by_speech_handle_id.get(speech_handle_id) + if ttfb_ms is not None and ttfb_ms > 0: + return ttfb_ms + + return None + + def _resolve_tts_provider_duration_ms( + self, + handle: Any, + ) -> int | None: + speech_handle_id = self._speech_handle_id(handle) + if not speech_handle_id: + return None + + duration_ms = self._tts_duration_ms_by_speech_handle_id.get(speech_handle_id) + if duration_ms is not None and duration_ms > 0: + return duration_ms + + return None + + def _resolve_tts_structured_metrics( + self, + handle: Any, + ) -> tuple[int | None, int | None, int | None, int | None, str]: + speech_handle_id = self._speech_handle_id(handle) + if not speech_handle_id: + return None, None, None, None, "livekit_tts_metrics_missing" + + ttfb_ms = self._tts_tffb_ms_by_speech_handle_id.get(speech_handle_id) + metric_duration_ms = self._tts_duration_ms_by_speech_handle_id.get(speech_handle_id) + audio_duration_ms = self._tts_audio_duration_ms_by_speech_handle_id.get(speech_handle_id) + has_livekit_metric = any( + value is not None and value > 0 + for value in (ttfb_ms, metric_duration_ms, audio_duration_ms) + ) + metrics_source = ( + "livekit_tts_metrics" if has_livekit_metric else "livekit_tts_metrics_missing" + ) + total_ms = metric_duration_ms + return total_ms, ttfb_ms, audio_duration_ms, metric_duration_ms, metrics_source + + def _resolve_tts_audio_duration_ms( + self, + handle: Any, + ) -> int | None: + speech_handle_id = self._speech_handle_id(handle) + if speech_handle_id: + audio_ms = self._tts_audio_duration_ms_by_speech_handle_id.get(speech_handle_id) + if audio_ms is not None and audio_ms > 0: + return audio_ms + + return None + + def _mark_structured_interruption(self, *, reason: str = "") -> None: + self._structured_interruption_pending = True + log_flow_event( + self._call_logger, + "structured_interruption_marked", + reason=reason, + stage=self.state.current_speech.stage or self.state.current_stage, + ) + + def _clear_structured_interruption(self) -> None: + self._structured_interruption_pending = False + + def structured_recebimento_interruption_flag(self) -> bool: + return bool( + self._structured_interruption_pending + or (self.speaking.is_set() and self._current_speech_allows_interruption()) + ) + + def reset_gap_state(self) -> None: + self.state.gap.active = False + self.state.gap.guard_seq = 0 + self.state.gap.stage = "" + self.state.gap.text = "" + self.state.gap.speech_id = "" + self.state.gap.cancelled = False + + @staticmethod + def _metadata_bool(metadata: Any, key: str) -> Optional[bool]: + if not isinstance(metadata, Mapping) or key not in metadata: + return None + + value = metadata.get(key) + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + return None + + @staticmethod + def _metadata_str(metadata: Any, key: str) -> str: + if not isinstance(metadata, Mapping): + return "" + return str(metadata.get(key) or "").strip() + + @classmethod + def _reply_expects_user_response(cls, reply: BackendReply) -> Optional[bool]: + return cls._metadata_bool(reply.metadata, "expects_user_response") + + @classmethod + def _reply_drops_user_input_while_speaking(cls, reply: BackendReply) -> bool: + return bool(cls._metadata_bool(reply.metadata, "drop_user_input_while_speaking")) + + @classmethod + def _reply_agent_message_type(cls, reply: BackendReply) -> str: + return cls._metadata_str(reply.metadata, "agent_message_type") + + @classmethod + def _reply_agent_result_type(cls, reply: BackendReply) -> str: + return cls._metadata_str(reply.metadata, "agent_result_type") + + def _protected_speech_drop_context_from_values( + self, + *, + reason: str, + stage: str, + allow_interruptions: bool, + drop_user_input_while_speaking: bool, + agent_message_type: str, + agent_result_type: str, + ) -> dict[str, Any] | None: + stage = stage or self.state.current_stage + protected_by_metadata = bool(drop_user_input_while_speaking) + protected_presentation = ( + str(stage or "").strip().upper() == "PRESENTATION" + and not allow_interruptions + ) + if not protected_by_metadata and not protected_presentation: + return None + + agent_message_type = str(agent_message_type or "").strip() + if not agent_message_type and protected_presentation: + agent_message_type = "ready" if self.state.user_final_seq == 0 else "presentation" + + return { + "reason": reason, + "stage": stage, + "agent_message_type": agent_message_type, + "agent_result_type": str(agent_result_type or "").strip(), + } + + def _current_speech_drop_context(self, *, reason: str) -> dict[str, Any] | None: + current = self.state.current_speech + if not self.speaking.is_set(): + return None + return self._protected_speech_drop_context_from_values( + reason=reason, + stage=current.stage or self.state.current_stage, + allow_interruptions=current.allow_interruptions, + drop_user_input_while_speaking=current.drop_user_input_while_speaking, + agent_message_type=current.agent_message_type, + agent_result_type=current.agent_result_type, + ) + + def _mark_drop_next_user_final(self, context: Mapping[str, Any]) -> bool: + self._drop_next_user_final_context = dict(context) + self._call_logger.info( + "USER_INPUT_DROP_ARMED | reason=%s | stage=%s | agent_message_type=%s | agent_result_type=%s", + context.get("reason") or "-", + context.get("stage") or "-", + context.get("agent_message_type") or "-", + context.get("agent_result_type") or "-", + ) + log_flow_event( + self._call_logger, + "user_input_drop_armed", + reason=context.get("reason") or "", + stage=context.get("stage") or "", + agent_message_type=context.get("agent_message_type") or "", + agent_result_type=context.get("agent_result_type") or "", + ) + if self._timeline is not None: + self._timeline.emit("user_input_drop_armed", **context) + return True + + def _mark_drop_next_user_final_from_current_speech(self, *, reason: str) -> bool: + context = self._current_speech_drop_context(reason=reason) + if context is None: + return False + return self._mark_drop_next_user_final(context) + + def _active_post_playout_drop_context(self, *, reason: str) -> dict[str, Any] | None: + context = self._post_playout_drop_context + if context is None: + return None + if time.monotonic() >= self._post_playout_drop_expires_at: + self._clear_post_playout_drop_window(reason="expired") + return None + active_context = dict(context) + active_context["reason"] = reason + return active_context + + def _mark_drop_next_user_final_from_post_playout_window(self, *, reason: str) -> bool: + context = self._active_post_playout_drop_context(reason=reason) + if context is None: + return False + return self._mark_drop_next_user_final(context) + + def _arm_post_playout_drop_window(self, context: Mapping[str, Any]) -> None: + grace_s = PROTECTED_SPEECH_POST_PLAYOUT_DROP_GRACE_S + if grace_s <= 0: + return + self._post_playout_drop_context = dict(context) + self._post_playout_drop_expires_at = time.monotonic() + grace_s + self._call_logger.info( + "USER_INPUT_DROP_GRACE_ARMED | reason=%s | stage=%s | agent_message_type=%s | agent_result_type=%s | grace_ms=%s", + context.get("reason") or "-", + context.get("stage") or "-", + context.get("agent_message_type") or "-", + context.get("agent_result_type") or "-", + round(grace_s * 1000), + ) + log_flow_event( + self._call_logger, + "user_input_drop_grace_armed", + reason=context.get("reason") or "", + stage=context.get("stage") or "", + agent_message_type=context.get("agent_message_type") or "", + agent_result_type=context.get("agent_result_type") or "", + grace_ms=round(grace_s * 1000), + ) + if self._timeline is not None: + self._timeline.emit( + "user_input_drop_grace_armed", + **dict(context), + grace_ms=round(grace_s * 1000), + ) + + def _clear_post_playout_drop_window(self, *, reason: str = "clear") -> None: + if self._post_playout_drop_context is None: + return + self._post_playout_drop_context = None + self._post_playout_drop_expires_at = 0.0 + log_flow_event( + self._call_logger, + "user_input_drop_grace_cleared", + reason=reason, + ) + + def _clear_pending_user_input_drop(self) -> None: + self._drop_next_user_final_context = None + + def _backend_processing_drop_context_from_reply( + self, + reply: BackendReply, + *, + reason: str, + ) -> dict[str, Any]: + return { + "reason": reason, + "stage": reply.stage or self.state.current_stage, + "agent_message_type": self._reply_agent_message_type(reply), + "agent_result_type": self._reply_agent_result_type(reply), + } + + def _update_backend_processing_drop_from_reply( + self, + reply: BackendReply, + *, + source: str, + ) -> None: + if ( + self._reply_agent_message_type(reply) == "feedback" + or self._reply_agent_result_type(reply) == "feedback" + ): + self._backend_processing_drop_context = self._backend_processing_drop_context_from_reply( + reply, + reason=f"backend_processing_feedback:{source}", + ) + return + + self._backend_processing_drop_context = None + + def _drop_context_for_user_final(self) -> dict[str, Any] | None: + if self.finalized.is_set(): + return { + "reason": "finalized", + "stage": self.state.current_stage, + "agent_message_type": "", + "agent_result_type": "", + } + + context = self._current_speech_drop_context(reason="protected_speech_active") + if context is not None: + self._clear_pending_user_input_drop() + return context + + if self._backend_processing_drop_context is not None: + return dict(self._backend_processing_drop_context) + + if self._drop_next_user_final_context is not None: + context = dict(self._drop_next_user_final_context) + self._clear_pending_user_input_drop() + return context + + context = self._active_post_playout_drop_context( + reason="protected_speech_post_playout_grace" + ) + if context is not None: + self._clear_post_playout_drop_window(reason="user_final_dropped") + return context + + return None + + def _log_user_input_dropped( + self, + *, + context: Mapping[str, Any], + transcription: str, + text: str, + ) -> None: + reason = str(context.get("reason") or "protected_speech").strip() + stage = str(context.get("stage") or self.state.current_stage or "").strip() + agent_message_type = str(context.get("agent_message_type") or "").strip() + agent_result_type = str(context.get("agent_result_type") or "").strip() + self._call_logger.info( + "USER_INPUT_DROPPED | reason=%s | stage=%s | agent_message_type=%s | agent_result_type=%s | text=%r", + reason or "-", + stage or "-", + agent_message_type or "-", + agent_result_type or "-", + text, + ) + log_flow_event( + self._call_logger, + "user_input_dropped", + reason=reason, + stage=stage, + agent_message_type=agent_message_type, + agent_result_type=agent_result_type, + text=text, + transcript_len=len(transcription or ""), + ) + if self._timeline is not None: + self._timeline.emit( + "user_input_dropped", + reason=reason, + stage=stage, + agent_message_type=agent_message_type, + agent_result_type=agent_result_type, + text=text, + transcript_len=len(transcription or ""), + ) + + @staticmethod + def _speech_id_from_reply(reply: BackendReply) -> str: + metadata = reply.metadata + if not isinstance(metadata, Mapping): + return "" + return str(metadata.get("speech_id") or "").strip() + + @staticmethod + def _message_id_from_reply(reply: BackendReply) -> str: + metadata = reply.metadata + if not isinstance(metadata, Mapping): + return "" + + for key in ("message_id", "messageId"): + value = metadata.get(key) + if value not in (None, ""): + return str(value).strip() + + payload = metadata.get("payload") + if isinstance(payload, Mapping): + for key in ("message_id", "messageId"): + value = payload.get(key) + if value not in (None, ""): + return str(value).strip() + + return "" + + @staticmethod + def _backend_reply_with_message_id(reply: BackendReply, message_id: str) -> BackendReply: + message_id = str(message_id or "").strip() + if not message_id: + return reply + + metadata = dict(reply.metadata) if isinstance(reply.metadata, Mapping) else {} + metadata["message_id"] = message_id + return BackendReply( + stage=reply.stage, + text=reply.text, + done=reply.done, + export_payload=reply.export_payload, + metadata=metadata, + ) + + @staticmethod + def _payload_with_message_id(payload: Any, message_id: str, *, text: str = "") -> Any: + message_id = str(message_id or "").strip() + if not message_id: + return payload + + if isinstance(payload, Mapping): + enriched = dict(payload) + enriched["message_id"] = message_id + if text and not any( + key in enriched + for key in ("text", "transcript", "utterance", "message", "content") + ): + enriched["text"] = text + return enriched + + return { + "text": str(text or payload or "").strip(), + "message_id": message_id, + } + + def _consume_turn_message_id(self, transcription: str, text: str) -> str: + if not text: + return "" + + turn = consume_transcribed_turn( + self._structured_log_context, + transcription=transcription, + text=text, + ) + if turn is None: + return "" + clear_started_turn_message_id( + self._structured_log_context, + message_id=turn.message_id, + ) + return turn.message_id + + @staticmethod + def _combined_interruption_transcription(turns: list[PendingUserTurn]) -> tuple[str, str]: + combined_text = ". ".join( + turn.text.strip().rstrip(".") for turn in turns if turn.text.strip() + ).strip() + if not turns: + return "", combined_text + try: + payload = json.loads(turns[0].transcription) + except Exception: + return combined_text, combined_text + if not isinstance(payload, dict): + return combined_text, combined_text + data = payload.get("data") + if isinstance(data, dict): + payload = dict(payload) + payload["data"] = dict(data) + payload["data"]["text"] = combined_text + payload["data"].pop("words", None) + elif "text" in payload: + payload = dict(payload) + payload["text"] = combined_text + else: + payload = dict(payload) + payload["text"] = combined_text + return json.dumps(payload, ensure_ascii=False), combined_text + + def _prune_pending_vad_utterances(self) -> None: + deferred = self.state.deferred_interruption + now = time.monotonic() + queues = ( + ("pre_backend", deferred.pre_backend_vad_utterances), + ("backend", deferred.pending_vad_utterances), + ) + for queue_name, queue in queues: + expired = 0 + capacity_dropped = 0 + while ( + queue + and queue[0].ended_at + and now - queue[0].ended_at > PENDING_VAD_STT_MAX_AGE_S + ): + utterance = queue.popleft() + expired += 1 + if queue_name == "backend" and utterance.is_long: + deferred.pending_long_stt_finals = max( + 0, deferred.pending_long_stt_finals - 1 + ) + while len(queue) > PENDING_VAD_STT_MAX_UTTERANCES: + utterance = queue.popleft() + capacity_dropped += 1 + if queue_name == "backend" and utterance.is_long: + deferred.pending_long_stt_finals = max( + 0, deferred.pending_long_stt_finals - 1 + ) + if expired: + log_flow_event( + self._call_logger, + "deferred_interruption_vad_expired", + queue=queue_name, + discarded_utterances=expired, + max_age_s=PENDING_VAD_STT_MAX_AGE_S, + ) + if capacity_dropped: + log_flow_event( + self._call_logger, + "deferred_interruption_vad_capacity_shed", + queue=queue_name, + discarded_utterances=capacity_dropped, + max_utterances=PENDING_VAD_STT_MAX_UTTERANCES, + ) + if not deferred.pending_long_stt_finals: + self._deferred_long_stt_settled.set() + + def _promote_pre_backend_vad_utterances(self) -> None: + deferred = self.state.deferred_interruption + self._prune_pending_vad_utterances() + if not deferred.pre_backend_vad_utterances: + return + promoted = list(deferred.pre_backend_vad_utterances) + deferred.pre_backend_vad_utterances.clear() + deferred.pending_vad_utterances.extend(promoted) + long_count = sum(1 for utterance in promoted if utterance.is_long) + if not long_count: + return + if long_count: + deferred.pending_long_stt_finals += long_count + self._deferred_long_stt_settled.clear() + if not deferred.special_comfort_sent: + deferred.special_comfort_sent = True + self._record_inflight_backend_activity() + self._play_deferred_interruption_comfort() + log_flow_event( + self._call_logger, + "deferred_interruption_vad_promoted", + promoted_utterances=len(promoted), + promoted_long_stt_finals=long_count, + ) + + def _consume_vad_for_stt_final(self) -> DeferredVADUtterance | None: + deferred = self.state.deferred_interruption + self._prune_pending_vad_utterances() + queue = ( + deferred.pending_vad_utterances + if deferred.backend_in_flight or deferred.replay_in_flight + else deferred.pre_backend_vad_utterances + ) + if not queue: + return None + utterance = queue.popleft() + if deferred.backend_in_flight and utterance.is_long: + deferred.pending_long_stt_finals = max(0, deferred.pending_long_stt_finals - 1) + if not deferred.pending_long_stt_finals: + self._deferred_long_stt_settled.set() + return utterance + + def _reset_deferred_interruption(self) -> None: + deferred = self.state.deferred_interruption + deferred.backend_in_flight = False + deferred.pre_backend_vad_utterances.clear() + deferred.pending_vad_utterances.clear() + deferred.pending_long_stt_finals = 0 + deferred.long_turns.clear() + deferred.special_comfort_sent = False + deferred.replay_in_flight = False + deferred.cycle_seq += 1 + self._deferred_empty_final_latched_at = 0.0 + self._deferred_long_stt_settled.set() + + def _release_deferred_cycle(self, *, is_deferred_replay: bool) -> None: + """Fecha o ciclo diferido ao encerrar um turno do pipeline. + + R2 sempre limpa: e o fim do reenvio unico. R1 so limpa se chegou a abrir + o ciclo, e nunca quando acabou de despachar R2 -- ai a marca de reenvio + pertence ao turno seguinte, nao a este. + """ + deferred = self.state.deferred_interruption + if is_deferred_replay or ( + deferred.special_comfort_sent and not deferred.replay_in_flight + ): + self._reset_deferred_interruption() + + def _play_deferred_interruption_comfort(self, *, message_id: str = "") -> None: + deferred = self.state.deferred_interruption + cycle_seq = deferred.cycle_seq + + async def _play() -> None: + # O audio so entra no silencio do cliente. A espera tem teto porque + # um estado de fala travado deixaria a tarefa pendurada ate o fim da + # ligacao. + if not await self._wait_event_or_finalized( + self._user_not_speaking, + timeout=max( + 0.0, + float(self._config.deferred_interruption_user_turn_timeout_s), + ), + ): + log_flow_event( + self._call_logger, + "deferred_interruption_comfort_skipped", + reason="user_still_speaking", + ) + return + deferred = self.state.deferred_interruption + if ( + self.finalized.is_set() + or deferred.cycle_seq != cycle_seq + or deferred.replay_in_flight + or not deferred.special_comfort_sent + ): + return + base_message_id = str(message_id or "").strip() or self._current_base_message_id() + comfort_message_id = self._interruption_comfort_message_id( + message_id=base_message_id, + ) + audio_duration_ms = wav_duration_ms(str(DEFERRED_INTERRUPTION_COMFORT_AUDIO_PATH)) + await self.say_stage( + DEFERRED_INTERRUPTION_COMFORT_TEXT, + "INTERRUPTION_COMFORT", + # Comeca protegido contra o STT final da fala que originou este + # conforto e volta a ser interrompivel assim que esse final chega. + allow_interruptions=False, + rearm_interruptions_on_next_user_speech=True, + add_to_chat_ctx=False, + schedule_idle_after=False, + require_user_not_speaking=True, + message_id=comfort_message_id, + audio=wav_audio_frames(str(DEFERRED_INTERRUPTION_COMFORT_AUDIO_PATH)), + audio_duration_ms=audio_duration_ms, + ) + + self._create_task_logged( + _play(), + name="deferred_interruption_comfort", + ) + + def _play_deferred_short_comfort(self, *, source: str) -> None: + deferred = self.state.deferred_interruption + cycle_seq = deferred.cycle_seq + replay_in_flight = deferred.replay_in_flight + log_flow_event( + self._call_logger, + "deferred_short_comfort_scheduled", + source=source, + replay_in_flight=replay_in_flight, + ) + + async def _play() -> None: + current = self.state.deferred_interruption + if ( + self.finalized.is_set() + or current.cycle_seq != cycle_seq + or current.replay_in_flight != replay_in_flight + ): + return + await self._play_inflight_backend_wait_notice_audio( + user_seq=None, + attempt=1, + require_user_not_speaking=True, + ) + + self._create_task_logged( + _play(), + name=f"deferred_short_comfort_{source}", + ) + + async def _dispatch_deferred_interruption(self, reply: BackendReply) -> bool: + deferred = self.state.deferred_interruption + if deferred.replay_in_flight or not deferred.long_turns: + return False + turns = list(deferred.long_turns) + transcription, text = self._combined_interruption_transcription(turns) + turn = turns[-1] + speech_id = self._speech_id_from_reply(reply) + # O reenvio e unico: a marca precisa estar ativa antes de R2 ser + # agendado para que nenhum evento concorrente do cliente abra R3. + deferred.pending_vad_utterances.clear() + deferred.pending_long_stt_finals = 0 + deferred.long_turns.clear() + deferred.replay_in_flight = True + self._deferred_long_stt_settled.set() + await self.execute( + SetPendingInterrupt(listened_text="", skipped=True, speech_id=speech_id) + ) + log_flow_event( + self._call_logger, + "deferred_interruption_dispatched", + user_seq=turn.seq, + speech_id=speech_id, + accumulated_turns=len(turns), + text=text, + ) + self._create_task_logged( + self.run_pipeline( + transcription, + text, + is_deferred_replay=True, + user_seq=turn.seq, + message_id=turn.message_id, + inflight_initial_notice_consumed=True, + ), + name=f"run_pipeline_deferred_interruption_{turn.seq}", + ) + return True + + def note_vad_speech_end(self, speech_duration_ms: int) -> None: + duration_ms = max(0, int(speech_duration_ms or 0)) + self._last_vad_speech_end_at = time.monotonic() + self._last_vad_speech_duration_ms = duration_ms + self._emit_debug_event( + "user.speech.finished", + speech_duration_ms=duration_ms, + ) + deferred = self.state.deferred_interruption + minimum_audio_ms = max(0, int(self._config.deferred_interruption_min_audio_ms)) + is_long = duration_ms >= minimum_audio_ms + self._prune_pending_vad_utterances() + + if deferred.replay_in_flight: + log_flow_event( + self._call_logger, + "deferred_replay_vad_discarded", + speech_duration_ms=duration_ms, + is_long=is_long, + minimum_audio_ms=minimum_audio_ms, + short_comfort_scheduled=False, + ) + return + + utterance = DeferredVADUtterance( + duration_ms=duration_ms, + is_long=is_long, + ended_at=time.monotonic(), + ) + if not deferred.backend_in_flight: + if not self._config.deferred_interruption_enabled: + return + # O STT pode terminar depois que R1 for aberto. Guardamos o fim de + # fala para preservar a associacao VAD/STT nessa corrida. + deferred.pre_backend_vad_utterances.append(utterance) + log_flow_event( + self._call_logger, + "deferred_interruption_vad_pending_pre_backend", + speech_duration_ms=duration_ms, + is_long=is_long, + minimum_audio_ms=minimum_audio_ms, + ) + return + + if not self._config.deferred_interruption_enabled: + log_flow_event( + self._call_logger, + "backend_processing_vad_discarded", + speech_duration_ms=duration_ms, + minimum_audio_ms=minimum_audio_ms, + mode="feedback_compat", + ) + return + + deferred.pending_vad_utterances.append(utterance) + if is_long: + # Only utterances accepted by the business duration rule start a + # new interaction cycle. Short utterances may temporarily suppress + # playout while the customer is speaking, but must not postpone the + # periodic notice for the request already in flight. + self._record_inflight_backend_activity() + deferred.pending_long_stt_finals += 1 + self._deferred_long_stt_settled.clear() + if not deferred.special_comfort_sent: + deferred.special_comfort_sent = True + self._play_deferred_interruption_comfort() + else: + self._play_deferred_short_comfort(source="repeated_interruption") + + log_flow_event( + self._call_logger, + "deferred_interruption_vad_end", + speech_duration_ms=duration_ms, + is_long=is_long, + minimum_audio_ms=minimum_audio_ms, + ) + + def _close_deferred_vad_window(self) -> None: + """Descarta as falas VAD que nao receberam final dentro do ciclo. + + A associacao VAD/STT e por ordem de chegada. Uma fala sem final que fique + na fila seria consumida pelo final de outra fala no turno seguinte, e a + partir dai todo o pareamento anda deslocado. + """ + deferred = self.state.deferred_interruption + deferred.backend_in_flight = False + discarded_backend = len(deferred.pending_vad_utterances) + discarded_pre_backend = len(deferred.pre_backend_vad_utterances) + if discarded_backend or discarded_pre_backend: + log_flow_event( + self._call_logger, + "deferred_interruption_vad_window_closed", + discarded_utterances=discarded_backend + discarded_pre_backend, + discarded_backend_utterances=discarded_backend, + discarded_pre_backend_utterances=discarded_pre_backend, + pending_long_stt_finals=deferred.pending_long_stt_finals, + ) + deferred.pre_backend_vad_utterances.clear() + deferred.pending_vad_utterances.clear() + deferred.pending_long_stt_finals = 0 + self._deferred_long_stt_settled.set() + + async def _wait_event_or_finalized(self, event: asyncio.Event, *, timeout: float) -> bool: + """Espera o evento, mas desiste se a chamada terminar antes. + + Sem a corrida com `finalized` um desligamento no meio da espera ficaria + parado ate o teto do timeout. + """ + if event.is_set(): + return True + waiters = [ + asyncio.ensure_future(event.wait()), + asyncio.ensure_future(self.finalized.wait()), + ] + try: + await asyncio.wait( + waiters, timeout=timeout, return_when=asyncio.FIRST_COMPLETED + ) + finally: + for waiter in waiters: + if not waiter.done(): + waiter.cancel() + await asyncio.gather(*waiters, return_exceptions=True) + return event.is_set() + + async def _wait_deferred_interruption_settlement(self, *, user_seq: int) -> None: + """Segura a resposta de R1 ate o turno do cliente fechar. + + Com o VAD aberto nao ha o que decidir: a fala em curso ainda pode virar + mais uma transcricao do acumulado. Depois do END_OF_SPEECH resta esperar + o STT correspondente, e essa espera e curta e com teto -- a associacao + VAD/STT e por ordem de chegada, entao um final que nunca vem (falha do + provider, dois segmentos de VAD para um final so) prenderia o run_lock + pelo resto da ligacao. + """ + deferred = self.state.deferred_interruption + user_turn_timeout_s = max( + 0.0, float(self._config.deferred_interruption_user_turn_timeout_s) + ) + stt_settle_timeout_s = max( + 0.0, float(self._config.deferred_interruption_stt_settle_timeout_s) + ) + user_turn_deadline = time.monotonic() + user_turn_timeout_s + waited = False + + while not self.finalized.is_set(): + if not self._user_not_speaking.is_set(): + remaining = user_turn_deadline - time.monotonic() + if remaining <= 0.0: + log_flow_event( + self._call_logger, + "deferred_interruption_settle_timeout", + reason="user_turn", + user_seq=user_seq, + timeout_s=user_turn_timeout_s, + ) + break + waited = True + if not await self._wait_event_or_finalized( + self._user_not_speaking, timeout=remaining + ): + log_flow_event( + self._call_logger, + "deferred_interruption_settle_timeout", + reason="user_turn", + user_seq=user_seq, + timeout_s=user_turn_timeout_s, + ) + break + # O END_OF_SPEECH do VAD e a mudanca de estado do usuario nao tem + # ordem garantida; a folga evita liberar R1 no instante entre os + # dois e perder a ultima fala. + await asyncio.sleep(POST_USER_FINAL_SPEECH_GRACE_S) + continue + + if not deferred.pending_long_stt_finals: + break + + waited = True + if not await self._wait_event_or_finalized( + self._deferred_long_stt_settled, timeout=stt_settle_timeout_s + ): + log_flow_event( + self._call_logger, + "deferred_interruption_settle_timeout", + reason="stt_final", + user_seq=user_seq, + timeout_s=stt_settle_timeout_s, + pending_long_stt_finals=deferred.pending_long_stt_finals, + ) + # A fila esta dessincronizada: um final atrasado consumiria a + # fala errada mais adiante. + deferred.pending_vad_utterances.clear() + deferred.pending_long_stt_finals = 0 + self._deferred_long_stt_settled.set() + break + + if waited: + log_flow_event( + self._call_logger, + "deferred_interruption_settled", + user_seq=user_seq, + accumulated_turns=len(deferred.long_turns), + ) + + def _peek_started_turn_message_id(self) -> str: + return peek_started_turn_message_id(self._structured_log_context) + + def _clear_started_turn_message_id(self, message_id: str = "") -> None: + clear_started_turn_message_id( + self._structured_log_context, + message_id=message_id, + ) + + @staticmethod + def _base_message_id(message_id: str) -> str: + normalized_message_id = str(message_id or "").strip() + if not normalized_message_id: + return "" + + for marker in ( + "_conforto_", + "_inatividade_", + "_idle_nudge_", + "_interruption_confort_", + "_feedback_", + "_tts_error_", + "_transferencia_", + "_erro_terminal_", + "_erro_recurso_", + ): + base_message_id, separator, suffix = normalized_message_id.rpartition(marker) + if separator and base_message_id and suffix: + return base_message_id + return normalized_message_id + + + @staticmethod + def _feedback_message_id(*, message_id: str = "", speech_id: str = "") -> str: + base = str(message_id or "").strip() or str(uuid.uuid4()) + feedback_id = str(speech_id or "").strip() or uuid.uuid4().hex + return f"{base}_feedback_{feedback_id}" + + @staticmethod + def _idle_nudge_message_id(*, message_id: str = "", attempt: int) -> str: + base = str(message_id or "").strip() or str(uuid.uuid4()) + return f"{base}_idle_nudge_{max(1, int(attempt or 1))}" + + @staticmethod + def _interruption_comfort_message_id(*, message_id: str = "") -> str: + base = str(message_id or "").strip() or str(uuid.uuid4()) + return f"{base}_interruption_confort_{uuid.uuid4().hex}" + + @staticmethod + def _tts_error_message_id(*, message_id: str = "") -> str: + base = str(message_id or "").strip() or str(uuid.uuid4()) + return f"{base}_tts_error_{uuid.uuid4().hex}" + + @staticmethod + def _transfer_message_id(*, message_id: str = "") -> str: + base = str(message_id or "").strip() or str(uuid.uuid4()) + return f"{base}_transferencia_{uuid.uuid4().hex}" + + @staticmethod + def _resource_error_message_id( + *, + message_id: str = "", + terminal: bool, + tipo_evento: str, + ) -> str: + base = str(message_id or "").strip() or str(uuid.uuid4()) + family = "erro_terminal" if terminal else "erro_recurso" + direction = "recebimento" if tipo_evento == EVENT_RECEBIMENTO_MSG else "envio" + return f"{base}_{family}_{direction}_{uuid.uuid4().hex}" + + def _remember_agent_base_message_id(self, message_id: str) -> None: + base_message_id = self._base_message_id(message_id) + if base_message_id: + self._last_agent_message_id = base_message_id + + def _current_base_message_id(self) -> str: + return self._peek_started_turn_message_id() or self._last_agent_message_id + + def _resolve_agent_message_id(self, message_id: str = "", *, generate: bool = False) -> str: + resolved_message_id = str(message_id or "").strip() or self._current_base_message_id() + if not resolved_message_id and generate: + resolved_message_id = next_turn_message_id(self._structured_log_context) + self._remember_agent_base_message_id(resolved_message_id) + return resolved_message_id + + @classmethod + def _allow_interruptions_for_reply( + cls, + reply: BackendReply, + default: bool, + ) -> bool: + explicit = cls._metadata_bool(reply.metadata, "is_interruptible") + if explicit is None: + return default + return explicit + + def _current_speech_allows_interruption(self) -> bool: + if not self.speaking.is_set(): + return False + return bool(self.state.current_speech.allow_interruptions) + + def _rearm_current_speech_interruptions_on_new_user_speech(self) -> None: + current = self.state.current_speech + if ( + not self.speaking.is_set() + or not current.rearm_interruptions_on_next_user_speech + ): + return + + handle = current.handle + stage = current.stage + if handle is not None and bool(getattr(handle, "interrupted", False)): + return + try: + if handle is not None: + handle.allow_interruptions = True + except Exception: + self._logger.exception( + "[audio] failed rearming comfort interruptions | stage=%s", + stage, + ) + return + + current.rearm_interruptions_on_next_user_speech = False + current.allow_interruptions = True + log_flow_event( + self._call_logger, + "comfort_interruptions_rearmed", + stage=stage, + reason="new_user_speech", + ) + + @staticmethod + def _status_for_resource(resource: str) -> str: + normalized = str(resource or "").strip() + return IN_SESSION_STOP_STATUS_BY_RESOURCE.get(normalized, "stop_bridge_failed") + + @staticmethod + def _resource_from_session_error(error: Any) -> str: + error_type = str(getattr(error, "type", "") or "").strip().lower() + if error_type == "stt_error": + return "stt" + if error_type == "tts_error": + return "tts" + return "agent_runtime" + + @staticmethod + def _payload_requests_transfer(payload: Any) -> bool: + if isinstance(payload, Mapping): + for key in ("type", "status", "result_type", "resultType"): + value = str(payload.get(key) or "").strip().lower() + if value in {"transferred", "transferido"}: + return True + result = payload.get("result") + if result is not payload and CallRuntime._payload_requests_transfer(result): + return True + return False + + if isinstance(payload, (list, tuple)): + return any(CallRuntime._payload_requests_transfer(item) for item in payload) + + return False + + @classmethod + def _error_message_from_backend_reply(cls, reply: BackendReply) -> str | None: + if cls._payload_requests_transfer(reply.export_payload): + return error_message_from_resource( + status="transferred", + tipo_evento=EVENT_ENVIO_MSG, + ) + return None + + def _log_backend_reply_error_event( + self, + reply: BackendReply, + *, + source: str, + message_id: str = "", + ) -> None: + transfer_status = "" + if isinstance(reply.export_payload, Mapping): + transfer_status = str(reply.export_payload.get("status") or "").strip() + + self._log_structured_error_event( + tipo_evento=EVENT_ENVIO_MSG, + erro_msg=self._error_message_from_backend_reply(reply), + message_id=self._transfer_message_id( + message_id=message_id or self._message_id_from_reply(reply) + ), + status=transfer_status, + reason="agent_transfer", + source=source, + ) + + def _log_structured_error_event( + self, + *, + tipo_evento: str, + erro_msg: str | None, + erro_detalhe: str | None = None, + message_id: str = "", + status: str = "", + reason: str = "", + resource: str = "", + source: str = "", + http_cod_status: int | str | None = None, + http_cod_desc: str | None = None, + ) -> None: + erro_msg = str(erro_msg or "").strip() + if not erro_msg: + return + + dedupe_key = ( + str(tipo_evento or ""), + erro_msg, + str(status or ""), + str(reason or ""), + str(resource or ""), + str(message_id or ""), + ) + if dedupe_key in self._structured_error_event_keys: + return + self._structured_error_event_keys.add(dedupe_key) + + now_ns = time.time_ns() + log_structured_event( + self._call_logger, + self._structured_log_context, + tipo_evento=tipo_evento, + message_id=message_id, + inicio_ns=now_ns, + fim_ns=now_ns, + latencia_total_ms=0, + latencia_tffb_ms=0, + erro_msg=erro_msg, + erro_detalhe=erro_detalhe, + http_cod_status=http_cod_status, + http_cod_desc=http_cod_desc, + finalizacao=status or None, + ) + log_flow_event( + self._call_logger, + "structured_error", + tipo_evento=tipo_evento, + erro_msg=erro_msg, + status=status, + reason=reason, + resource=resource, + source=source, + ) + + def _log_resource_error_events( + self, + *, + status: str = "", + reason: str = "", + resource: str = "", + source: str = "", + message_id: str = "", + exc: Exception | None = None, + terminal: bool = False, + ) -> None: + message_id = str(message_id or "").strip() or self._current_base_message_id() + detail = f"{type(exc).__name__}: {exc}" if exc is not None else None + for tipo_evento in (EVENT_RECEBIMENTO_MSG, EVENT_ENVIO_MSG): + erro_msg = error_message_from_resource( + resource=resource, + status=status, + reason=reason, + tipo_evento=tipo_evento, + ) + self._log_structured_error_event( + tipo_evento=tipo_evento, + erro_msg=erro_msg, + erro_detalhe=detail, + status=status, + reason=reason, + resource=resource, + source=source, + message_id=self._resource_error_message_id( + message_id=message_id, + terminal=terminal, + tipo_evento=tipo_evento, + ), + ) + + async def notify_bridge_stop( + self, + *, + status: str, + reason: str, + resource: str = "", + failed_resources: tuple[str, ...] = (), + phase: str = "in_session", + ) -> None: + try: + await self.execute( + NotifyBridgeStop( + status=status, + reason=reason, + resource=resource, + failed_resources=failed_resources, + phase=phase, + ) + ) + if self._timeline is not None: + self._timeline.emit( + "bridge_stop_requested", + status=status, + reason=reason, + resource=resource, + failed_resources=list(failed_resources), + phase=phase, + ) + except Exception as exc: + self._log_resource_error_events( + status="stop_bridge_failed", + reason="bridge_notify_stop_failed", + resource="bridge", + source="notify_bridge_stop", + exc=exc, + ) + self._logger.exception("[bridge] Falha enviando STOP") + + async def terminate_with_resource_stop( + self, + *, + resource: str, + source: str, + reason: str = "resource_unhealthy", + ) -> None: + async with self._terminal_stop_lock: + if self._terminal_stop_sent: + return + self._terminal_stop_sent = True + + status = self._status_for_resource(resource) + if self._timeline is not None: + self._timeline.emit( + "terminal_stop_started", + status=status, + reason=reason, + resource=resource, + source=source, + ) + + await self.notify_bridge_stop( + status=status, + reason=reason, + resource=resource, + failed_resources=(resource,) if resource else (), + phase="in_session", + ) + self._log_resource_error_events( + status=status, + reason=reason, + resource=resource, + source=source, + terminal=True, + ) + await self.finalize(status) + + async def _wait_post_user_final_grace(self, *, stage: str) -> None: + last_user_final_at = float(self.state.last_user_final_at or 0.0) + if last_user_final_at <= 0.0: + return + + remaining_s = POST_USER_FINAL_SPEECH_GRACE_S - (time.monotonic() - last_user_final_at) + if remaining_s <= 0.0: + return + + self._call_logger.info( + "BOT_SAY_WAIT_POST_FINAL | stage=%s | wait_ms=%s", + stage, + round(remaining_s * 1000), + ) + if self._timeline is not None: + self._timeline.emit( + "tts_stage_wait_post_final", + stage=stage, + wait_ms=round(remaining_s * 1000), + ) + await asyncio.sleep(remaining_s) + + def _has_remote_participants(self) -> bool: + participants = getattr(getattr(self._ctx, "room", None), "remote_participants", None) + return bool(participants) + + @staticmethod + def _decode_data_packet(data_packet: Any) -> dict[str, Any] | None: + raw = getattr(data_packet, "data", "") + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8", "ignore") + if not isinstance(raw, str): + return None + + message = raw.strip() + if not message: + return None + + try: + payload = json.loads(message) + except Exception: + return None + + if not isinstance(payload, dict): + return None + return payload + + @classmethod + def _user_audio_input_setup_timeout_s(cls) -> float: + return cls._float_env_s( + "USER_AUDIO_INPUT_SETUP_TIMEOUT_S", + USER_AUDIO_INPUT_SETUP_TIMEOUT_S, + ) + + def _set_session_audio_input_enabled(self, enabled: bool) -> bool: + session_input = getattr(self._session, "input", None) + set_audio_enabled = getattr(session_input, "set_audio_enabled", None) + if not callable(set_audio_enabled): + return False + + try: + set_audio_enabled(enabled) + except Exception: + self._logger.exception("[audio] failed toggling session audio input") + return False + return True + + def hold_user_audio_input(self, *, reason: str) -> None: + """Descarta o audio do usuario na origem ate a primeira fala do agente terminar.""" + if self._user_audio_input_gate_released or not self._user_audio_input_enabled: + return + if not self._set_session_audio_input_enabled(False): + return + + self._user_audio_input_enabled = False + self._call_logger.info("USER_AUDIO_INPUT_HELD | reason=%s", reason) + log_flow_event( + self._call_logger, + "user_audio_input_held", + reason=reason, + stage=self.state.current_stage, + ) + if self._timeline is not None: + self._timeline.emit("user_audio_input_held", reason=reason) + + def release_user_audio_input(self, *, reason: str) -> None: + if self._user_audio_input_gate_released: + return + + self._user_audio_input_gate_released = True + self._scheduler.cancel( + "user_audio_input_gate", + reason=reason, + log_name="USER_AUDIO_INPUT_GATE_CANCEL", + ) + if self._user_audio_input_enabled: + return + + self._set_session_audio_input_enabled(True) + self._user_audio_input_enabled = True + self._call_logger.info("USER_AUDIO_INPUT_RELEASED | reason=%s", reason) + log_flow_event( + self._call_logger, + "user_audio_input_released", + reason=reason, + stage=self.state.current_stage, + ) + if self._timeline is not None: + self._timeline.emit("user_audio_input_released", reason=reason) + + def _arm_user_audio_input_gate_timeout(self) -> None: + """Rede de seguranca: sem o turno inicial, o gate nunca seria liberado.""" + delay_s = self._user_audio_input_setup_timeout_s() + if delay_s <= 0: + return + + token = 0 + + async def _timer() -> None: + try: + await asyncio.sleep(delay_s) + except asyncio.CancelledError: + return + + if not self._scheduler.is_current("user_audio_input_gate", token): + return + if self._initial_agent_turn_requested: + return + + self._call_logger.warning( + "USER_AUDIO_INPUT_GATE_TIMEOUT | timeout_s=%.1f | reason=initial_agent_turn_not_requested", + delay_s, + ) + self.release_user_audio_input(reason="setup_timeout") + + token = self._scheduler.arm( + "user_audio_input_gate", + task_name="user_audio_input_gate_timeout", + coro=_timer(), + ) + + def request_initial_agent_turn(self, *, reason: str) -> None: + if not self._agent_starts_conversation: + return + if self._initial_agent_turn_requested or self.finalized.is_set(): + return + if self.state.user_final_seq != 0: + return + if str(self.state.current_stage or "INTRO").upper() not in {"", "INTRO"}: + return + + self._initial_agent_turn_requested = True + self._scheduler.cancel( + "user_audio_input_gate", + reason="initial_agent_turn_requested", + log_name="USER_AUDIO_INPUT_GATE_CANCEL", + ) + self._call_logger.info("INITIAL_AGENT_TURN | requested | reason=%s", reason) + if self._timeline is not None: + self._timeline.emit("initial_agent_turn_requested", reason=reason) + + async def _run_initial_turn() -> None: + try: + await self.run_pipeline("", "", user_seq=0) + except Exception: + self._logger.exception("[pipeline] initial agent turn failed") + if self._timeline is not None: + self._timeline.emit("initial_agent_turn_failed", reason=reason) + finally: + self.release_user_audio_input(reason="first_agent_message_played") + + self._create_task_logged( + _run_initial_turn(), + name="run_pipeline_initial", + ) + + def _supports_backend_push(self) -> bool: + pipeline = self._agent.pipeline + if pipeline is None: + return False + + supports = getattr(pipeline, "supports_server_push", None) + if callable(supports): + try: + return bool(supports()) + except Exception: + return False + + return callable(getattr(pipeline, "wait_for_server_push", None)) + + def _supports_inflight_backend_push(self) -> bool: + pipeline = self._agent.pipeline + if pipeline is None: + return False + + supports = getattr(pipeline, "supports_inflight_backend_push", None) + if not callable(supports): + return False + + try: + return bool(supports()) + except Exception: + return False + + def _inflight_backend_wait_enabled(self) -> bool: + return ( + self._supports_inflight_backend_push() + and float(self._config.inflight_backend_wait_timeout_s or 0.0) > 0.0 + ) + + def _inflight_backend_wait_notice_enabled(self) -> bool: + return ( + self._inflight_backend_wait_enabled() + and self._has_inflight_backend_wait_audio() + and float(self._config.inflight_backend_wait_interval_s or 0.0) > 0.0 + ) + + def _record_inflight_backend_activity(self) -> None: + self._inflight_backend_activity_at = time.monotonic() + + def _should_continue_inflight_backend_wait(self) -> bool: + """Keep supervising the backend task for its whole lifetime. + + A valid deferred interruption increments ``user_final_seq`` while the + original backend request remains in flight. Tying this supervisor to + that sequence used to disable both periodic notices and the timeout, + leaving the call waiting silently for the backend. The coroutine that + owns the backend task already defines the request lifetime; only call + finalization should stop its supervision early. + """ + return not self.finalized.is_set() + + @staticmethod + def _resolve_wait_audio_path(raw_path: str) -> Path: + path = Path(raw_path).expanduser() + if not path.is_absolute(): + path = Path.cwd() / path + return path + + @classmethod + def _wait_audio_candidates_from_value(cls, value: str) -> tuple[Path, ...]: + raw_path = str(value or "").strip() + if not raw_path: + return () + + path = cls._resolve_wait_audio_path(raw_path) + if path.is_file() and path.suffix.lower() == ".wav": + return (path,) + if not path.is_dir(): + return () + + return tuple( + sorted( + child + for child in path.iterdir() + if child.is_file() and child.suffix.lower() == ".wav" + ) + ) + + @classmethod + def _inflight_backend_wait_audio_candidates_from_config( + cls, + config: RuntimeConfig, + ) -> dict[str, tuple[Path, ...]]: + long_candidates: list[Path] = [] + long_candidates.extend( + cls._wait_audio_candidates_from_value( + config.inflight_backend_wait_long_audio_dir + ) + ) + long_candidates.extend( + cls._wait_audio_candidates_from_value( + config.inflight_backend_wait_long_audio_path + ) + ) + return { + "short": cls._wait_audio_candidates_from_value( + config.inflight_backend_wait_short_audio_dir + ), + "long": tuple(dict.fromkeys(long_candidates)), + } + + @staticmethod + def _unique_wait_audio_candidates( + candidates_by_kind: Mapping[str, Iterable[Path]], + ) -> tuple[Path, ...]: + paths: list[Path] = [] + for candidates in candidates_by_kind.values(): + paths.extend(candidates) + return tuple(dict.fromkeys(paths)) + + @staticmethod + def _safe_log_warning(logger: Any, msg: str, *args: Any) -> None: + log_fn = getattr(logger, "warning", None) or getattr(logger, "info", None) + if not callable(log_fn): + return + try: + log_fn(msg, *args) + except Exception: + return + + @classmethod + def _preload_wait_audio_paths( + cls, + paths: Iterable[Path], + *, + logger: Any = None, + ) -> tuple[dict[Path, int], int]: + durations: dict[Path, int] = {} + failures = 0 + for audio_path in paths: + try: + durations[audio_path] = wav_duration_ms(str(audio_path)) + except Exception as exc: + failures += 1 + cls._safe_log_warning( + logger, + "[audio] failed preloading wait audio path=%s error=%s: %s", + audio_path, + type(exc).__name__, + exc, + ) + return durations, failures + + @classmethod + def prewarm_wait_audio_cache( + cls, + *, + short_audio_dir: str = "", + long_audio_dir: str = "", + long_audio_path: str = "", + logger: Any = None, + ) -> tuple[int, int]: + config = RuntimeConfig( + call_end_grace_s=0.0, + final_grace_s=0.0, + idle_nudge_delay_s=0.0, + idle_nudge_join_delay_s=0.0, + idle_nudge_close_delay_s=0.0, + idle_nudge_max_tries=0, + idle_nudge_end_reason="", + inflight_backend_wait_short_audio_dir=short_audio_dir, + inflight_backend_wait_long_audio_dir=long_audio_dir, + inflight_backend_wait_long_audio_path=long_audio_path, + ) + candidates_by_kind = cls._inflight_backend_wait_audio_candidates_from_config(config) + durations, failures = cls._preload_wait_audio_paths( + cls._unique_wait_audio_candidates(candidates_by_kind), + logger=logger, + ) + return len(durations), failures + + def _inflight_backend_wait_audio_candidates(self, kind: str) -> tuple[Path, ...]: + normalized = str(kind or "").strip().lower() + if normalized == "short": + return self._inflight_backend_wait_audio_candidates_cache.get("short", ()) + if normalized == "long": + return self._inflight_backend_wait_audio_candidates_cache.get("long", ()) + return () + + def _has_inflight_backend_wait_audio(self) -> bool: + return bool( + self._inflight_backend_wait_audio_candidates("short") + or self._inflight_backend_wait_audio_candidates("long") + ) + + @staticmethod + def _preferred_inflight_backend_wait_audio_kind(attempt: int) -> str: + return "short" if int(attempt or 0) <= 1 else "long" + + def _select_inflight_backend_wait_audio( + self, + *, + attempt: int, + ) -> tuple[Path | None, str]: + preferred_kind = self._preferred_inflight_backend_wait_audio_kind(attempt) + candidates = self._inflight_backend_wait_audio_candidates(preferred_kind) + selected_kind = preferred_kind + if not candidates: + selected_kind = "long" if preferred_kind == "short" else "short" + candidates = self._inflight_backend_wait_audio_candidates(selected_kind) + + if not candidates: + return None, selected_kind + return random.choice(candidates), selected_kind + + def _inflight_backend_wait_audio_path(self, *, attempt: int = 2) -> Path | None: + audio_path, _kind = self._select_inflight_backend_wait_audio(attempt=attempt) + return audio_path + + def _default_inflight_backend_wait_text(self) -> str: + text = str(self._config.inflight_backend_wait_text or "").strip() + return text or "Um momento, ainda estou consultando para te ajudar." + + @staticmethod + def _inflight_backend_wait_audio_text(audio_path: Path, *, fallback_text: str) -> str: + text_path = audio_path.with_suffix(".txt") + if not text_path.is_file(): + return fallback_text + + for encoding in ("utf-8-sig", "utf-8", "latin-1"): + try: + text = " ".join(text_path.read_text(encoding=encoding).split()) + except UnicodeDecodeError: + continue + except OSError: + return fallback_text + return text or fallback_text + + return fallback_text + + def _cached_inflight_backend_wait_audio_text( + self, + audio_path: Path, + *, + fallback_text: str, + ) -> str: + key = (audio_path, fallback_text) + cached = self._inflight_backend_wait_audio_text_cache.get(key) + if cached is not None: + return cached + text = self._inflight_backend_wait_audio_text( + audio_path, + fallback_text=fallback_text, + ) + self._inflight_backend_wait_audio_text_cache[key] = text + return text + + def _preload_inflight_backend_wait_audio_texts(self) -> None: + fallback_text = self._default_inflight_backend_wait_text() + for audio_path in self._unique_wait_audio_candidates( + self._inflight_backend_wait_audio_candidates_cache + ): + self._cached_inflight_backend_wait_audio_text( + audio_path, + fallback_text=fallback_text, + ) + + def _inflight_backend_wait_audio_duration_ms(self, audio_path: Path) -> int | None: + if audio_path in self._inflight_backend_wait_audio_duration_cache: + return self._inflight_backend_wait_audio_duration_cache[audio_path] + try: + duration_ms = wav_duration_ms(str(audio_path)) + except Exception: + return None + self._inflight_backend_wait_audio_duration_cache[audio_path] = duration_ms + return duration_ms + + @staticmethod + def _inflight_backend_wait_message_id(*, message_id: str = "", attempt: int) -> str: + base = str(message_id or "").strip() or str(uuid.uuid4()) + comfort_id = uuid.uuid4().hex + return f"{base}_conforto_{comfort_id}" + + @staticmethod + def _agent_wait_timeout_inactivity_message_id(*, message_id: str = "", attempt: int) -> str: + base = str(message_id or "").strip() or str(uuid.uuid4()) + return f"{base}_inatividade_{max(1, int(attempt or 1))}" + + async def _play_inflight_backend_wait_notice_audio( + self, + *, + user_seq: int | None, + attempt: int, + message_id: str = "", + require_user_not_speaking: bool = True, + abort_if_agent_spoke_since: int | None = None, + ) -> InflightBackendWaitNoticeResult: + if user_seq is not None and not self._should_continue_inflight_backend_wait(): + return InflightBackendWaitNoticeResult(started=False) + + processing_notice = user_seq is not None + fallback_text = self._default_inflight_backend_wait_text() + audio_path, notice_kind = self._select_inflight_backend_wait_audio(attempt=attempt) + if audio_path is None or not self._has_remote_participants(): + return InflightBackendWaitNoticeResult(started=False) + if require_user_not_speaking and not self._user_not_speaking.is_set(): + return InflightBackendWaitNoticeResult( + started=False, + interrupted_by_user=processing_notice, + ) + text = self._cached_inflight_backend_wait_audio_text( + audio_path, + fallback_text=fallback_text, + ) + duration_ms = self._inflight_backend_wait_audio_duration_ms(audio_path) + + base_message_id = str(message_id or "").strip() or self._current_base_message_id() + comfort_message_id = self._inflight_backend_wait_message_id( + message_id=base_message_id, + attempt=attempt, + ) + self._call_logger.info( + "INFLIGHT_BACKEND_WAIT_AUDIO | message_id=%s | original_message_id=%s | user_seq=%s | attempt=%s | kind=%s | path=%s | text=%r", + comfort_message_id, + base_message_id, + user_seq, + attempt, + notice_kind, + audio_path, + text, + ) + if self._timeline is not None: + self._timeline.emit( + "inflight_backend_wait_audio", + message_id=comfort_message_id, + user_seq=user_seq, + attempt=attempt, + kind=notice_kind, + path=str(audio_path), + duration_ms=duration_ms, + text=text, + ) + + interrupted_by_user = False + + def _capture_playout_result( + *, + interrupted: bool, + user_spoke_during: bool, + ) -> None: + nonlocal interrupted_by_user + interrupted_by_user = processing_notice and ( + interrupted or user_spoke_during + ) + + try: + started = await self.say_stage( + text, + INFLIGHT_BACKEND_WAIT_STAGE, + add_to_chat_ctx=False, + # O aviso curto comeca antes do STT final do mesmo turno. Ele fica + # protegido somente ate esse final chegar; depois uma nova fala + # real do cliente pode interrompe-lo normalmente. + allow_interruptions=notice_kind != "short", + rearm_interruptions_on_next_user_speech=notice_kind == "short", + schedule_idle_after=False, + wait_post_final_grace=False, + require_user_not_speaking=require_user_not_speaking, + abort_if_agent_spoke_since=abort_if_agent_spoke_since, + message_id=comfort_message_id, + audio=wav_audio_frames(str(audio_path)), + audio_duration_ms=duration_ms, + playout_result_callback=_capture_playout_result, + ) + result = InflightBackendWaitNoticeResult( + started=bool(started), + interrupted_by_user=interrupted_by_user, + ) + if result: + self._record_inflight_backend_activity() + return result + except Exception: + self._logger.exception("[audio] failed playing inflight backend wait audio") + if self._timeline is not None: + self._timeline.emit( + "audio_stage_failed", + stage=INFLIGHT_BACKEND_WAIT_STAGE, + source="inflight_backend_wait_audio", + path=str(audio_path), + ) + await self.terminate_with_resource_stop( + resource="tts", + source="inflight_backend_wait_audio", + ) + raise InflightBackendWaitTimedOut() + + def _cancel_pre_backend_wait_notice(self, *, reason: str) -> None: + self._pre_backend_wait_notice_generation += 1 + self._pre_backend_wait_notice_reserved = False + task = self._pre_backend_wait_notice_task + self._pre_backend_wait_notice_task = None + if task is not None and task is not asyncio.current_task() and not task.done(): + task.cancel() + if self._timeline is not None: + self._timeline.emit("pre_backend_wait_notice_cancelled", reason=reason) + + def _reserve_pre_backend_wait_notice(self) -> bool: + if ( + self.finalized.is_set() + or not self._inflight_backend_wait_notice_enabled() + or not self._has_remote_participants() + ): + return False + + self._pre_backend_wait_notice_reserved = True + self._record_inflight_backend_activity() + return True + + def _consume_pre_backend_wait_notice(self) -> bool: + if not self._pre_backend_wait_notice_reserved: + return False + + self._pre_backend_wait_notice_reserved = False + return True + + def _pre_backend_wait_notice_is_current_speech(self) -> bool: + return ( + self._pre_backend_wait_notice_reserved + and self.state.current_speech.stage == INFLIGHT_BACKEND_WAIT_STAGE + ) + + def _inflight_backend_wait_notice_is_speaking(self) -> bool: + return ( + self.speaking.is_set() + and self.state.current_speech.stage == INFLIGHT_BACKEND_WAIT_STAGE + ) + + def _pre_backend_wait_notice_fast_on_vad_pause(self, *, reason: str) -> bool: + return ( + bool(self._config.pre_backend_wait_notice_fast_on_vad_pause) + and str(reason or "").strip() == "vad_end_of_speech" + ) + + def _log_pre_backend_wait_notice_skipped(self, *, reason: str, skipped_reason: str) -> None: + log_flow_event( + self._call_logger, + "pre_backend_wait_notice_skipped", + reason=reason, + skipped_reason=skipped_reason, + ) + if self._timeline is not None: + self._timeline.emit( + "pre_backend_wait_notice_skipped", + reason=reason, + skipped_reason=skipped_reason, + ) + + def _schedule_pre_backend_wait_notice(self, *, reason: str) -> None: + if not self._inflight_backend_wait_notice_enabled() or self.finalized.is_set(): + return + post_playout_drop_context = self._active_post_playout_drop_context( + reason="protected_speech_post_playout_grace" + ) + if ( + self._drop_next_user_final_context is not None + or self._backend_processing_drop_context is not None + or post_playout_drop_context is not None + ): + self._log_pre_backend_wait_notice_skipped( + reason=reason, + skipped_reason="user_input_drop_pending", + ) + return + if self.speaking.is_set(): + # Nao ha silencio para mascarar: o aviso so entraria na fila do + # say_lock e sairia colado no fim da fala do agente. + self._log_pre_backend_wait_notice_skipped( + reason=reason, + skipped_reason="agent_speaking", + ) + return + if self._agent._run_lock.locked(): + # O fim de fala aconteceu enquanto o backend ainda processava o + # turno anterior. O STT pode transformar essa fala em uma + # interrupcao diferida, que possui seu proprio conforto dedicado. + # Tocar o aviso especulativo agora produziria dois confortos em + # sequencia: AGENT_BACKEND_WAIT e, logo depois, + # INTERRUPTION_COMFORT. + self._log_pre_backend_wait_notice_skipped( + reason=reason, + skipped_reason="backend_processing", + ) + return + if self._pre_backend_wait_notice_reserved: + return + if ( + self._pre_backend_wait_notice_task is not None + and not self._pre_backend_wait_notice_task.done() + ): + return + + self._cancel_pre_backend_wait_notice(reason=f"restart:{reason}") + self._pre_backend_wait_notice_generation += 1 + generation = self._pre_backend_wait_notice_generation + fast_on_vad_pause = self._pre_backend_wait_notice_fast_on_vad_pause( + reason=reason + ) + say_stage_seq_at_schedule = self._say_stage_seq + + async def _run() -> None: + try: + if not fast_on_vad_pause: + await asyncio.sleep(PRE_BACKEND_WAIT_NOTICE_GUARD_S) + if generation != self._pre_backend_wait_notice_generation: + return + if not fast_on_vad_pause and not self._user_not_speaking.is_set(): + self._log_pre_backend_wait_notice_skipped( + reason=reason, + skipped_reason="user_speaking", + ) + return + if self.speaking.is_set(): + self._log_pre_backend_wait_notice_skipped( + reason=reason, + skipped_reason="agent_speaking", + ) + return + if not self._reserve_pre_backend_wait_notice(): + return + + if self._timeline is not None: + self._timeline.emit( + "pre_backend_wait_notice_started", + reason=reason, + fast_on_vad_pause=fast_on_vad_pause, + ) + + played = await self._play_inflight_backend_wait_notice_audio( + user_seq=None, + attempt=1, + message_id=self._peek_started_turn_message_id(), + require_user_not_speaking=not fast_on_vad_pause, + abort_if_agent_spoke_since=say_stage_seq_at_schedule, + ) + if not played: + # Sem audio reproduzido nao ha aviso a consumir no pipeline: + # devolve a reserva para nao bloquear o proximo agendamento. + self._cancel_pre_backend_wait_notice(reason="notice_not_played") + except asyncio.CancelledError: + return + except InflightBackendWaitTimedOut: + return + finally: + if self._pre_backend_wait_notice_task is asyncio.current_task(): + self._pre_backend_wait_notice_task = None + + self._pre_backend_wait_notice_task = self._create_task_logged( + _run(), + name="pre_backend_wait_notice", + ) + + def schedule_pre_backend_wait_notice(self, *, reason: str) -> None: + self._schedule_pre_backend_wait_notice(reason=reason) + + async def _execute_pipeline_with_inflight_backend_wait( + self, + stt_payload: Any, + *, + user_seq: int, + message_id: str = "", + initial_notice_consumed: bool = False, + ) -> BackendReply: + if not self._inflight_backend_wait_enabled(): + return await self.execute(RunPipelineInput(stt_payload)) + + timeout_s = max(0.0, float(self._config.inflight_backend_wait_timeout_s or 0.0)) + interval_s = max(0.0, float(self._config.inflight_backend_wait_interval_s or 0.0)) + max_notices = max(0, int(self._config.inflight_backend_wait_max_notices or 0)) + can_notice = self._inflight_backend_wait_notice_enabled() + + def _notice_limit_reached(notices: int) -> bool: + # A non-positive limit explicitly means unlimited notices. The + # absolute backend timeout remains the terminal boundary. + return max_notices > 0 and notices >= max_notices + + started_at = time.monotonic() + backend_task = self._create_task_logged( + self.execute(RunPipelineInput(stt_payload)), + name=f"run_pipeline_backend_{user_seq}", + ) + notices_sent = 1 if initial_notice_consumed else 0 + pre_notice_consumed = initial_notice_consumed + max_notices_logged = False + user_silence_task: asyncio.Task[bool] | None = None + if initial_notice_consumed: + # O INTERRUPTION_COMFORT ja confirmou a fala do usuario. Conta como + # primeiro aviso para que o pipeline substituto nao toque um short + # logo em seguida; o proximo aviso, se necessario, sera long e + # respeitara o intervalo normal. + self._inflight_backend_activity_at = started_at + + def _notice_activity_at() -> float: + if pre_notice_consumed: + return self._inflight_backend_activity_at or started_at + return max(started_at, self._inflight_backend_activity_at) + + def _defer_notice_until_user_silence() -> None: + nonlocal user_silence_task + # Any real user activity, including a short utterance that business + # rules later discard, must rearm the notice interval. Otherwise an + # already-due notice is retried immediately after every silence and + # several comfort audios appear back-to-back. + self._record_inflight_backend_activity() + if user_silence_task is None or user_silence_task.done(): + user_silence_task = asyncio.create_task( + self._user_not_speaking.wait(), + name="inflight_backend_wait_user_silence", + ) + + def _log_max_notices_reached() -> None: + nonlocal max_notices_logged + if max_notices_logged or not can_notice or not _notice_limit_reached(notices_sent): + return + max_notices_logged = True + elapsed_s = max(0.0, time.monotonic() - started_at) + remaining_timeout_s = max(0.0, timeout_s - elapsed_s) + log_flow_event( + self._call_logger, + "inflight_backend_wait_max_notices_reached", + user_seq=user_seq, + max_notices=max_notices, + notices_sent=notices_sent, + wait_timeout_s=timeout_s, + remaining_timeout_s=round(remaining_timeout_s, 3), + ) + if self._timeline is not None: + self._timeline.emit( + "inflight_backend_wait_max_notices_reached", + user_seq=user_seq, + max_notices=max_notices, + notices_sent=notices_sent, + wait_timeout_s=timeout_s, + remaining_timeout_s=round(remaining_timeout_s, 3), + ) + self._log_resource_error_events( + status=self._status_for_resource("agent_backend"), + reason="inflight_backend_wait_max_notices_reached", + resource="agent_backend", + source="inflight_backend_wait_max_notices_reached", + message_id=message_id, + ) + + try: + if can_notice: + if initial_notice_consumed: + pass + elif self._consume_pre_backend_wait_notice(): + notices_sent = 1 + pre_notice_consumed = True + else: + self._inflight_backend_activity_at = started_at + await asyncio.sleep(0) + if backend_task.done(): + return backend_task.result() + + notices_sent += 1 + notice_result = await self._play_inflight_backend_wait_notice_audio( + user_seq=user_seq, + attempt=notices_sent, + message_id=message_id, + ) + if not notice_result: + notices_sent -= 1 + if notice_result.interrupted_by_user: + _defer_notice_until_user_silence() + else: + self._record_inflight_backend_activity() + else: + _log_max_notices_reached() + else: + self._inflight_backend_activity_at = started_at + + _log_max_notices_reached() + + while True: + if not self._should_continue_inflight_backend_wait(): + return await backend_task + + now = time.monotonic() + timeout_at = started_at + timeout_s + wait_until = timeout_at + pre_notice_task = self._pre_backend_wait_notice_task + pre_notice_running = ( + notices_sent > 0 + and pre_notice_task is not None + and not pre_notice_task.done() + ) + waiting_for_user_silence = ( + user_silence_task is not None + and not user_silence_task.done() + ) + if ( + can_notice + and not _notice_limit_reached(notices_sent) + and not pre_notice_running + and not waiting_for_user_silence + ): + last_activity_at = _notice_activity_at() + wait_until = min(wait_until, last_activity_at + interval_s) + + wait_s = max(0.0, wait_until - now) + wait_tasks = {backend_task} + if pre_notice_running: + wait_tasks.add(pre_notice_task) + if waiting_for_user_silence and user_silence_task is not None: + wait_tasks.add(user_silence_task) + + done, _pending = await asyncio.wait( + wait_tasks, + timeout=wait_s, + return_when=asyncio.FIRST_COMPLETED, + ) + if backend_task in done: + return backend_task.result() + if pre_notice_task is not None and pre_notice_task in done: + await asyncio.gather(pre_notice_task, return_exceptions=True) + continue + if user_silence_task is not None and user_silence_task in done: + await asyncio.gather(user_silence_task, return_exceptions=True) + user_silence_task = None + continue + + now = time.monotonic() + if timeout_s > 0.0 and now >= timeout_at: + if self._timeline is not None: + self._timeline.emit( + "inflight_backend_wait_timeout", + user_seq=user_seq, + wait_timeout_s=timeout_s, + notices_sent=notices_sent, + ) + backend_task.cancel() + await asyncio.gather(backend_task, return_exceptions=True) + await self.terminate_with_resource_stop( + resource="agent_backend", + source="inflight_backend_wait_timeout", + ) + raise InflightBackendWaitTimedOut() + + if can_notice and not _notice_limit_reached(notices_sent): + last_activity_at = _notice_activity_at() + if now - last_activity_at >= interval_s: + notices_sent += 1 + notice_result = await self._play_inflight_backend_wait_notice_audio( + user_seq=user_seq, + attempt=notices_sent, + message_id=message_id, + ) + if not notice_result: + notices_sent -= 1 + if notice_result.interrupted_by_user: + _defer_notice_until_user_silence() + else: + self._record_inflight_backend_activity() + else: + _log_max_notices_reached() + except InflightBackendWaitTimedOut: + if not backend_task.done(): + backend_task.cancel() + await asyncio.gather(backend_task, return_exceptions=True) + raise + except asyncio.CancelledError: + backend_task.cancel() + await asyncio.gather(backend_task, return_exceptions=True) + raise + finally: + if user_silence_task is not None and not user_silence_task.done(): + user_silence_task.cancel() + await asyncio.gather(user_silence_task, return_exceptions=True) + + def _cancel_backend_push_listener(self, *, reason: str, force: bool = False) -> None: + task = self._backend_push_task + if not force and self.speaking.is_set(): + return + self._backend_push_task = None + if task is not None and not task.done(): + task.cancel() + if self._timeline is not None: + self._timeline.emit("backend_push_cancelled", reason=reason) + + def _schedule_backend_push_listener( + self, + *, + user_seq: int, + reason: str, + inflight: bool = False, + ) -> None: + if self.finalized.is_set(): + return + + supported = ( + self._supports_inflight_backend_push() + if inflight + else self._supports_backend_push() + ) + if not supported: + if not inflight: + self._cancel_backend_push_listener(reason=f"unsupported:{reason}", force=True) + return + + self._cancel_backend_push_listener(reason=f"restart:{reason}", force=True) + + async def _listen() -> None: + try: + while not self.finalized.is_set(): + if user_seq != self.state.user_final_seq: + return + + pipeline = self._agent.pipeline + if pipeline is None: + return + + wait_for_push = getattr(pipeline, "wait_for_server_push", None) + if not callable(wait_for_push): + return + + raw_reply = await wait_for_push() + if raw_reply is None or self.finalized.is_set(): + return + if user_seq != self.state.user_final_seq: + return + + self._record_inflight_backend_activity() + reply = self._coerce_backend_reply( + raw_reply, + fallback_stage=self.state.current_stage, + ) + self._update_backend_processing_drop_from_reply(reply, source="backend_push") + self.state.current_stage = reply.stage + if not reply.text: + self._log_backend_reply_error_event(reply, source="backend_push") + + if reply.text: + if not await self._wait_for_stable_user_silence_before_terminal_reply( + reply, + user_seq=user_seq, + source="backend_push", + ): + return + try: + await self._speak_backend_reply( + reply, + source="backend_push", + add_to_chat_ctx=True, + ) + except Exception: + self._logger.exception("[tts] failed speaking backend push reply") + if self._timeline is not None: + self._timeline.emit( + "tts_stage_failed", + stage=self.state.current_stage, + source="backend_push", + ) + await self.terminate_with_resource_stop( + resource="tts", + source="backend_push_tts", + ) + return + + self.arm_agent_wait_timeout_from_reply( + reply, + user_seq=user_seq, + source="backend_push", + ) + + terminal_stop = self._terminal_stop_from_reply(reply) + if terminal_stop is not None: + await self._finish_with_agent_final_stop( + terminal_stop, + source="backend_push", + ) + return + + if reply.done or self._finalization_policy.is_done_stage(reply.stage): + self._create_task_logged( + self.notify_bridge_done("stage_done"), + name="bridge_done_push", + ) + self._create_task_logged( + self.finalize("stage_done"), + name="finalize_push", + ) + return + except asyncio.CancelledError: + return + except Exception: + self._logger.exception("[pipeline] backend push listener failed") + if self._timeline is not None: + self._timeline.emit("backend_push_failed", reason=reason) + await self.terminate_with_resource_stop( + resource="agent_backend", + source="backend_push_listener", + ) + + self._backend_push_task = self._create_task_logged( + _listen(), + name=f"backend_push_{user_seq}", + ) + if self._timeline is not None: + self._timeline.emit( + "backend_push_started", + reason=reason, + user_seq=user_seq, + inflight=inflight, + ) + + def _coerce_backend_reply(self, raw_reply: Any, *, fallback_stage: str) -> BackendReply: + if isinstance(raw_reply, BackendReply): + stage = (raw_reply.stage or fallback_stage or "UNKNOWN").strip().upper() + done = bool(raw_reply.done) or stage == "DONE" + return BackendReply( + stage=stage, + text=(raw_reply.text or "").strip(), + done=done, + export_payload=raw_reply.export_payload, + metadata=raw_reply.metadata, + ) + + if isinstance(raw_reply, tuple) and len(raw_reply) == 2: + stage_raw, text_raw = raw_reply + stage = str(stage_raw or fallback_stage or "UNKNOWN").strip().upper() + return BackendReply( + stage=stage, + text=str(text_raw or "").strip(), + done=stage == "DONE", + export_payload=None, + ) + + if isinstance(raw_reply, dict): + stage = str(raw_reply.get("stage") or fallback_stage or "UNKNOWN").strip().upper() + text = str(raw_reply.get("text") or "").strip() + done = bool(raw_reply.get("done")) or stage == "DONE" + export_payload = raw_reply.get("export_payload") + if export_payload is None and "result" in raw_reply: + export_payload = raw_reply.get("result") + return BackendReply( + stage=stage, + text=text, + done=done, + export_payload=export_payload, + metadata=raw_reply.get("metadata"), + ) + + stage = str(fallback_stage or self.state.current_stage or "UNKNOWN").strip().upper() + return BackendReply( + stage=stage, + text="", + done=stage == "DONE", + export_payload=raw_reply, + ) + + @staticmethod + def _wait_timeout_from_reply(reply: BackendReply) -> float: + metadata = reply.metadata + if not isinstance(metadata, Mapping): + return 0.0 + + raw_timeout = metadata.get("wait_timeout_seconds") + try: + timeout_s = float(raw_timeout) + except (TypeError, ValueError): + return 0.0 + + if timeout_s <= 0: + return 0.0 + return timeout_s + + @staticmethod + def _reply_has_wait_timeout_metadata(reply: BackendReply) -> bool: + metadata = reply.metadata + return isinstance(metadata, Mapping) and "wait_timeout_seconds" in metadata + + @staticmethod + def _wait_retry_messages_from_reply(reply: BackendReply) -> tuple[str, ...]: + metadata = reply.metadata + if not isinstance(metadata, Mapping): + return () + + raw_messages = metadata.get("wait_retry_messages") + if not isinstance(raw_messages, (list, tuple)): + return () + + messages: list[str] = [] + for raw_message in raw_messages: + message = str(raw_message or "").strip() + if message: + messages.append(message) + return tuple(messages) + + @classmethod + def _wait_timeout_config_from_reply( + cls, + reply: BackendReply, + ) -> AgentWaitTimeoutConfig | None: + timeout_s = cls._wait_timeout_from_reply(reply) + if timeout_s <= 0: + return None + return AgentWaitTimeoutConfig( + timeout_s=timeout_s, + retry_messages=cls._wait_retry_messages_from_reply(reply), + ) + + @staticmethod + def _terminal_stop_from_reply(reply: BackendReply) -> AgentFinalStop | None: + return final_stop_from_agent_result(reply.export_payload) + + async def _wait_for_stable_user_silence_before_terminal_reply( + self, + reply: BackendReply, + *, + user_seq: int, + source: str, + ) -> bool: + """Do not let a terminal reply start on top of an active user utterance. + + User state changes to ``listening`` slightly before the final transcript is + delivered. Requiring a short stable-silence window keeps the terminal + message from starting on top of the user, but a late transcript must not + invalidate a reply that closes the conversation. + """ + terminal = self._terminal_stop_from_reply(reply) is not None or ( + reply.done or self._finalization_policy.is_done_stage(reply.stage) + ) + if not terminal: + return True + + waited = False + while not self.finalized.is_set(): + if not self._user_not_speaking.is_set(): + if not waited: + waited = True + log_flow_event( + self._call_logger, + "terminal_tts_wait_user_silence", + source=source, + stage=reply.stage, + user_seq=user_seq, + ) + await self._user_not_speaking.wait() + + await asyncio.sleep(POST_USER_FINAL_SPEECH_GRACE_S) + if self._user_not_speaking.is_set(): + break + + allowed = not self.finalized.is_set() + if not allowed: + log_flow_event( + self._call_logger, + "terminal_tts_skipped", + reason="already_finalized", + source=source, + stage=reply.stage, + user_seq=user_seq, + current_seq=self.state.user_final_seq, + ) + elif waited: + log_flow_event( + self._call_logger, + "terminal_tts_user_silence_confirmed", + source=source, + stage=reply.stage, + user_seq=user_seq, + ) + return allowed + + async def _finish_with_agent_final_stop( + self, + stop: AgentFinalStop, + *, + source: str, + ) -> None: + async with self._terminal_stop_lock: + if self._terminal_stop_sent: + return + self._terminal_stop_sent = True + + if self._timeline is not None: + self._timeline.emit( + "agent_final_stop_started", + status=stop.status, + reason=stop.reason, + result_type=stop.result_type, + source=source, + ) + + if source == "backend_push": + self._backend_push_task = None + else: + self._cancel_backend_push_listener(reason=f"agent_final_stop:{stop.reason}", force=True) + self.cancel_idle_timer("agent_final_stop") + self.cancel_idle_close("agent_final_stop") + self.cancel_agent_wait_timeout("agent_final_stop") + + self._log_resource_error_events( + status=stop.status, + reason=stop.reason, + source=source, + terminal=True, + ) + await self.notify_bridge_stop( + status=stop.status, + reason=stop.reason, + phase=stop.phase, + ) + self.finalized.set() + if self._timeline is not None: + self._timeline.emit( + "agent_final_stop_completed", + status=stop.status, + reason=stop.reason, + result_type=stop.result_type, + source=source, + ) + + async def _speak_backend_reply( + self, + reply: BackendReply, + *, + initial_turn: bool = False, + source: str, + add_to_chat_ctx: bool, + ) -> None: + structured_erro_msg = self._error_message_from_backend_reply(reply) + message_id = self._message_id_from_reply(reply) + agent_message_type = self._reply_agent_message_type(reply) + agent_result_type = self._reply_agent_result_type(reply) + if ( + agent_message_type == "feedback" + or agent_result_type == "feedback" + or self._metadata_str(reply.metadata, "event") == "feedback" + ): + message_id = self._feedback_message_id( + message_id=message_id, + speech_id=self._speech_id_from_reply(reply), + ) + self._update_backend_processing_drop_from_reply(reply, source=source) + if not reply.text: + self._log_backend_reply_error_event(reply, source=source, message_id=message_id) + return + self._call_logger.debug( + "BACKEND_REPLY_TEXT | source=%s | stage=%s | done=%s | text=%r", + source, + reply.stage, + reply.done, + reply.text, + ) + if self.finalized.is_set(): + self._call_logger.debug( + "BACKEND_TTS_SKIPPED | source=%s | stage=%s | reason=already_finalized", + source, + reply.stage, + ) + log_flow_event( + self._call_logger, + "tts_skip", + source=source, + stage=reply.stage, + reason="already_finalized", + ) + return + if not self._has_remote_participants(): + self._call_logger.debug( + "BACKEND_TTS_SKIPPED | source=%s | stage=%s | reason=no_remote_participants", + source, + reply.stage, + ) + log_flow_event( + self._call_logger, + "tts_skip", + source=source, + stage=reply.stage, + reason="no_remote_participants", + ) + return + + default_allow_interruptions = self._interrupt_policy.allow_stage(reply.stage) + allow_interruptions = self._allow_interruptions_for_reply( + reply, + default_allow_interruptions, + ) + if self._terminal_stop_from_reply(reply) is not None or ( + reply.done or self._finalization_policy.is_done_stage(reply.stage) + ): + # A resposta terminal e a confirmacao audivel do desfecho da chamada. + # Ela pode esperar o usuario terminar, mas nunca pode ser cortada. + allow_interruptions = False + if self.state.deferred_interruption.replay_in_flight: + # A resposta do reenvio nao pode ser interrompida. Descartar a fala do + # cliente no runtime nao basta: o barge-in do proprio LiveKit corta o + # playout por atividade de audio, e R2 sairia pela metade. + allow_interruptions = False + expects_user_response = self._reply_expects_user_response(reply) + initial_audio = None + if initial_turn: + cache_lookup = getattr(self._initial_greeting_tts, "initial_greeting_audio", None) + if callable(cache_lookup): + try: + initial_audio = cache_lookup(reply.text) + except Exception: + self._logger.exception("INITIAL_GREETING_AUDIO_CACHE_LOOKUP_FAIL") + if self._timeline is not None: + self._timeline.emit( + "initial_greeting_audio_cache", + outcome="hit" if initial_audio is not None else "miss_or_disabled", + ) + await self.say_stage( + reply.text, + reply.stage, + add_to_chat_ctx=add_to_chat_ctx, + allow_interruptions=allow_interruptions, + speech_id=self._speech_id_from_reply(reply), + message_id=message_id, + schedule_idle_after=True if expects_user_response is None else expects_user_response, + drop_user_input_while_speaking=self._reply_drops_user_input_while_speaking(reply), + audio=initial_audio, + agent_message_type=agent_message_type, + agent_result_type=agent_result_type, + erro_msg=structured_erro_msg, + ) + + def cancel_agent_wait_timeout(self, reason: str = "cancel") -> None: + self._scheduler.cancel( + "agent_wait_timeout", + reason=reason, + log_name="AGENT_WAIT_TIMEOUT_CANCEL", + ) + + def _remember_agent_wait_timeout( + self, + config: AgentWaitTimeoutConfig, + *, + user_seq: int, + source: str, + message_id: str, + ) -> None: + self._last_agent_wait_timeout_config = config + self._last_agent_wait_timeout_user_seq = user_seq + self._last_agent_wait_timeout_source = source + self._last_agent_wait_timeout_message_id = message_id + + def _clear_agent_wait_timeout_memory(self) -> None: + self._last_agent_wait_timeout_config = None + self._last_agent_wait_timeout_user_seq = 0 + self._last_agent_wait_timeout_source = "" + self._last_agent_wait_timeout_message_id = "" + + def _clear_agent_wait_timeout_config(self) -> None: + self._agent_wait_timeout_config = None + + def _restore_agent_wait_timeout_after_empty_stt(self) -> bool: + config = self._last_agent_wait_timeout_config + if config is None: + return False + if self._last_agent_wait_timeout_user_seq != self.state.user_final_seq: + return False + if self.finalized.is_set() or (self.state.current_stage or "").upper() == "DONE": + return False + + previous_source = self._last_agent_wait_timeout_source + previous_message_id = self._last_agent_wait_timeout_message_id + self._arm_agent_wait_timeout( + config, + user_seq=self.state.user_final_seq, + source="empty_stt_final", + message_id=previous_message_id, + ) + log_flow_event( + self._call_logger, + "stt_final_empty_recovery", + action="rearm_agent_wait_timeout", + previous_source=previous_source, + message_id=previous_message_id, + user_seq=self.state.user_final_seq, + stage=self.state.current_stage, + ) + return True + + def _recover_after_empty_stt_final(self) -> None: + if self.finalized.is_set(): + self._empty_stt_recovery_pending = False + return + if self.speaking.is_set() or self.state.gap.active: + if not self._pre_backend_wait_notice_is_current_speech(): + self._cancel_pre_backend_wait_notice(reason="empty_stt_final") + self._empty_stt_recovery_pending = True + log_flow_event( + self._call_logger, + "stt_final_empty_recovery", + action="defer_while_agent_active", + stage=self.state.current_stage, + speaking=self.speaking.is_set(), + gap_active=self.state.gap.active, + ) + return + self._cancel_pre_backend_wait_notice(reason="empty_stt_final") + self._empty_stt_recovery_pending = False + if self._restore_agent_wait_timeout_after_empty_stt(): + return + + self.arm_idle_timer(reason="empty_stt_final") + log_flow_event( + self._call_logger, + "stt_final_empty_recovery", + action="arm_idle_timer", + stage=self.state.current_stage, + ) + + def handle_empty_stt_final(self, *, transcript_len: int = 0, source: str = "stt_final") -> None: + deferred = self.state.deferred_interruption + if source != "user_input_transcribed": + # O provider avisa o final vazio antes de devolver o SpeechEvent. Em + # raw_json o transcript cru nao e vazio, o LiveKit repassa o evento e + # a mesma fala chegaria duas vezes ate a fila VAD. + self._deferred_empty_final_latched_at = time.monotonic() + if deferred.replay_in_flight: + log_flow_event(self._call_logger, "deferred_replay_stt_discarded", text="") + return + if self._handle_deferred_stt_final(transcription="", text=""): + return + + log_flow_event( + self._call_logger, + "stt_final_empty", + reason="empty_text", + source=source, + stage=self.state.current_stage, + speaking=self.speaking.is_set(), + transcript_len=transcript_len, + pre_backend_wait_reserved=self._pre_backend_wait_notice_reserved, + ) + if self._timeline is not None: + self._timeline.emit( + "user_transcript_final_empty", + current_stage=self.state.current_stage, + speaking=self.speaking.is_set(), + transcript_len=transcript_len, + pre_backend_wait_reserved=self._pre_backend_wait_notice_reserved, + ) + self._recover_after_empty_stt_final() + + async def _stop_due_to_agent_wait_timeout( + self, + *, + reason: str, + source: str, + message_id: str = "", + ) -> None: + async with self._terminal_stop_lock: + if self._terminal_stop_sent: + return + self._terminal_stop_sent = True + + self.cancel_idle_timer("agent_wait_timeout") + self.cancel_idle_close("agent_wait_timeout") + if self._timeline is not None: + self._timeline.emit( + "agent_wait_timeout_fired", + status=AGENT_WAIT_TIMEOUT_STOP_STATUS, + reason=reason, + source=source, + message_id=message_id, + ) + + self._restore_agent_wait_timeout_retry_vad_threshold( + reason=f"agent_wait_timeout:{reason}" + ) + await self.notify_bridge_stop( + status=AGENT_WAIT_TIMEOUT_STOP_STATUS, + reason=reason, + phase="in_session", + ) + self._log_resource_error_events( + status=AGENT_WAIT_TIMEOUT_STOP_STATUS, + reason=reason, + source=source, + message_id=message_id, + terminal=True, + ) + self.finalized.set() + + def _arm_agent_wait_timeout( + self, + config: AgentWaitTimeoutConfig, + *, + user_seq: int, + source: str, + message_id: str = "", + ) -> None: + timeout_s = config.timeout_s + if timeout_s <= 0: + self.cancel_agent_wait_timeout(f"clear:{source}") + self._clear_agent_wait_timeout_memory() + return + + timeout_message_id = self._resolve_agent_message_id(message_id, generate=True) + self._remember_agent_wait_timeout( + config, + user_seq=user_seq, + source=source, + message_id=timeout_message_id, + ) + retry_messages = config.retry_messages + self.cancel_idle_timer("agent_wait_timeout") + self.cancel_idle_close("agent_wait_timeout") + self.cancel_agent_wait_timeout(f"rearm:{source}") + reason = self._idle_policy.end_reason + token = 0 + + def _should_continue() -> bool: + if not self._scheduler.is_current("agent_wait_timeout", token): + return False + if user_seq != self.state.user_final_seq: + return False + if self.finalized.is_set(): + return False + return True + + async def _fire() -> None: + for attempt, retry_text in enumerate(retry_messages, start=1): + try: + await asyncio.sleep(timeout_s) + except asyncio.CancelledError: + return + + if not _should_continue(): + return + + self.cancel_idle_timer("agent_wait_timeout_retry") + self.cancel_idle_close("agent_wait_timeout_retry") + inactivity_message_id = self._agent_wait_timeout_inactivity_message_id( + message_id=timeout_message_id, + attempt=attempt, + ) + self._call_logger.info( + "AGENT_WAIT_TIMEOUT_RETRY | attempt=%s/%s | wait_s=%.1f | message_id=%s | text=%r", + attempt, + len(retry_messages), + timeout_s, + inactivity_message_id, + retry_text, + ) + if self._timeline is not None: + self._timeline.emit( + "agent_wait_timeout_retry", + wait_timeout_s=timeout_s, + attempt=attempt, + max_attempts=len(retry_messages), + reason=reason, + source=source, + user_seq=user_seq, + text=retry_text, + message_id=inactivity_message_id, + ) + + self._activate_agent_wait_timeout_retry_vad_threshold( + attempt=attempt, + reason="agent_wait_timeout_retry", + ) + try: + if self._agent.pipeline is not None: + await self.execute(InjectIdleNudge(retry_text)) + except Exception: + self._logger.debug("IDLE_NUDGE_PIPELINE_HOOK_FAIL", exc_info=True) + + try: + await self.say_stage( + retry_text, + "AGENT_WAIT_TIMEOUT_RETRY", + add_to_chat_ctx=False, + schedule_idle_after=False, + message_id=inactivity_message_id, + ) + except Exception: + self._logger.exception("[tts] failed speaking agent wait retry") + if self._timeline is not None: + self._timeline.emit( + "tts_stage_failed", + stage="AGENT_WAIT_TIMEOUT_RETRY", + source="agent_wait_timeout", + message_id=inactivity_message_id, + ) + await self.terminate_with_resource_stop( + resource="tts", + source="agent_wait_timeout_retry_tts", + ) + return + + if not _should_continue(): + return + + try: + await asyncio.sleep(timeout_s) + except asyncio.CancelledError: + return + + if not _should_continue(): + return + await self._stop_due_to_agent_wait_timeout( + reason=reason, + source=source, + message_id=timeout_message_id, + ) + + token = self._scheduler.arm( + "agent_wait_timeout", + task_name="agent_wait_timeout_timer", + coro=_fire(), + ) + if self._timeline is not None: + self._timeline.emit( + "agent_wait_timeout_armed", + wait_timeout_s=timeout_s, + retry_messages=len(retry_messages), + reason=reason, + source=source, + user_seq=user_seq, + ) + + def arm_agent_wait_timeout_from_reply( + self, + reply: BackendReply, + *, + user_seq: int, + source: str, + ) -> None: + stage = str(reply.stage or self.state.current_stage or "").strip().upper() + if reply.done or self._finalization_policy.is_done_stage(stage): + self.cancel_agent_wait_timeout(f"done:{source}") + self._clear_agent_wait_timeout_memory() + self._clear_agent_wait_timeout_config() + return + + expects_user_response = self._reply_expects_user_response(reply) + if expects_user_response is False: + self.cancel_agent_wait_timeout(f"not_expected:{source}") + self._clear_agent_wait_timeout_memory() + return + + incoming_config = self._wait_timeout_config_from_reply(reply) + has_wait_timeout_metadata = self._reply_has_wait_timeout_metadata(reply) + if incoming_config is not None: + self._agent_wait_timeout_config = incoming_config + elif has_wait_timeout_metadata: + self.cancel_agent_wait_timeout(f"disable:{source}") + self._clear_agent_wait_timeout_memory() + self._clear_agent_wait_timeout_config() + return + elif not reply.text: + self._clear_agent_wait_timeout_memory() + return + + config = self._agent_wait_timeout_config + if config is None: + self._clear_agent_wait_timeout_memory() + return + + self._arm_agent_wait_timeout( + config, + user_seq=user_seq, + source=source, + message_id=self._message_id_from_reply(reply), + ) + + async def notify_bridge_done(self, reason: str = "stage_done") -> None: + try: + await self.execute(NotifyBridgeDone(reason)) + if self._timeline is not None: + self._timeline.emit("bridge_done_requested", reason=reason) + except Exception as exc: + self._log_resource_error_events( + status="stop_bridge_failed", + reason="bridge_notify_done_failed", + resource="bridge", + source="notify_bridge_done", + exc=exc, + ) + self._logger.exception("[bridge] Falha enviando DONE") + + async def finalize(self, reason: str) -> None: + if self._finalization_policy.should_skip(finalized=self.finalized.is_set()): + return + + async with self.finalize_lock: + if self._finalization_policy.should_skip(finalized=self.finalized.is_set()): + return + self._restore_agent_wait_timeout_retry_vad_threshold( + reason=f"finalize:{reason}" + ) + if self._timeline is not None: + self._timeline.emit("finalize_started", reason=reason) + + self._cancel_backend_push_listener(reason=f"finalize:{reason}", force=True) + self.cancel_idle_timer("finalize") + self.cancel_idle_close("finalize") + self.cancel_agent_wait_timeout("finalize") + self._reset_deferred_interruption() + self.release_user_audio_input(reason=f"finalize:{reason}") + + dur = time.monotonic() - self._call_t0 + self._call_logger.info( + "CALL_END | room=%s | protocol=%s | session_id=%s | reason=%s | duration_s=%.1f", + self._ctx.room.name, + self._protocol, + self._session_id, + reason, + dur, + ) + + try: + await asyncio.wait_for(self._agent._ready.wait(), timeout=5.0) + except Exception: + pass + + try: + raw_reply = await self.execute(EndServiceOnce()) + reply = self._coerce_backend_reply( + raw_reply, + fallback_stage=self.state.current_stage, + ) + self.state.current_stage = reply.stage + await self._speak_backend_reply( + reply, + source="end_service_once", + add_to_chat_ctx=False, + ) + export_payload = reply.export_payload if reply.export_payload is not None else [] + await self.execute(ExportSession(output=export_payload, session_id=self._session_id)) + except Exception: + self._logger.exception( + "[finalize] Falha ao finalizar/exportar session_id=%s", + self._session_id, + ) + + self.finalized.set() + if self._timeline is not None: + self._timeline.emit("finalize_completed", reason=reason) + + def cancel_idle_timer(self, reason: str = "cancel") -> None: + self._scheduler.cancel("idle", reason=reason, log_name="IDLE_TIMER_CANCEL") + + def cancel_idle_close(self, reason: str = "cancel") -> None: + self._scheduler.cancel("idle_close", reason=reason, log_name="IDLE_CLOSE_CANCEL") + + def note_user_speech_activity(self, *, reason: str) -> None: + self.cancel_idle_timer(reason) + self.cancel_idle_close(reason) + self.cancel_agent_wait_timeout(reason) + self.state.idle_nudge_count = 0 + if self._timeline is not None: + self._timeline.emit( + "user_speech_activity_detected", + reason=reason, + speaking=self.speaking.is_set(), + current_stage=self.state.current_stage, + ) + + async def _end_due_to_no_response(self) -> None: + end_reason = self._idle_policy.end_reason + self._restore_agent_wait_timeout_retry_vad_threshold(reason="idle_no_response") + self._log_resource_error_events( + status=AGENT_WAIT_TIMEOUT_STOP_STATUS, + reason=end_reason, + source="idle_no_response", + terminal=True, + ) + self._create_task_logged( + self.notify_bridge_done(end_reason), + name="bridge_done_noresp", + ) + self._create_task_logged( + self.finalize(end_reason), + name="finalize_noresp", + ) + + def arm_idle_close_timer(self, *, reason: str) -> None: + if self._idle_policy.close_delay_s <= 0: + self._create_task_logged( + self._end_due_to_no_response(), + name="idle_close_immediate", + ) + return + + self.cancel_idle_timer("close_arm") + self.cancel_idle_close(f"rearm:{reason}") + seq_snapshot = self.state.user_final_seq + token = 0 + + async def _close() -> None: + try: + await asyncio.sleep(self._idle_policy.close_delay_s) + except asyncio.CancelledError: + return + + if not self._idle_policy.should_fire_close( + token_is_current=self._scheduler.is_current("idle_close", token), + seq_matches=self.state.user_final_seq == seq_snapshot, + finalized=self.finalized.is_set(), + current_stage=self.state.current_stage, + ): + return + + t0 = time.monotonic() + while ( + (self.speaking.is_set() or self.state.gap.active) + and not self.finalized.is_set() + and (time.monotonic() - t0) < 3.0 + ): + await asyncio.sleep(0.25) + if not self._scheduler.is_current("idle_close", token): + return + if self.state.user_final_seq != seq_snapshot: + return + + self._call_logger.info( + "IDLE_CLOSE_FIRE | after_max_tries=%s | wait_s=%.1f | reason=%s", + self._idle_policy.max_tries, + self._idle_policy.close_delay_s, + self._idle_policy.end_reason, + ) + if self._timeline is not None: + self._timeline.emit( + "idle_close_fire", + reason=self._idle_policy.end_reason, + max_tries=self._idle_policy.max_tries, + ) + await self._end_due_to_no_response() + + token = self._scheduler.arm( + "idle_close", + task_name="idle_close_timer", + coro=_close(), + ) + + def arm_idle_timer(self, *, reason: str, delay_s: Optional[float] = None) -> None: + delay = self._idle_policy.resolve_delay(delay_s) + if not self._idle_policy.should_arm_nudge(delay_s=delay, nudge_text=self._nudge_text): + return + + self.cancel_idle_timer(f"rearm:{reason}") + seq_snapshot = self.state.user_final_seq + token = 0 + + async def _timer() -> None: + try: + await asyncio.sleep(delay) + except asyncio.CancelledError: + return + + if not self._idle_policy.should_fire_nudge( + token_is_current=self._scheduler.is_current("idle", token), + seq_matches=self.state.user_final_seq == seq_snapshot, + speaking=self.speaking.is_set(), + gap_active=self.state.gap.active, + finalized=self.finalized.is_set(), + current_stage=self.state.current_stage, + ): + return + + if self._idle_policy.should_arm_close_before_fire(nudge_count=self.state.idle_nudge_count): + self._call_logger.info( + "IDLE_NUDGE_MAX_REACHED | tries=%s | will_close_in_s=%.1f", + self.state.idle_nudge_count, + self._idle_policy.close_delay_s, + ) + self.arm_idle_close_timer(reason="max_reached_before_fire") + return + + self.state.idle_nudge_count += 1 + self._call_logger.info( + "IDLE_NUDGE_FIRE | attempt=%s/%s | delay_s=%.1f | text=%r", + self.state.idle_nudge_count, + self._idle_policy.max_tries, + delay, + self._nudge_text, + ) + if self._timeline is not None: + self._timeline.emit( + "idle_nudge_fire", + attempt=self.state.idle_nudge_count, + max_tries=self._idle_policy.max_tries, + text=self._nudge_text, + ) + + try: + if self._agent.pipeline is not None: + await self.execute(InjectIdleNudge(self._nudge_text)) + except Exception: + self._logger.debug("IDLE_NUDGE_PIPELINE_HOOK_FAIL", exc_info=True) + + inactivity_message_id = self._idle_nudge_message_id( + message_id=self._current_base_message_id(), + attempt=self.state.idle_nudge_count, + ) + await self.say_stage( + self._nudge_text, + "IDLE_NUDGE", + add_to_chat_ctx=False, + message_id=inactivity_message_id, + ) + + token = self._scheduler.arm( + "idle", + task_name="idle_timer", + coro=_timer(), + ) + + def should_ignore_backchannel(self, text: str) -> bool: + return False + + def _remember_interrupt_task(self, task: asyncio.Task[Any]) -> asyncio.Task[Any]: + self.state.current_speech.interruption_task = task + self._last_interrupt_task = task + return task + + def request_current_speech_interruption( + self, + *, + reason: str, + force: bool = False, + ) -> asyncio.Task[Any] | None: + if not force and not self._current_speech_allows_interruption(): + return None + + self._mark_structured_interruption(reason=reason) + existing = self.state.current_speech.interruption_task + if self.state.current_speech.interruption_requested and existing is not None: + return existing + + self.state.current_speech.interruption_requested = True + + handle = self.state.current_speech.handle + speech_id = self.state.current_speech.speech_id + stage = self.state.current_speech.stage + + self._call_logger.info( + "BARGE_IN_INTERRUPT_REQUEST | reason=%s | stage=%s | speech_id=%s | has_handle=%s", + reason, + stage or "-", + speech_id or "-", + handle is not None, + ) + if self._timeline is not None: + self._timeline.emit( + "barge_in_interrupt_requested", + reason=reason, + stage=stage or "", + speech_id=speech_id, + has_handle=handle is not None, + ) + + async def _interrupt() -> None: + if handle is None: + return + await self.execute(InterruptSpeech(handle, force=force)) + + return self._remember_interrupt_task( + self._create_task_logged( + _interrupt(), + name=f"interrupt_current_speech_{reason}", + ) + ) + + @staticmethod + def _track_publication_fields(publication: Any) -> dict[str, Any]: + source = getattr(publication, "source", "") + source_name = getattr(source, "name", None) or str(source) + track = getattr(publication, "track", None) + return { + "track_sid": str( + getattr(publication, "sid", "") + or getattr(track, "sid", "") + or "" + ), + "track_name": str(getattr(publication, "name", "") or getattr(track, "name", "") or ""), + "source": source_name, + "kind": str(getattr(publication, "kind", "") or getattr(track, "kind", "") or ""), + "muted": bool(getattr(publication, "muted", False)), + "subscribed": bool(getattr(publication, "subscribed", False)), + "has_track": track is not None, + } + + def log_room_input_snapshot(self, *, reason: str) -> None: + room = getattr(self._ctx, "room", None) + participants = getattr(room, "remote_participants", {}) or {} + bridge_identity = (self._bridge_identity or "").strip() + participant = participants.get(bridge_identity) if bridge_identity else None + + if participant is None and len(participants) == 1: + participant = next(iter(participants.values())) + + if participant is None: + log_flow_event( + self._call_logger, + "agent_input_snapshot", + reason=reason, + bridge_identity=bridge_identity, + participant_found=False, + remote_participants=len(participants), + ) + return + + publications = list((getattr(participant, "track_publications", {}) or {}).values()) + audio_publications = [ + publication + for publication in publications + if "audio" in str(getattr(publication, "kind", "") or "").lower() + or "microphone" in str(getattr(publication, "source", "") or "").lower() + ] + selected = audio_publications[0] if audio_publications else (publications[0] if publications else None) + fields = self._track_publication_fields(selected) if selected is not None else {} + log_flow_event( + self._call_logger, + "agent_input_snapshot", + reason=reason, + bridge_identity=bridge_identity, + participant_found=True, + participant=str(getattr(participant, "identity", "") or ""), + remote_participants=len(participants), + publication_count=len(publications), + audio_publication_count=len(audio_publications), + **fields, + ) + + def mark_interrupt_from_handle_if_any(self, *, reason: str) -> asyncio.Task[Any]: + handle = self.state.current_speech.handle + speech_id = self.state.current_speech.speech_id + if handle is None: + task = self._create_task_logged( + self.execute( + SetPendingInterrupt( + listened_text="", + skipped=True, + speech_id=speech_id, + ) + ), + name="pending_interrupt_nohandle", + ) + self._call_logger.info( + "INTERRUPT_MARK | reason=%s | skipped=True | speech_id=%s | spoken=''", + reason, + speech_id or "-", + ) + log_flow_event( + self._call_logger, + "interrupt_ignored", + reason="no_current_speech_handle", + source=reason, + stage=self.state.current_speech.stage or self.state.current_stage, + skipped=True, + speech_id=speech_id, + ) + if self._timeline is not None: + self._timeline.emit( + "interrupt_marked", + reason=reason, + skipped=True, + spoken="", + speech_id=speech_id, + ) + self.state.current_speech.interruption_requested = True + return self._remember_interrupt_task(task) + + spoken = self.execute_now(ExtractSpokenText(handle)) + skipped = not bool((spoken or "").strip()) + task = self._create_task_logged( + self.execute( + SetPendingInterrupt( + listened_text=spoken, + skipped=skipped, + speech_id=speech_id, + ) + ), + name="pending_interrupt_handle", + ) + self._call_logger.info( + "INTERRUPT_MARK | reason=%s | stage=%s | skipped=%s | speech_id=%s | spoken=%r", + reason, + self.state.current_speech.stage or "-", + skipped, + speech_id or "-", + spoken, + ) + log_flow_event( + self._call_logger, + "interrupt_marked", + reason=reason, + stage=self.state.current_speech.stage or self.state.current_stage, + skipped=skipped, + text=spoken, + speech_id=speech_id, + ) + if skipped: + log_flow_event( + self._call_logger, + "interrupt_ignored", + reason="empty_spoken_text", + source=reason, + stage=self.state.current_speech.stage or self.state.current_stage, + skipped=True, + speech_id=speech_id, + ) + if self._timeline is not None: + self._timeline.emit( + "interrupt_marked", + reason=reason, + stage=self.state.current_speech.stage or "", + skipped=skipped, + spoken=spoken, + speech_id=speech_id, + ) + self.state.current_speech.interruption_requested = True + return self._remember_interrupt_task(task) + + async def say_stage( + self, + text: str, + stage: str, + *, + add_to_chat_ctx: bool = True, + allow_interruptions: Optional[bool] = None, + rearm_interruptions_on_next_user_speech: bool = False, + speech_id: str = "", + message_id: str = "", + schedule_idle_after: bool = True, + wait_post_final_grace: bool = True, + require_user_not_speaking: bool = False, + abort_if_agent_spoke_since: int | None = None, + audio: Any = None, + audio_duration_ms: int | None = None, + drop_user_input_while_speaking: bool = False, + agent_message_type: str = "", + agent_result_type: str = "", + erro_msg: str | None = None, + playout_result_callback: Optional[Callable[..., None]] = None, + ) -> bool: + text = (text or "").strip() + if not text: + return False + message_id = str(message_id or "").strip() + stage_key = str(stage or "").strip().upper() + if stage_key not in AGENT_WAIT_TIMEOUT_RETRY_VAD_STAGES: + self._restore_agent_wait_timeout_retry_vad_threshold( + reason=f"agent_stage:{stage_key or 'UNKNOWN'}" + ) + if require_user_not_speaking and not self._user_not_speaking.is_set(): + log_flow_event( + self._call_logger, + "tts_skipped", + stage=stage, + reason="user_speaking", + message_id=message_id or self._current_base_message_id(), + text=text, + ) + return False + + self._clear_post_playout_drop_window(reason="new_tts") + allow_int = ( + self._interrupt_policy.allow_stage(stage) + if allow_interruptions is None + else bool(allow_interruptions) + ) + speech_id = str(speech_id or "").strip() + message_id = self._resolve_agent_message_id(message_id, generate=True) + + self.cancel_idle_timer("bot_speaking") + self.cancel_idle_close("bot_speaking") + + if self.state.gap.active and self.state.gap.cancelled: + self._call_logger.info("BOT_SAY_SKIPPED | stage=%s | reason=gap_cancelled", stage) + self.reset_gap_state() + return False + + if self.state.gap.active and self.state.user_final_seq != self.state.gap.guard_seq: + await self.execute( + SetPendingInterrupt( + listened_text="", + skipped=True, + speech_id=self.state.gap.speech_id, + ) + ) + self._call_logger.info("BOT_SAY_SKIPPED | stage=%s | reason=user_spoke_in_gap", stage) + self.reset_gap_state() + return False + + async with self.say_lock: + if require_user_not_speaking and not self._user_not_speaking.is_set(): + log_flow_event( + self._call_logger, + "tts_skipped", + stage=stage, + reason="user_speaking_after_lock", + message_id=message_id, + text=text, + ) + return False + if ( + abort_if_agent_spoke_since is not None + and self._say_stage_seq != abort_if_agent_spoke_since + ): + # Outra fala ocupou o say_lock enquanto este audio esperava na + # fila: o silencio que ele mascararia ja foi preenchido. + log_flow_event( + self._call_logger, + "tts_skipped", + stage=stage, + reason="agent_spoke_after_schedule", + message_id=message_id, + text=text, + ) + return False + self._say_stage_seq += 1 + self.state.current_speech.stage = stage + self.state.current_speech.text = text + self.state.current_speech.speech_id = speech_id + self.state.current_speech.drop_user_input_while_speaking = bool( + drop_user_input_while_speaking + ) + self.state.current_speech.agent_message_type = str(agent_message_type or "").strip() + self.state.current_speech.agent_result_type = str(agent_result_type or "").strip() + self.state.current_speech.allow_interruptions = allow_int + self.state.current_speech.rearm_interruptions_on_next_user_speech = bool( + rearm_interruptions_on_next_user_speech + ) + self.state.current_speech.interruption_requested = False + self.state.current_speech.interruption_task = None + self.speaking.set() + log_flow_event( + self._call_logger, + "tts_start", + stage=stage, + allow_interruptions=allow_int, + message_id=message_id, + text=text, + ) + if self._timeline is not None: + self._timeline.emit( + "tts_stage_started", + stage=stage, + allow_interruptions=allow_int, + text=text, + ) + self._emit_debug_event( + "agent.speech.started", + stage=stage, + message_id=message_id, + text=text, + ) + seq_at_start = self.state.user_final_seq + handle = None + tts_started_ns = None + tts_started_monotonic = 0.0 + agent_speaking_seq_at_start = 0 + tts_tffb_ms = None + tts_audio_duration_ms = None + tts_playout_completed = False + + def _tts_was_interrupted() -> bool: + return bool(getattr(handle, "interrupted", False)) or ( + self.state.user_final_seq != seq_at_start + ) + + try: + self.reset_gap_state() + if wait_post_final_grace: + await self._wait_post_user_final_grace(stage=stage) + + async def _start_speech_attempt(): + started_ns = time.time_ns() + started_monotonic = time.monotonic() + speaking_seq_at_start = self._agent_speaking_seq + started_handle = await self.execute( + StartSpeech( + text=text, + allow_interruptions=allow_int, + add_to_chat_ctx=add_to_chat_ctx, + audio=audio, + ) + ) + self.state.current_speech.handle = started_handle + + self._call_logger.info( + "BOT_SAY_HANDLE | stage=%s | speech_handle_id=%s | seq_at_start=%s | current_seq=%s", + stage, + self._speech_handle_id(started_handle) or "-", + seq_at_start, + self.state.user_final_seq, + ) + return ( + started_handle, + started_ns, + started_monotonic, + speaking_seq_at_start, + ) + + ( + handle, + tts_started_ns, + tts_started_monotonic, + agent_speaking_seq_at_start, + ) = await _start_speech_attempt() + + try: + should_abort_tts = False + abort_reason = "empty_frame_timeout" + retry_error: Exception | None = None + retryable_error_detail: str | None = None + partial_failure: _TTSPartialFailure | None = None + if audio is None: + playout_start_timeout_s = self._tts_playout_start_timeout_s() + try: + should_abort_tts = await self._wait_for_tts_playout_or_empty_frame_retry( + handle, + started_monotonic=tts_started_monotonic, + agent_speaking_seq_at_start=agent_speaking_seq_at_start, + timeout_s=playout_start_timeout_s, + stage=stage, + message_id=message_id, + ) + except Exception as exc: + if not self._is_retryable_tts_playout_error(exc): + raise + retry_error = exc + abort_reason = "playout_error" + should_abort_tts = True + if not should_abort_tts: + partial_failure = self._pop_tts_partial_failure(handle) + if partial_failure is not None: + abort_reason = "provider_partial_failure" + retryable_error_detail = partial_failure.detail + should_abort_tts = True + else: + retryable_error_detail = self._pop_retryable_tts_error_detail(handle) + observed_audio_duration_ms = self._resolve_tts_audio_duration_ms( + handle + ) + if ( + retryable_error_detail + and ( + observed_audio_duration_ms is None + or "xai tts partial audio failure" in retryable_error_detail + ) + and not bool(getattr(handle, "interrupted", False)) + and self.state.user_final_seq == seq_at_start + ): + abort_reason = "provider_error_no_audio_metric" + should_abort_tts = True + log_flow_event( + self._call_logger, + "tts_playout_missing_audio_after_error", + stage=stage, + message_id=message_id, + speech_handle_id=self._speech_handle_id(handle), + detail=retryable_error_detail, + ) + else: + await self.execute(WaitForSpeechPlayout(handle)) + + if should_abort_tts: + if not bool(getattr(handle, "interrupted", False)): + try: + await self.execute(InterruptSpeech(handle, force=True)) + except Exception as exc: + log_flow_event( + self._call_logger, + "tts_failure_interrupt_error", + stage=stage, + message_id=message_id, + speech_handle_id=self._speech_handle_id(handle), + error=type(exc).__name__, + ) + tts_total_ms = self._resolve_tts_provider_duration_ms(handle) + tts_audio_duration_ms = self._resolve_tts_audio_duration_ms(handle) + if partial_failure is not None: + tts_total_ms = ( + partial_failure.provider_synthesis_ms or tts_total_ms + ) + tts_audio_duration_ms = ( + partial_failure.pcm_duration_ms + or tts_audio_duration_ms + ) + if retry_error is not None: + retry_error_detail = f"{type(retry_error).__name__}: {retry_error}" + http_cod_status, http_cod_desc = _http_status_details_from_exception( + retry_error + ) + elif retryable_error_detail: + retry_error_detail = retryable_error_detail + http_cod_status, http_cod_desc = None, None + else: + retry_error_detail = ( + "TimeoutError: nenhum frame de audio do TTS foi observado " + f"em {round(playout_start_timeout_s * 1000)} ms" + ) + http_cod_status, http_cod_desc = None, None + tts_error_message_id = self._tts_error_message_id( + message_id=message_id + ) + tts_max_gap_ms = self._tts_failure_metric(retry_error_detail, "max_audio_delta_gap_ms") + tts_max_underrun_0ms = self._tts_failure_metric(retry_error_detail, "xai_underrun_estimado_ms") + tts_underflow_count = self._tts_failure_metric(retry_error_detail, "xai_micro_underflows") + tts_avg_underflow_ms = self._tts_failure_metric(retry_error_detail, "xai_avg_underrun_ms") + log_structured_event( + self._call_logger, + self._structured_log_context, + tipo_evento=EVENT_ENVIO_MSG, + message_id=tts_error_message_id, + inicio_ns=tts_started_ns, + fim_ns=time.time_ns(), + latencia_total_ms=tts_total_ms, + duracao_audio_ms=tts_audio_duration_ms, + tts_max_gap_ms=tts_max_gap_ms, + tts_max_underrun_0ms=tts_max_underrun_0ms, + tts_underflow_count=tts_underflow_count, + tts_avg_underflow_ms=tts_avg_underflow_ms, + interrupcao=True if _tts_was_interrupted() else None, + erro_msg="Falha TTS", + erro_detalhe=retry_error_detail, + http_cod_status=http_cod_status, + http_cod_desc=http_cod_desc, + ) + log_flow_event( + self._call_logger, + "tts_failure", + stage=stage, + message_id=tts_error_message_id, + reason=abort_reason, + erro_msg="Falha TTS", + erro_detalhe=retry_error_detail, + detail=retry_error_detail, + duration_ms=tts_total_ms, + audio_ms=tts_audio_duration_ms, + timeout_ms=round(playout_start_timeout_s * 1000), + timeout_kind="playout_start", + first_frame_timeout_ms=round(self._tts_first_frame_timeout_s() * 1000), + connection_grace_ms=round(self._tts_connection_start_grace_s() * 1000), + error=type(retry_error).__name__ if retry_error is not None else "", + ) + self._call_logger.warning( + "BOT_TTS_FAILURE | stage=%s | message_id=%s | reason=%s", + stage, + message_id or "-", + TTS_FAILURE_DETAIL, + ) + log_flow_event( + self._call_logger, + "tts_failure_return_to_listening", + stage=stage, + message_id=message_id, + reason=abort_reason, + socket_action="reconnect_on_next_synthesis", + action="wait_for_client_repetition", + ) + # The adapter has discarded the websocket after a partial failure. + # Do not replay a consumed LiveKit stream or add a recovery prompt. + return False + + tts_finished_ns = time.time_ns() + await self._resolve_tts_tffb_ms(handle) + ( + tts_total_ms, + tts_tffb_ms, + tts_audio_duration_ms, + tts_metric_duration_ms, + tts_metrics_source, + ) = self._resolve_tts_structured_metrics(handle) + tts_interrupted = _tts_was_interrupted() + ( + tts_max_gap_ms, + tts_max_underrun_0ms, + tts_underflow_count, + tts_avg_underflow_ms, + ) = self._resolve_tts_turn_health_metrics(handle) + log_structured_event( + self._call_logger, + self._structured_log_context, + tipo_evento=EVENT_ENVIO_MSG, + message_id=message_id, + inicio_ns=tts_started_ns, + fim_ns=tts_finished_ns, + latencia_total_ms=tts_total_ms, + latencia_tffb_ms=tts_tffb_ms, + duracao_audio_ms=tts_audio_duration_ms, + tts_max_gap_ms=tts_max_gap_ms, + tts_max_underrun_0ms=tts_max_underrun_0ms, + tts_underflow_count=tts_underflow_count, + tts_avg_underflow_ms=tts_avg_underflow_ms, + interrupcao=True if tts_interrupted else None, + erro_msg=erro_msg, + http_cod_status=200, + http_cod_desc="OK", + ) + log_flow_event( + self._call_logger, + "tts_done", + stage=stage, + message_id=message_id, + vocalized=True, + interrupted=bool(getattr(handle, "interrupted", False)), + interruption=tts_interrupted, + duration_ms=tts_total_ms, + provider_duration_ms=tts_metric_duration_ms, + metrics_source=tts_metrics_source, + tffb_ms=tts_tffb_ms, + audio_ms=tts_audio_duration_ms, + max_gap_ms=tts_max_gap_ms, + max_underrun_0ms=tts_max_underrun_0ms, + underflow_count=tts_underflow_count, + avg_underflow_ms=tts_avg_underflow_ms, + regenerated=False, + ) + self.log_room_input_snapshot(reason=f"tts_done:{stage}") + if self._timeline is not None: + self._timeline.emit( + "tts_stage_playout_done", + stage=stage, + interrupted=bool(getattr(handle, "interrupted", False)), + ) + await self._publish_debug_event( + "agent.speech.finished", + stage=stage, + message_id=message_id, + text=text, + interrupted=bool(getattr(handle, "interrupted", False)), + duration_ms=tts_total_ms, + ttfb_ms=tts_tffb_ms, + audio_duration_ms=tts_audio_duration_ms, + ) + tts_playout_completed = True + except Exception as exc: + tts_finished_ns = time.time_ns() + await self._resolve_tts_tffb_ms(handle) + ( + tts_total_ms, + tts_tffb_ms, + tts_audio_duration_ms, + tts_metric_duration_ms, + tts_metrics_source, + ) = self._resolve_tts_structured_metrics(handle) + http_cod_status, http_cod_desc = _http_status_details_from_exception(exc) + tts_interrupted = _tts_was_interrupted() + tts_error_message_id = self._tts_error_message_id( + message_id=message_id + ) + log_structured_event( + self._call_logger, + self._structured_log_context, + tipo_evento=EVENT_ENVIO_MSG, + message_id=tts_error_message_id, + inicio_ns=tts_started_ns, + fim_ns=tts_finished_ns, + latencia_total_ms=tts_total_ms, + latencia_tffb_ms=tts_tffb_ms, + duracao_audio_ms=tts_audio_duration_ms, + interrupcao=True if tts_interrupted else None, + erro_msg="Falha TTS", + erro_detalhe=f"{type(exc).__name__}: {exc}", + http_cod_status=http_cod_status, + http_cod_desc=http_cod_desc, + ) + log_flow_event( + self._call_logger, + "tts_error", + stage=stage, + message_id=tts_error_message_id, + source_message_id=message_id, + duration_ms=tts_total_ms, + provider_duration_ms=tts_metric_duration_ms, + metrics_source=tts_metrics_source, + interruption=tts_interrupted, + tffb_ms=tts_tffb_ms, + audio_ms=tts_audio_duration_ms, + error=type(exc).__name__, + ) + self._call_logger.exception( + "BOT_PLAYOUT_ERROR | stage=%s | duration_ms=%s | tffb_ms=%s", + stage, + tts_total_ms, + tts_tffb_ms if tts_tffb_ms is not None else "-", + ) + + user_spoke_during = self.state.user_final_seq != seq_at_start + handle_interrupted = bool(getattr(handle, "interrupted", False)) + if playout_result_callback is not None: + playout_result_callback( + interrupted=handle_interrupted, + user_spoke_during=user_spoke_during, + ) + + self._call_logger.info( + "BOT_SAY_RESULT | stage=%s | interrupted=%s | user_spoke_during=%s", + stage, + handle_interrupted, + user_spoke_during, + ) + if self._timeline is not None: + self._timeline.emit( + "tts_stage_result", + stage=stage, + interrupted=handle_interrupted, + user_spoke_during=user_spoke_during, + ) + + if ( + tts_playout_completed + and not handle_interrupted + and not user_spoke_during + and str(stage or "").strip().upper() == "PRESENTATION" + and not allow_int + ): + post_playout_drop_context = self._protected_speech_drop_context_from_values( + reason="protected_speech_post_playout_grace", + stage=stage, + allow_interruptions=allow_int, + drop_user_input_while_speaking=True, + agent_message_type=agent_message_type, + agent_result_type=agent_result_type, + ) + if post_playout_drop_context is not None: + self._arm_post_playout_drop_window(post_playout_drop_context) + + if user_spoke_during or handle_interrupted: + if self._config.final_grace_s > 0: + await asyncio.sleep(self._config.final_grace_s) + spoken = self.execute_now(ExtractSpokenText(handle)) + skipped = not bool((spoken or "").strip()) + await self.execute( + SetPendingInterrupt( + listened_text=spoken, + skipped=skipped, + speech_id=speech_id, + ) + ) + + self._call_logger.info( + "BOT_INTERRUPTED_AFTER_START | stage=%s | skipped=%s | speech_id=%s | spoken=%r", + stage, + skipped, + speech_id or "-", + spoken, + ) + log_flow_event( + self._call_logger, + "interrupt_marked", + reason="tts_interrupted_after_start", + stage=stage, + skipped=skipped, + text=spoken, + ) + if skipped: + log_flow_event( + self._call_logger, + "interrupt_ignored", + reason="empty_spoken_text_after_tts", + stage=stage, + skipped=True, + ) + if self._timeline is not None: + self._timeline.emit( + "tts_stage_interrupted", + stage=stage, + skipped=skipped, + spoken=spoken, + speech_id=speech_id, + ) + except Exception as exc: + if tts_started_ns is None: + tts_started_ns = time.time_ns() + tts_failed_ns = time.time_ns() + await self._resolve_tts_tffb_ms(handle) + ( + tts_total_ms, + tts_tffb_ms, + tts_audio_duration_ms, + tts_metric_duration_ms, + tts_metrics_source, + ) = self._resolve_tts_structured_metrics(handle) + http_cod_status, http_cod_desc = _http_status_details_from_exception(exc) + tts_interrupted = _tts_was_interrupted() + tts_error_message_id = self._tts_error_message_id( + message_id=message_id + ) + log_structured_event( + self._call_logger, + self._structured_log_context, + tipo_evento=EVENT_ENVIO_MSG, + message_id=tts_error_message_id, + inicio_ns=tts_started_ns, + fim_ns=tts_failed_ns, + latencia_total_ms=tts_total_ms, + latencia_tffb_ms=tts_tffb_ms, + duracao_audio_ms=tts_audio_duration_ms, + interrupcao=True if tts_interrupted else None, + erro_msg="Falha TTS", + erro_detalhe=f"{type(exc).__name__}: {exc}", + http_cod_status=http_cod_status, + http_cod_desc=http_cod_desc, + ) + log_flow_event( + self._call_logger, + "tts_error", + stage=stage, + message_id=tts_error_message_id, + source_message_id=message_id, + duration_ms=tts_total_ms, + provider_duration_ms=tts_metric_duration_ms, + metrics_source=tts_metrics_source, + interruption=tts_interrupted, + tffb_ms=tts_tffb_ms, + audio_ms=tts_audio_duration_ms, + error=type(exc).__name__, + ) + self._call_logger.exception( + "BOT_SAY_ERROR | stage=%s | duration_ms=%s | tffb_ms=%s", + stage, + tts_total_ms, + tts_tffb_ms if tts_tffb_ms is not None else "-", + ) + raise + finally: + self.state.current_speech.handle = None + self.state.current_speech.stage = "" + self.state.current_speech.text = "" + self.state.current_speech.speech_id = "" + self.state.current_speech.allow_interruptions = False + self.state.current_speech.rearm_interruptions_on_next_user_speech = False + self.state.current_speech.drop_user_input_while_speaking = False + self.state.current_speech.agent_message_type = "" + self.state.current_speech.agent_result_type = "" + self.state.current_speech.interruption_requested = False + self.state.current_speech.interruption_task = None + self.speaking.clear() + self.reset_gap_state() + if self._empty_stt_recovery_pending: + self._recover_after_empty_stt_final() + + if ( + schedule_idle_after + and self.state.user_final_seq == seq_at_start + and (stage or "").upper() != "DONE" + ): + if ( + self._idle_policy.should_arm_close_after_nudge( + stage=stage, + nudge_count=self.state.idle_nudge_count, + ) + ): + self._call_logger.info( + "IDLE_NUDGE_MAX_REACHED_AFTER_SAY | tries=%s | will_close_in_s=%.1f", + self.state.idle_nudge_count, + self._idle_policy.close_delay_s, + ) + self.arm_idle_close_timer(reason="max_reached_after_nudge") + return True + + self.arm_idle_timer(reason=f"bot_end:{(stage or '').upper()}") + + if not allow_int and not drop_user_input_while_speaking: + async with self.pending_lock: + pending_turn = self.state.pending_user_final + self.state.pending_user_final = None + if pending_turn: + self._create_task_logged( + self.run_pipeline( + pending_turn.transcription, + pending_turn.text, + user_seq=pending_turn.seq, + message_id=pending_turn.message_id, + ), + name=f"run_pipeline_pending_{pending_turn.seq}", + ) + + return True + + async def finalize_when_room_empty(self) -> None: + while not self.finalized.is_set(): + await asyncio.sleep(0.5) + try: + if self._finalization_policy.should_finalize_room_empty( + remote_participants=len(self._ctx.room.remote_participants), + finalized=self.finalized.is_set(), + ): + await asyncio.sleep(self._finalization_policy.call_end_grace_s) + if self._finalization_policy.should_finalize_room_empty( + remote_participants=len(self._ctx.room.remote_participants), + finalized=self.finalized.is_set(), + ): + await self.finalize("room_empty") + return + except Exception: + continue + + async def run_pipeline( + self, + transcription: str, + text: str, + *, + user_seq: int, + message_id: str = "", + inflight_initial_notice_consumed: bool = False, + is_deferred_replay: bool = False, + ) -> None: + text = (text or "").strip() + transcription = transcription or "" + message_id = str(message_id or "").strip() + if not message_id: + message_id = self._consume_turn_message_id(transcription, text) + elif text: + self._clear_started_turn_message_id(message_id) + + if self.should_ignore_backchannel(text): + log_flow_event( + self._call_logger, + "interrupt_ignored", + reason="backchannel", + message_id=message_id, + user_seq=user_seq, + stage=self.state.current_stage, + speaking=self.speaking.is_set(), + text=text, + ) + if is_deferred_replay: + self._reset_deferred_interruption() + return + + await self._agent._ready.wait() + if self._agent.pipeline is None: + self._logger.exception("[pipeline] pipeline=None (ready)") + if is_deferred_replay: + self._reset_deferred_interruption() + return + + async with self._agent._run_lock: + if self.finalized.is_set() or self._finalization_policy.is_done_stage( + self.state.current_stage + ): + log_flow_event( + self._call_logger, + "pipeline_run_skipped_terminal", + user_seq=user_seq, + stage=self.state.current_stage, + ) + if is_deferred_replay: + self._reset_deferred_interruption() + return + + if user_seq != self.state.user_final_seq: + self._call_logger.info( + "RUN_PIPELINE_SKIP_STALE | user_seq=%s | current_seq=%s | text=%r", + user_seq, + self.state.user_final_seq, + text, + ) + if self._timeline is not None: + self._timeline.emit( + "pipeline_run_skipped_stale", + user_seq=user_seq, + current_seq=self.state.user_final_seq, + ) + if is_deferred_replay: + self._reset_deferred_interruption() + return + + pending_interrupt = await self._agent.consume_pending_interrupt() + if len(pending_interrupt) == 3: + listened_text, speech_id, skipped = pending_interrupt + else: + listened_text, skipped = pending_interrupt + speech_id = "" + try: + if listened_text is not None: + if is_deferred_replay: + await self.execute( + SetPipelineProcessingInterruption( + listened_text=listened_text, + skipped=skipped, + speech_id=speech_id, + ) + ) + else: + await self.execute( + SetPipelineInterruption( + interrupted=True, + listened_text=listened_text, + skipped=skipped, + speech_id=speech_id, + ) + ) + else: + await self.execute(SetPipelineInterruption(interrupted=False)) + except Exception: + self._logger.exception("PIPELINE_INTERRUPT | erro chamando set_interruption") + + try: + stt_payload = json.loads(transcription) + except Exception: + stt_payload = text + stt_payload = self._payload_with_message_id(stt_payload, message_id, text=text) + + log_flow_event( + self._call_logger, + "agent_start", + message_id=message_id, + user_seq=user_seq, + stage=self.state.current_stage, + text=text, + payload=stt_payload, + ) + if self._timeline is not None: + self._timeline.emit( + "pipeline_run_started", + user_seq=user_seq, + input_text=text, + current_stage=self.state.current_stage, + ) + if text and self._supports_inflight_backend_push(): + self._schedule_backend_push_listener( + user_seq=user_seq, + reason="run_pipeline_inflight", + inflight=True, + ) + backend_started_ns = time.time_ns() + deferred = self.state.deferred_interruption + # A janela do ciclo cobre o backend e a espera de liquidacao. Depois + # dela a resposta ja esta liberada e a fala do cliente volta a ser + # barge-in comum. + deferred.backend_in_flight = not is_deferred_replay + if deferred.backend_in_flight: + self._promote_pre_backend_vad_utterances() + try: + raw_reply = await self._execute_pipeline_with_inflight_backend_wait( + stt_payload, + user_seq=user_seq, + message_id=message_id, + initial_notice_consumed=inflight_initial_notice_consumed, + ) + except InflightBackendWaitTimedOut: + self._close_deferred_vad_window() + self._release_deferred_cycle(is_deferred_replay=is_deferred_replay) + return + except Exception: + self._close_deferred_vad_window() + backend_duration_ms = event_latency_ms(backend_started_ns) + log_backend_error = getattr(self._call_logger, "error", self._call_logger.exception) + log_backend_error( + "AGENT_BACKEND_ERROR | user_seq=%s | stage=%s | duration_ms=%s", + user_seq, + self.state.current_stage, + backend_duration_ms, + ) + log_flow_event( + self._call_logger, + "agent_error", + user_seq=user_seq, + stage=self.state.current_stage, + duration_ms=backend_duration_ms, + ) + self._logger.exception("[pipeline] backend run failed") + if self._timeline is not None: + self._timeline.emit( + "pipeline_run_failed", + user_seq=user_seq, + resource="agent_backend", + ) + self._release_deferred_cycle(is_deferred_replay=is_deferred_replay) + await self.terminate_with_resource_stop( + resource="agent_backend", + source="run_pipeline", + ) + return + + try: + backend_duration_ms = event_latency_ms(backend_started_ns) + reply = self._coerce_backend_reply( + raw_reply, + fallback_stage=self.state.current_stage, + ) + reply = self._backend_reply_with_message_id(reply, message_id) + if not is_deferred_replay: + await self._wait_deferred_interruption_settlement(user_seq=user_seq) + finally: + self._close_deferred_vad_window() + + terminal_reply = self._terminal_stop_from_reply(reply) is not None or ( + reply.done or self._finalization_policy.is_done_stage(reply.stage) + ) + if user_seq != self.state.user_final_seq and not terminal_reply: + self._call_logger.info( + "RUN_PIPELINE_DROP_AFTER_RUN | user_seq=%s | current_seq=%s | stage=%s", + user_seq, + self.state.user_final_seq, + reply.stage, + ) + if self._timeline is not None: + self._timeline.emit( + "pipeline_run_dropped_after_run", + user_seq=user_seq, + current_seq=self.state.user_final_seq, + stage=reply.stage, + ) + if not is_deferred_replay and await self._dispatch_deferred_interruption(reply): + log_flow_event( + self._call_logger, + "pipeline_to_tts_skipped", + stage=reply.stage, + reason="deferred_interruption", + speech_id=self._speech_id_from_reply(reply), + ) + self.reset_gap_state() + self._release_deferred_cycle(is_deferred_replay=is_deferred_replay) + return + + self.state.current_stage = reply.stage + self._update_backend_processing_drop_from_reply(reply, source="run_pipeline") + out = reply.text + log_flow_event( + self._call_logger, + "agent_done", + message_id=message_id, + user_seq=user_seq, + stage=self.state.current_stage, + done=reply.done, + duration_ms=backend_duration_ms, + text=out, + result=reply.export_payload, + ) + if self._timeline is not None: + self._timeline.emit( + "pipeline_run_completed", + user_seq=user_seq, + stage=self.state.current_stage, + output_len=len(out), + ) + + self._emit_debug_event( + "agent.completed", + user_seq=user_seq, + message_id=message_id, + stage=self.state.current_stage, + duration_ms=backend_duration_ms, + done=reply.done, + ) + + if not out: + self._log_backend_reply_error_event( + reply, + source="run_pipeline", + message_id=message_id, + ) + + self._release_deferred_cycle(is_deferred_replay=is_deferred_replay) + if out: + self.state.gap.active = True + self.state.gap.cancelled = False + self.state.gap.guard_seq = user_seq + self.state.gap.stage = self.state.current_stage + self.state.gap.text = out + self.state.gap.speech_id = self._speech_id_from_reply(reply) + + if ( + self.state.user_final_seq != self.state.gap.guard_seq + and not terminal_reply + ): + await self.execute( + SetPendingInterrupt( + listened_text="", + skipped=True, + speech_id=self.state.gap.speech_id, + ) + ) + self.state.gap.cancelled = True + self._call_logger.info( + "SKIPPED_BETWEEN_RUN_AND_TTS | stage=%s | reason=user_spoke_during_run", + self.state.current_stage, + ) + log_flow_event( + self._call_logger, + "interrupt_ignored", + reason="user_spoke_during_agent_run", + user_seq=user_seq, + stage=self.state.current_stage, + skipped=True, + ) + if self._timeline is not None: + self._timeline.emit( + "pipeline_to_tts_skipped", + stage=self.state.current_stage, + reason="user_spoke_during_run", + ) + self.reset_gap_state() + self._release_deferred_cycle(is_deferred_replay=is_deferred_replay) + return + + if not await self._wait_for_stable_user_silence_before_terminal_reply( + reply, + user_seq=user_seq, + source="run_pipeline", + ): + self.reset_gap_state() + self._release_deferred_cycle(is_deferred_replay=is_deferred_replay) + return + + if terminal_reply: + # Um STT que chega na janela entre backend e TTS marca o gap + # como cancelado. Para respostas terminais esse marcador serve + # apenas para esperar o usuario terminar; nao pode suprimir a + # confirmacao final. + self.state.gap.cancelled = False + self.state.gap.guard_seq = self.state.user_final_seq + + try: + await self._speak_backend_reply( + reply, + source="run_pipeline", + add_to_chat_ctx=True, + initial_turn=user_seq == 0, + ) + except Exception: + self._logger.exception("[tts] failed speaking backend reply") + if self._timeline is not None: + self._timeline.emit( + "tts_stage_failed", + stage=self.state.current_stage, + source="run_pipeline", + ) + self._release_deferred_cycle(is_deferred_replay=is_deferred_replay) + await self.terminate_with_resource_stop( + resource="tts", + source="run_pipeline_tts", + ) + return + self.reset_gap_state() + self._release_deferred_cycle(is_deferred_replay=is_deferred_replay) + self.arm_agent_wait_timeout_from_reply( + reply, + user_seq=user_seq, + source="run_pipeline", + ) + + terminal_stop = self._terminal_stop_from_reply(reply) + if terminal_stop is not None: + await self._finish_with_agent_final_stop( + terminal_stop, + source="run_pipeline", + ) + return + + if reply.done or self._finalization_policy.is_done_stage(reply.stage): + self._create_task_logged( + self.notify_bridge_done("stage_done"), + name="bridge_done", + ) + self._create_task_logged( + self.finalize("stage_done"), + name="finalize", + ) + elif user_seq == self.state.user_final_seq: + self._schedule_backend_push_listener( + user_seq=user_seq, + reason="run_pipeline_completed", + ) + + async def stash_pending_user_turn( + self, + *, + seq: int, + transcription: str, + text: str, + message_id: str = "", + ) -> None: + async with self.pending_lock: + self.state.pending_user_final = PendingUserTurn( + seq=seq, + transcription=transcription, + text=text, + message_id=message_id, + ) + + def _consume_deferred_empty_final_latch(self) -> bool: + latched_at = self._deferred_empty_final_latched_at + if not latched_at: + return False + self._deferred_empty_final_latched_at = 0.0 + # A marca vale so para o evento imediatamente seguinte: se o LiveKit + # filtrou a copia, ela nao pode sobrar e engolir o final vazio de outra + # fala mais adiante. + return (time.monotonic() - latched_at) <= EMPTY_STT_FINAL_DEDUP_WINDOW_S + + def _handle_deferred_stt_final(self, *, transcription: str, text: str) -> bool: + if not self.state.deferred_interruption.backend_in_flight: + return False + utterance = self._consume_vad_for_stt_final() + if utterance is None: + return False + + message_id = self._consume_turn_message_id(transcription, text) if text else "" + if not message_id: + self._clear_started_turn_message_id() + + if not utterance.is_long: + log_flow_event( + self._call_logger, + "interrupt_ignored", + reason="deferred_interruption_too_short", + message_id=message_id, + stage=self.state.current_stage, + audio_ms=utterance.duration_ms, + minimum_audio_ms=self._config.deferred_interruption_min_audio_ms, + text=text, + ) + return True + + if not text: + log_flow_event( + self._call_logger, + "deferred_interruption_stt_empty", + stage=self.state.current_stage, + audio_ms=utterance.duration_ms, + ) + return True + + self.cancel_idle_timer("deferred_interruption") + self.cancel_idle_close("deferred_interruption") + self.cancel_agent_wait_timeout("deferred_interruption") + self._cancel_backend_push_listener(reason="deferred_interruption") + self.state.user_final_seq += 1 + event_seq = self.state.user_final_seq + self.state.last_user_final_at = time.monotonic() + deferred = self.state.deferred_interruption + deferred.long_turns.append( + PendingUserTurn( + seq=event_seq, + transcription=transcription, + text=text, + message_id=message_id, + ) + ) + log_flow_event( + self._call_logger, + "deferred_interruption_accumulated", + message_id=message_id, + user_seq=event_seq, + stage=self.state.current_stage, + audio_ms=utterance.duration_ms, + accumulated_turns=len(deferred.long_turns), + text=text, + ) + return True + + def on_user_input_transcribed(self, ev: UserInputTranscribedEvent) -> None: + transcription = ev.transcript or "" + text = (self._extract_text_from_transcript(transcription) or "").strip() + + if not ev.is_final: + return + + deferred = self.state.deferred_interruption + if deferred.replay_in_flight: + self._consume_vad_for_stt_final() + dropped_message_id = self._consume_turn_message_id(transcription, text) if text else "" + if not dropped_message_id: + self._clear_started_turn_message_id() + log_flow_event( + self._call_logger, + "deferred_replay_stt_discarded", + message_id=dropped_message_id, + text=text, + ) + self._clear_structured_interruption() + return + + if deferred.backend_in_flight and not self._config.deferred_interruption_enabled: + dropped_message_id = ( + self._consume_turn_message_id(transcription, text) if text else "" + ) + if not dropped_message_id: + self._clear_started_turn_message_id() + self._log_user_input_dropped( + context={ + "reason": "backend_processing_feedback_compat", + "stage": self.state.current_stage, + "agent_message_type": "feedback", + "agent_result_type": "feedback", + }, + transcription=transcription, + text=text, + ) + self._clear_structured_interruption() + return + + # Finais normais consomem o VAD pré-R1 correspondente. Se R1 já + # estiver em voo, _handle_deferred_stt_final faz esse consumo e + # acumula o turno longo para o replay R2. + if not deferred.backend_in_flight: + self._consume_vad_for_stt_final() + + if not text and self._consume_deferred_empty_final_latch(): + # O provider ja contabilizou este final vazio; este evento e a copia + # que o LiveKit repassa. + self._clear_structured_interruption() + return + + self._restore_agent_wait_timeout_retry_vad_threshold(reason="user_final") + drop_context = self._drop_context_for_user_final() + if drop_context is not None: + # A fala protegida tem prioridade sobre o ciclo diferido, mas a fala + # VAD correspondente precisa sair da fila junto: senao o proximo + # final consumiria a fala errada. + if deferred.backend_in_flight: + self._consume_vad_for_stt_final() + dropped_message_id = ( + self._consume_turn_message_id(transcription, text) + if text + else "" + ) + if not dropped_message_id: + self._clear_started_turn_message_id() + self._log_user_input_dropped( + context=drop_context, + transcription=transcription, + text=text, + ) + self._clear_structured_interruption() + return + + if self._handle_deferred_stt_final(transcription=transcription, text=text): + self._clear_structured_interruption() + return + + if not text: + self._clear_started_turn_message_id() + self.handle_empty_stt_final( + transcript_len=len(transcription), + source="user_input_transcribed", + ) + self._clear_structured_interruption() + return + + if deferred.backend_in_flight: + # Dentro da janela do ciclo todo final precisa de uma fala VAD + # correspondente; sem ela a associacao por ordem ja esta + # dessincronizada e reenviar este texto misturaria os turnos. Fora da + # janela o final segue o fluxo normal de interrupcao. + message_id = self._consume_turn_message_id(transcription, text) + log_flow_event( + self._call_logger, + "interrupt_ignored", + reason="deferred_interruption_missing_vad", + message_id=message_id, + stage=self.state.current_stage, + text=text, + ) + self._clear_structured_interruption() + return + + self.cancel_idle_timer("user_final") + message_id = self._consume_turn_message_id(transcription, text) + self.cancel_idle_close("user_final") + self.cancel_agent_wait_timeout("user_final") + self._clear_agent_wait_timeout_memory() + self._cancel_backend_push_listener(reason="user_final") + self.state.idle_nudge_count = 0 + + self.state.user_final_seq += 1 + event_seq = self.state.user_final_seq + self.state.last_user_final_at = time.monotonic() + was_speaking = self.speaking.is_set() + keep_pre_backend_notice = ( + was_speaking and self._pre_backend_wait_notice_is_current_speech() + ) + interruptible = ( + self._current_speech_allows_interruption() + if was_speaking + else self._interrupt_policy.allow_stage(self.state.current_stage) + ) + if keep_pre_backend_notice: + interruptible = False + log_flow_event( + self._call_logger, + "stt_final", + message_id=message_id, + user_seq=event_seq, + stage=self.state.current_stage, + speaking=was_speaking, + interruptible=interruptible, + text=text, + ) + if self._timeline is not None: + self._timeline.emit( + "user_transcript_final", + user_seq=event_seq, + message_id=message_id, + text=text, + speaking=self.speaking.is_set(), + current_stage=self.state.current_stage, + ) + stt_duration_ms = ( + round((time.monotonic() - self._last_vad_speech_end_at) * 1000) + if self._last_vad_speech_end_at > 0 + else None + ) + self._emit_debug_event( + "stt.completed", + user_seq=event_seq, + message_id=message_id, + duration_ms=stt_duration_ms, + audio_duration_ms=self._last_vad_speech_duration_ms, + text=text, + ) + + interrupt_tasks: list[asyncio.Task[Any]] = [] + if keep_pre_backend_notice: + log_flow_event( + self._call_logger, + "interrupt_ignored", + reason="pre_backend_wait_notice_same_turn", + action="keep_speaking", + message_id=message_id, + user_seq=event_seq, + stage=self.state.current_speech.stage, + text=text, + ) + + if self.state.gap.active and not self.state.gap.cancelled: + self.state.gap.cancelled = True + interrupt_tasks.append(self._create_task_logged( + self.execute( + SetPendingInterrupt( + listened_text="", + skipped=True, + speech_id=self.state.gap.speech_id, + ) + ), + name="pending_interrupt_gap", + )) + self._call_logger.info( + "GAP_INTERRUPTION | stage=%s | skipped=True | speech_id=%s | reason=user_spoke_before_tts", + self.state.gap.stage or self.state.current_stage, + self.state.gap.speech_id or "-", + ) + log_flow_event( + self._call_logger, + "interrupt_marked", + reason="user_spoke_before_tts", + message_id=message_id, + user_seq=event_seq, + stage=self.state.gap.stage or self.state.current_stage, + skipped=True, + text=text, + ) + + if was_speaking and interruptible: + log_flow_event( + self._call_logger, + "interrupt_detected", + reason="user_final_while_speaking", + message_id=message_id, + user_seq=event_seq, + stage=self.state.current_stage, + text=text, + ) + stop_task = self.request_current_speech_interruption(reason="user_final_while_speaking") + if stop_task is not None: + interrupt_tasks.append(stop_task) + interrupt_tasks.append( + self.mark_interrupt_from_handle_if_any(reason="user_final_while_speaking") + ) + elif self._last_interrupt_task is not None and not self._last_interrupt_task.done(): + if not keep_pre_backend_notice: + interrupt_tasks.append(self._last_interrupt_task) + + if was_speaking and not interruptible and not keep_pre_backend_notice: + log_flow_event( + self._call_logger, + "interrupt_ignored", + reason="stage_not_interruptible", + action="stash_pending", + message_id=message_id, + user_seq=event_seq, + stage=self.state.current_stage, + text=text, + ) + self._create_task_logged( + self.stash_pending_user_turn( + seq=event_seq, + transcription=transcription, + text=text, + message_id=message_id, + ), + name=f"stash_pending_final_{event_seq}", + ) + self._clear_structured_interruption() + return + + async def _run_after_interrupt_mark() -> None: + if interrupt_tasks: + try: + seen: set[asyncio.Task[Any]] = set() + unique_tasks: list[asyncio.Task[Any]] = [] + for task in interrupt_tasks: + if task not in seen: + seen.add(task) + unique_tasks.append(task) + await asyncio.gather(*unique_tasks) + except Exception: + self._logger.exception("PIPELINE_INTERRUPT | erro marcando interrupcao") + await self.run_pipeline(transcription, text, user_seq=event_seq, message_id=message_id) + + self._create_task_logged( + _run_after_interrupt_mark(), + name=f"run_pipeline_{event_seq}", + ) + self._clear_structured_interruption() + + async def on_shutdown(self) -> None: + await self.finalize("shutdown_callback") + + def register_callbacks(self) -> None: + @self._session.on("user_input_transcribed") + def _on_user_input_transcribed(ev: UserInputTranscribedEvent) -> None: + self.on_user_input_transcribed(ev) + + @self._session.on("error") + def _on_session_error(ev: Any) -> None: + error = getattr(ev, "error", None) + self._record_retryable_tts_session_error(error) + if self._timeline is not None: + self._timeline.emit( + "session_error", + error_type=str(getattr(error, "type", "") or ""), + recoverable=bool(getattr(error, "recoverable", False)), + ) + + @self._session.on("close") + def _on_session_close(ev: Any) -> None: + error = getattr(ev, "error", None) + raw_reason = getattr(ev, "reason", "") + reason = str(getattr(raw_reason, "value", raw_reason) or "") + if self._timeline is not None: + self._timeline.emit( + "session_closed", + reason=reason, + error_type=str(getattr(error, "type", "") or ""), + ) + if reason != "error" or error is None: + return + + resource = self._resource_from_session_error(error) + self._create_task_logged( + self.terminate_with_resource_stop( + resource=resource, + source="session_close", + ), + name=f"terminal_stop_session_close_{resource}", + ) + + @self._session.on("metrics_collected") + def _on_metrics_collected(ev: Any) -> None: + self._record_tts_metric(ev) + + @self._session.on("agent_state_changed") + def _on_agent_state_changed(ev: Any) -> None: + new_state = str(getattr(ev, "new_state", "") or "").lower() + state_name = new_state.rsplit(".", 1)[-1] + self._agent_state_name = state_name or new_state + if state_name == "speaking": + self._agent_speaking_seq += 1 + self._agent_speaking_at = time.monotonic() + self._agent_speaking_event.set() + if self._timeline is not None: + self._timeline.emit( + "agent_state_changed", + old_state=str(getattr(ev, "old_state", "") or ""), + new_state=str(getattr(ev, "new_state", "") or ""), + ) + + @self._session.on("user_state_changed") + def _on_user_state_changed(ev) -> None: + new_state = str(getattr(ev, "new_state", "") or "").lower() + state_name = new_state.rsplit(".", 1)[-1] + self._user_state_name = state_name or new_state + if state_name == "speaking": + self._user_not_speaking.clear() + else: + self._user_not_speaking.set() + self._call_logger.info( + "USER_STATE | old=%s | new=%s", + getattr(ev, "old_state", ""), + getattr(ev, "new_state", ""), + ) + if self._timeline is not None: + self._timeline.emit( + "user_state_changed", + old_state=str(getattr(ev, "old_state", "") or ""), + new_state=str(getattr(ev, "new_state", "") or ""), + ) + if state_name == "speaking": + if self.state.deferred_interruption.replay_in_flight: + log_flow_event( + self._call_logger, + "deferred_replay_user_speech_discarded", + source="user_state_changed", + ) + return + if ( + self.state.deferred_interruption.backend_in_flight + and not self._config.deferred_interruption_enabled + ): + log_flow_event( + self._call_logger, + "backend_processing_user_speech_discarded", + source="user_state_changed", + mode="feedback_compat", + ) + return + # O STT final pode chegar enquanto o conforto iniciado pelo + # mesmo turno ainda toca. So uma nova transicao real do VAD + # para speaking pode retirar essa protecao. + self._rearm_current_speech_interruptions_on_new_user_speech() + if self._mark_drop_next_user_final_from_current_speech( + reason="user_state_speaking_protected_speech" + ): + return + if self._mark_drop_next_user_final_from_post_playout_window( + reason="user_state_speaking_protected_post_playout_grace" + ): + return + self._clear_pending_user_input_drop() + if self._inflight_backend_wait_notice_is_speaking(): + log_flow_event( + self._call_logger, + "interrupt_detected", + reason="user_state_speaking", + action="interrupt_inflight_backend_wait_notice", + stage=self.state.current_speech.stage, + ) + self.note_user_speech_activity(reason="user_state_speaking") + self.request_current_speech_interruption( + reason="user_state_speaking_inflight_backend_wait", + force=True, + ) + return + self._cancel_pre_backend_wait_notice(reason="user_state_speaking") + self.note_user_speech_activity(reason="user_state_speaking") + self.request_current_speech_interruption(reason="user_state_speaking") + + @self._session.on("overlapping_speech") + def _on_overlapping_speech(ev) -> None: + is_interruption = bool(getattr(ev, "is_interruption", False)) + self._call_logger.info( + "OVERLAPPING_SPEECH | interruption=%s | delay_s=%.3f", + is_interruption, + float(getattr(ev, "detection_delay", 0.0) or 0.0), + ) + if self._timeline is not None: + self._timeline.emit( + "overlapping_speech", + is_interruption=is_interruption, + detection_delay_ms=round(float(getattr(ev, "detection_delay", 0.0) or 0.0) * 1000), + ) + if self.state.deferred_interruption.replay_in_flight: + log_flow_event( + self._call_logger, + "deferred_replay_user_speech_discarded", + source="overlapping_speech", + ) + return + if ( + self.state.deferred_interruption.backend_in_flight + and not self._config.deferred_interruption_enabled + ): + log_flow_event( + self._call_logger, + "backend_processing_user_speech_discarded", + source="overlapping_speech", + mode="feedback_compat", + ) + return + if is_interruption: + if self._mark_drop_next_user_final_from_current_speech( + reason="overlapping_speech_protected_speech" + ): + return + self.request_current_speech_interruption(reason="overlapping_speech") + + room_on = getattr(self._ctx.room, "on", None) + if callable(room_on): + @room_on("participant_connected") + def _on_room_participant_connected(participant: Any) -> None: + log_flow_event( + self._call_logger, + "agent_room_participant_connected", + participant=str(getattr(participant, "identity", "") or ""), + kind=str(getattr(participant, "kind", "") or ""), + bridge_identity=self._bridge_identity or "", + ) + self.log_room_input_snapshot(reason="participant_connected") + + @room_on("participant_disconnected") + def _on_room_participant_disconnected(participant: Any) -> None: + log_flow_event( + self._call_logger, + "agent_room_participant_disconnected", + participant=str(getattr(participant, "identity", "") or ""), + reason=str(getattr(participant, "disconnect_reason", "") or ""), + bridge_identity=self._bridge_identity or "", + ) + self.log_room_input_snapshot(reason="participant_disconnected") + + @room_on("track_published") + def _on_room_track_published(publication: Any, participant: Any) -> None: + log_flow_event( + self._call_logger, + "agent_room_track_published", + participant=str(getattr(participant, "identity", "") or ""), + bridge_identity=self._bridge_identity or "", + **self._track_publication_fields(publication), + ) + self.log_room_input_snapshot(reason="track_published") + + @room_on("track_subscribed") + def _on_room_track_subscribed(track: Any, publication: Any, participant: Any) -> None: + fields = self._track_publication_fields(publication) + fields["track_sid"] = fields.get("track_sid") or str(getattr(track, "sid", "") or "") + log_flow_event( + self._call_logger, + "agent_room_track_subscribed", + participant=str(getattr(participant, "identity", "") or ""), + bridge_identity=self._bridge_identity or "", + **fields, + ) + self.log_room_input_snapshot(reason="track_subscribed") + + @room_on("track_unsubscribed") + def _on_room_track_unsubscribed(track: Any, publication: Any, participant: Any) -> None: + fields = self._track_publication_fields(publication) + fields["track_sid"] = fields.get("track_sid") or str(getattr(track, "sid", "") or "") + log_flow_event( + self._call_logger, + "agent_room_track_unsubscribed", + participant=str(getattr(participant, "identity", "") or ""), + bridge_identity=self._bridge_identity or "", + **fields, + ) + self.log_room_input_snapshot(reason="track_unsubscribed") + + @room_on("track_muted") + def _on_room_track_muted(participant: Any, publication: Any) -> None: + log_flow_event( + self._call_logger, + "agent_room_track_muted", + participant=str(getattr(participant, "identity", "") or ""), + bridge_identity=self._bridge_identity or "", + **self._track_publication_fields(publication), + ) + self.log_room_input_snapshot(reason="track_muted") + + @room_on("track_unmuted") + def _on_room_track_unmuted(participant: Any, publication: Any) -> None: + log_flow_event( + self._call_logger, + "agent_room_track_unmuted", + participant=str(getattr(participant, "identity", "") or ""), + bridge_identity=self._bridge_identity or "", + **self._track_publication_fields(publication), + ) + self.log_room_input_snapshot(reason="track_unmuted") + + @room_on("data_received") + def _on_room_data_received(data_packet: Any) -> None: + if getattr(data_packet, "topic", "") != "bridge.control": + return + + message = self._decode_data_packet(data_packet) + if message is None: + return + + control_type = str(message.get("type") or "").strip() + if self._timeline is not None: + self._timeline.emit("bridge_control_received", control_type=control_type) + + if control_type == "client_audio_enabled": + self.request_initial_agent_turn(reason="client_audio_enabled") + + self._ctx.add_shutdown_callback(self.on_shutdown) + + async def run(self) -> None: + self.register_callbacks() + self._create_task_logged( + self.finalize_when_room_empty(), + name="finalize_when_room_empty", + ) + + if self._agent_starts_conversation: + # Precisa vir antes do StartSession: o RoomIO respeita o estado do + # input ao anexar a track, entao o audio ja nasce descartado. + self.hold_user_audio_input(reason="room_setup") + self._arm_user_audio_input_gate_timeout() + + room_opts_kwargs = dict( + audio_input=room_io.AudioInputOptions( + sample_rate=16000, + num_channels=1, + frame_size_ms=20, + pre_connect_audio=False, + ), + audio_output=room_io.AudioOutputOptions( + sample_rate=16000, + num_channels=1, + ), + ) + + if self._bridge_identity: + room_opts_kwargs["participant_identity"] = self._bridge_identity + + self._call_logger.debug( + "ROOM_ENTER | room=%s | participant_identity=%s", + self._ctx.room.name, + self._bridge_identity or "-", + ) + if self._timeline is not None: + self._timeline.emit( + "room_enter", + participant_identity=self._bridge_identity or "", + ) + + async def _start_session() -> None: + await self.execute( + StartSession( + room=self._ctx.room, + room_options=room_io.RoomOptions(**room_opts_kwargs), + ) + ) + + t_session = self._create_task_logged(_start_session(), name="session_start") + if self._timeline is not None: + self._timeline.emit("session_start_requested") + self.arm_idle_timer(reason="client_join", delay_s=self._idle_policy.join_delay_s) + + try: + await t_session + if self._timeline is not None: + self._timeline.emit("session_started") + except Exception as exc: + self._log_resource_error_events( + status="stop_agent_runtime_unavailable", + reason="livekit_session_start_failed", + resource="agent_runtime", + source="session_start", + exc=exc, + ) + raise + finally: + self._call_logger.debug("ROOM_EXIT | room=%s", self._ctx.room.name) + if self._timeline is not None: + self._timeline.emit("room_exit") diff --git a/src/app/livekit/runtime/command_executor.py b/src/app/livekit/runtime/command_executor.py new file mode 100644 index 0000000..9f2c859 --- /dev/null +++ b/src/app/livekit/runtime/command_executor.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from typing import Any + +from app.livekit.runtime.commands import ( + EndServiceOnce, + ExportSession, + ExtractSpokenText, + InterruptSpeech, + InjectIdleNudge, + NotifyBridgeDone, + NotifyBridgeStop, + RunPipelineInput, + SetPendingInterrupt, + SetPipelineInterruption, + SetPipelineProcessingInterruption, + StartSession, + StartSpeech, + WaitForSpeechPlayout, +) + + +class RuntimeCommandExecutor: + def __init__( + self, + *, + agent: Any, + bridge_gateway: Any, + export_service: Any, + session: Any, + speech_service: Any, + ) -> None: + self._agent = agent + self._bridge_gateway = bridge_gateway + self._export_service = export_service + self._session = session + self._speech_service = speech_service + + def execute_now(self, command: Any) -> Any: + if isinstance(command, ExtractSpokenText): + return self._speech_service.extract_spoken_text(command.handle) + + raise TypeError(f"Unsupported synchronous command: {type(command)!r}") + + async def execute(self, command: Any) -> Any: + if isinstance(command, NotifyBridgeDone): + await self._bridge_gateway.notify_stage_done(command.reason) + return None + + if isinstance(command, NotifyBridgeStop): + await self._bridge_gateway.notify_stop( + status=command.status, + reason=command.reason, + resource=command.resource, + failed_resources=command.failed_resources, + phase=command.phase, + ) + return None + + if isinstance(command, ExportSession): + await self._export_service.export_session(command.output, command.session_id) + return None + + if isinstance(command, EndServiceOnce): + return await self._agent.end_service_once() + + if isinstance(command, InjectIdleNudge): + if self._agent.pipeline is None: + raise RuntimeError("Agent backend not ready") + await self._agent.pipeline.inject_idle_nudge(command.text) + return None + + if isinstance(command, SetPendingInterrupt): + await self._agent.set_pending_interrupt( + listened_text=command.listened_text, + skipped=command.skipped, + speech_id=command.speech_id, + ) + return None + + if isinstance(command, StartSpeech): + return await self._speech_service.start( + command.text, + allow_interruptions=command.allow_interruptions, + add_to_chat_ctx=command.add_to_chat_ctx, + audio=command.audio, + ) + + if isinstance(command, WaitForSpeechPlayout): + await self._speech_service.wait_for_playout(command.handle) + return None + + if isinstance(command, InterruptSpeech): + await self._speech_service.interrupt(command.handle, force=command.force) + return None + + if isinstance(command, SetPipelineInterruption): + if self._agent.pipeline is None: + raise RuntimeError("Agent backend not ready") + await self._agent.pipeline.set_interruption( + command.interrupted, + command.listened_text, + command.skipped, + command.speech_id, + ) + return None + + if isinstance(command, SetPipelineProcessingInterruption): + if self._agent.pipeline is None: + raise RuntimeError("Agent backend not ready") + await self._agent.pipeline.set_processing_interruption( + command.listened_text, + command.skipped, + command.speech_id, + ) + return None + + if isinstance(command, RunPipelineInput): + if self._agent.pipeline is None: + raise RuntimeError("Agent backend not ready") + return await self._agent.pipeline.run(command.user_input) + + if isinstance(command, StartSession): + await self._session.start( + agent=self._agent, + room=command.room, + room_options=command.room_options, + ) + return None + + raise TypeError(f"Unsupported command: {type(command)!r}") diff --git a/src/app/livekit/runtime/commands.py b/src/app/livekit/runtime/commands.py new file mode 100644 index 0000000..b867345 --- /dev/null +++ b/src/app/livekit/runtime/commands.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True, slots=True) +class NotifyBridgeDone: + reason: str = "stage_done" + + +@dataclass(frozen=True, slots=True) +class NotifyBridgeStop: + status: str + reason: str + resource: str = "" + failed_resources: tuple[str, ...] = () + phase: str = "in_session" + + +@dataclass(frozen=True, slots=True) +class ExportSession: + output: Any + session_id: str + + +@dataclass(frozen=True, slots=True) +class EndServiceOnce: + pass + + +@dataclass(frozen=True, slots=True) +class InjectIdleNudge: + text: str + + +@dataclass(frozen=True, slots=True) +class SetPendingInterrupt: + listened_text: str = "" + skipped: bool = False + speech_id: str = "" + + +@dataclass(frozen=True, slots=True) +class StartSpeech: + text: str + allow_interruptions: bool + add_to_chat_ctx: bool = True + audio: Any = None + + +@dataclass(frozen=True, slots=True) +class WaitForSpeechPlayout: + handle: Any + + +@dataclass(frozen=True, slots=True) +class InterruptSpeech: + handle: Any + force: bool = False + + +@dataclass(frozen=True, slots=True) +class ExtractSpokenText: + handle: Any + + +@dataclass(frozen=True, slots=True) +class SetPipelineInterruption: + interrupted: bool + listened_text: str = "" + skipped: bool = False + speech_id: str = "" + + +@dataclass(frozen=True, slots=True) +class SetPipelineProcessingInterruption: + listened_text: str = "" + skipped: bool = False + speech_id: str = "" + + +@dataclass(frozen=True, slots=True) +class RunPipelineInput: + user_input: Any + + +@dataclass(frozen=True, slots=True) +class StartSession: + room: Any + room_options: Any diff --git a/src/app/livekit/runtime/initial_greeting_audio_cache.py b/src/app/livekit/runtime/initial_greeting_audio_cache.py new file mode 100644 index 0000000..1d93dd3 --- /dev/null +++ b/src/app/livekit/runtime/initial_greeting_audio_cache.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +import asyncio +import hashlib +import os +import tempfile +import time +import wave +from dataclasses import dataclass +from pathlib import Path +from typing import AsyncIterator + + +def _env_bool(name: str, default: bool) -> bool: + raw = (os.getenv(name, "") or "").strip().lower() + if not raw: + return default + return raw in {"1", "true", "yes", "on"} + + +def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default))) + except ValueError: + return default + + +@dataclass(frozen=True, slots=True) +class InitialGreetingCacheKey: + agent: str + text: str + provider: str + voice: str + language: str + sample_rate: int + + @property + def digest(self) -> str: + raw = "\x1f".join((self.agent, self.text, self.provider, self.voice, self.language, str(self.sample_rate))) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +class InitialGreetingAudioCache: + """Read-through cache for an exact, first-turn TTS response. + + The caller is responsible for only offering first-turn text. Cache entries are + keyed by the rendered text and TTS settings, so a changed greeting never + reuses stale audio. + """ + + def __init__(self) -> None: + self.enabled = _env_bool("INITIAL_GREETING_AUDIO_CACHE_ENABLED", True) + self._max_chars = max(1, _env_int("INITIAL_GREETING_AUDIO_CACHE_MAX_CHARS", 1_000)) + self._ttl_s = max(1, _env_int("INITIAL_GREETING_AUDIO_CACHE_TTL_S", 3_600)) + self._max_entries = max(1, _env_int("INITIAL_GREETING_AUDIO_CACHE_MAX_ENTRIES", 32)) + self._max_bytes = max(44, _env_int("INITIAL_GREETING_AUDIO_CACHE_MAX_BYTES", 16 * 1024 * 1024)) + self._max_agent_variants = max(1, _env_int("INITIAL_GREETING_AUDIO_CACHE_MAX_VARIANTS_PER_AGENT", 1)) + self._disable_ttl_s = max(1, _env_int("INITIAL_GREETING_AUDIO_CACHE_DISABLE_TTL_S", 300)) + self._agent_variants: dict[str, set[str]] = {} + self._agent_disabled_until: dict[str, float] = {} + self._dir = Path(os.getenv("INITIAL_GREETING_AUDIO_CACHE_DIR", "./cache/initial-greetings")) + + def key_for( + self, + *, + agent: str, + text: str, + provider: str, + voice: str, + language: str, + sample_rate: int, + ) -> InitialGreetingCacheKey | None: + normalized = " ".join(str(text or "").split()) + agent_name = (agent or "unknown").strip().lower() + now = time.monotonic() + if self._agent_disabled_until.get(agent_name, 0.0) > now: + return None + variants = self._agent_variants.setdefault(agent_name, set()) + if normalized not in variants and len(variants) >= self._max_agent_variants: + self._agent_disabled_until[agent_name] = now + self._disable_ttl_s + return None + variants.add(normalized) + if not self.enabled or not normalized or len(normalized) > self._max_chars: + return None + return InitialGreetingCacheKey( + agent=(agent or "unknown").strip().lower(), + text=normalized, + provider=(provider or "unknown").strip().lower(), + voice=(voice or "default").strip(), + language=(language or "pt-BR").strip().lower(), + sample_rate=max(1, int(sample_rate)), + ) + + def path_for(self, key: InitialGreetingCacheKey) -> Path: + return self._dir / f"{key.digest}.wav" + + def has(self, key: InitialGreetingCacheKey) -> bool: + path = self.path_for(key) + try: + stat = path.stat() + except FileNotFoundError: + return False + if stat.st_size <= 44 or time.time() - stat.st_mtime >= self._ttl_s: + self.discard(key) + return False + os.utime(path, None) + return True + + def frames(self, key: InitialGreetingCacheKey) -> AsyncIterator[object]: + return self._materialize_frames(key) + + def discard(self, key: InitialGreetingCacheKey) -> None: + self.path_for(key).unlink(missing_ok=True) + + def _materialize_frames(self, key: InitialGreetingCacheKey) -> AsyncIterator[object]: + path = self.path_for(key) + try: + with wave.open(str(path), "rb") as wav: + channels = wav.getnchannels() + sample_width = wav.getsampwidth() + sample_rate = wav.getframerate() + if channels != 1 or sample_width != 2: + raise ValueError(f"Unsupported cached greeting WAV format: {path}") + pcm = wav.readframes(10**9) + if not pcm: + raise ValueError(f"Cached greeting WAV is empty: {path}") + except (OSError, EOFError, ValueError, wave.Error): + self.discard(key) + raise + + async def _frames() -> AsyncIterator[object]: + from livekit import rtc + + samples_per_frame = max(1, round(sample_rate * 20 / 1000)) + frame_bytes = samples_per_frame * 2 * channels + for offset in range(0, len(pcm), frame_bytes): + chunk = pcm[offset : offset + frame_bytes] + if not chunk: + continue + if len(chunk) < frame_bytes: + chunk = chunk + (b"\x00" * (frame_bytes - len(chunk))) + yield rtc.AudioFrame( + data=chunk, + sample_rate=sample_rate, + num_channels=channels, + samples_per_channel=len(chunk) // (2 * channels), + ) + await asyncio.sleep(0) + + return _frames() + + async def store_pcm(self, key: InitialGreetingCacheKey, pcm: bytes) -> None: + if not pcm or self.has(key): + return + await asyncio.to_thread(self._store_pcm_sync, key, pcm) + + def _store_pcm_sync(self, key: InitialGreetingCacheKey, pcm: bytes) -> None: + self._dir.mkdir(parents=True, exist_ok=True) + target = self.path_for(key) + if self.has(key): + return + with tempfile.NamedTemporaryFile(dir=self._dir, suffix=".wav", delete=False) as handle: + tmp_path = Path(handle.name) + try: + with wave.open(str(tmp_path), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(key.sample_rate) + wav.writeframes(pcm) + os.replace(tmp_path, target) + self._enforce_limits_sync() + finally: + tmp_path.unlink(missing_ok=True) + + def _enforce_limits_sync(self) -> None: + entries = [path for path in self._dir.glob("*.wav") if path.is_file()] + for path in entries[:]: + if time.time() - path.stat().st_mtime >= self._ttl_s: + path.unlink(missing_ok=True) + entries.remove(path) + entries.sort(key=lambda path: path.stat().st_mtime) + total_bytes = sum(path.stat().st_size for path in entries) + while entries and (len(entries) > self._max_entries or total_bytes > self._max_bytes): + evicted = entries.pop(0) + total_bytes -= evicted.stat().st_size + evicted.unlink(missing_ok=True) diff --git a/src/app/livekit/runtime/scheduler.py b/src/app/livekit/runtime/scheduler.py new file mode 100644 index 0000000..bb188e8 --- /dev/null +++ b/src/app/livekit/runtime/scheduler.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Any, Callable + + +@dataclass(slots=True) +class ScheduledTimer: + token: int = 0 + task: asyncio.Task | None = None + + +class TimerScheduler: + def __init__(self, *, create_task_logged: Callable[..., asyncio.Task], logger: Any) -> None: + self._create_task_logged = create_task_logged + self._logger = logger + self._timers: dict[str, ScheduledTimer] = {} + + def cancel(self, key: str, *, reason: str, log_name: str) -> None: + timer = self._timers.get(key) + if timer is None or timer.task is None: + return + if timer.task is asyncio.current_task(): + return + if not timer.task.done(): + timer.task.cancel() + self._logger.debug("%s | reason=%s", log_name, reason) + timer.task = None + + def arm( + self, + key: str, + *, + task_name: str, + coro, + ) -> int: + timer = self._timers.setdefault(key, ScheduledTimer()) + timer.token += 1 + token = timer.token + timer.task = self._create_task_logged(coro, name=task_name) + return token + + def is_current(self, key: str, token: int) -> bool: + timer = self._timers.get(key) + if timer is None: + return False + return timer.token == token diff --git a/src/app/livekit/runtime/state.py b/src/app/livekit/runtime/state.py new file mode 100644 index 0000000..b6593cc --- /dev/null +++ b/src/app/livekit/runtime/state.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass, field +from typing import Any, Optional + + +@dataclass(slots=True) +class PendingUserTurn: + seq: int + transcription: str + text: str + message_id: str = "" + + +@dataclass(slots=True) +class DeferredVADUtterance: + """A fala encerrada pelo VAD, aguardando seu STT final correspondente.""" + + duration_ms: int + is_long: bool + ended_at: float = 0.0 + + +@dataclass(slots=True) +class DeferredInterruptionState: + # Janela do ciclo: aberta enquanto o backend do turno original esta em voo, + # fechada assim que a resposta dele e liberada para o playout. Fora dela a + # fala do cliente e uma interrupcao comum e segue o fluxo normal. + backend_in_flight: bool = False + # Finais VAD anteriores a R1 cujo STT ainda nao terminou. + pre_backend_vad_utterances: deque[DeferredVADUtterance] = field(default_factory=deque) + pending_vad_utterances: deque[DeferredVADUtterance] = field(default_factory=deque) + pending_long_stt_finals: int = 0 + long_turns: list[PendingUserTurn] = field(default_factory=list) + special_comfort_sent: bool = False + replay_in_flight: bool = False + # Incrementado a cada reset para que uma tarefa de conforto atrasada nao + # toque no ciclo seguinte. + cycle_seq: int = 0 + + +@dataclass(slots=True) +class GapState: + active: bool = False + guard_seq: int = 0 + stage: str = "" + text: str = "" + speech_id: str = "" + cancelled: bool = False + + +@dataclass(slots=True) +class CurrentSpeechState: + handle: Optional[Any] = None + stage: str = "" + text: str = "" + speech_id: str = "" + allow_interruptions: bool = False + rearm_interruptions_on_next_user_speech: bool = False + drop_user_input_while_speaking: bool = False + agent_message_type: str = "" + agent_result_type: str = "" + interruption_requested: bool = False + interruption_task: Optional[Any] = None + +@dataclass(slots=True) +class CallState: + current_stage: str = "INTRO" + pending_user_final: Optional[PendingUserTurn] = None + user_final_seq: int = 0 + last_user_final_at: float = 0.0 + idle_nudge_count: int = 0 + gap: GapState = field(default_factory=GapState) + current_speech: CurrentSpeechState = field(default_factory=CurrentSpeechState) + deferred_interruption: DeferredInterruptionState = field(default_factory=DeferredInterruptionState) + + +@dataclass(frozen=True, slots=True) +class RuntimeConfig: + call_end_grace_s: float + final_grace_s: float + idle_nudge_delay_s: float + idle_nudge_join_delay_s: float + idle_nudge_close_delay_s: float + idle_nudge_max_tries: int + idle_nudge_end_reason: str + inflight_backend_wait_interval_s: float = 0.0 + inflight_backend_wait_timeout_s: float = 0.0 + inflight_backend_wait_max_notices: int = 0 + inflight_backend_wait_text: str = "" + inflight_backend_wait_short_audio_dir: str = "" + inflight_backend_wait_long_audio_dir: str = "" + inflight_backend_wait_long_audio_path: str = "" + pre_backend_wait_notice_fast_on_vad_pause: bool = False + deferred_interruption_enabled: bool = True + deferred_interruption_min_audio_ms: int = 1000 + deferred_interruption_stt_settle_timeout_s: float = 3.0 + deferred_interruption_user_turn_timeout_s: float = 10.0 diff --git a/src/app/livekit/runtime/wav_audio.py b/src/app/livekit/runtime/wav_audio.py new file mode 100644 index 0000000..c38ca7c --- /dev/null +++ b/src/app/livekit/runtime/wav_audio.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import asyncio +import wave +from functools import lru_cache +from pathlib import Path +from typing import Any, AsyncIterator + +DEFAULT_TAIL_SILENCE_MS = 320 + + +@lru_cache(maxsize=16) +def _read_wav_pcm(path: str) -> tuple[bytes, int, int]: + wav_path = Path(path) + with wave.open(str(wav_path), "rb") as wav: + channels = wav.getnchannels() + sample_width = wav.getsampwidth() + sample_rate = wav.getframerate() + if channels != 1 or sample_width != 2: + raise ValueError( + f"Unsupported wait audio format for {wav_path}: " + f"expected PCM16 mono, got channels={channels}, sample_width={sample_width}" + ) + data = wav.readframes(10**9) + if not data: + raise ValueError(f"Wait audio file is empty: {wav_path}") + return data, sample_rate, channels + + +async def wav_audio_frames( + path: str, + *, + frame_duration_ms: int = 20, + tail_silence_ms: int = DEFAULT_TAIL_SILENCE_MS, +) -> AsyncIterator[Any]: + from livekit import rtc + + data, sample_rate, channels = _read_wav_pcm(path) + samples_per_frame = max(1, round(sample_rate * max(1, frame_duration_ms) / 1000)) + bytes_per_sample = 2 * channels + frame_bytes = samples_per_frame * bytes_per_sample + + def _frame(chunk: bytes) -> Any: + if len(chunk) < frame_bytes: + chunk = chunk + (b"\x00" * (frame_bytes - len(chunk))) + return rtc.AudioFrame( + data=chunk, + sample_rate=sample_rate, + num_channels=channels, + samples_per_channel=len(chunk) // bytes_per_sample, + ) + + for offset in range(0, len(data), frame_bytes): + chunk = data[offset : offset + frame_bytes] + samples_per_channel = len(chunk) // bytes_per_sample + if samples_per_channel <= 0: + continue + yield _frame(chunk) + await asyncio.sleep(0) + + tail_ms = max(0, int(tail_silence_ms)) + frame_ms = max(1, int(frame_duration_ms)) + tail_frames = (tail_ms + frame_ms - 1) // frame_ms + silence = b"\x00" * frame_bytes + for _ in range(tail_frames): + yield _frame(silence) + await asyncio.sleep(0) + + +def wav_duration_ms(path: str) -> int: + data, sample_rate, channels = _read_wav_pcm(path) + samples = len(data) // (2 * channels) + return round((samples / sample_rate) * 1000) diff --git a/src/app/livekit/vad_dynamic_threshold.py b/src/app/livekit/vad_dynamic_threshold.py new file mode 100644 index 0000000..efce1dc --- /dev/null +++ b/src/app/livekit/vad_dynamic_threshold.py @@ -0,0 +1,134 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from app.utils.logging import log_flow_event + + +@dataclass(frozen=True, slots=True) +class DynamicVADThresholdConfig: + enabled: bool + baseline_activation_threshold: float + baseline_deactivation_threshold: float + retry_activation_threshold: float + retry_deactivation_threshold: float + + +class DynamicVADThresholdController: + def __init__( + self, + vad: Any, + *, + config: DynamicVADThresholdConfig, + call_logger: Any = None, + timeline: Any = None, + ) -> None: + self._vad = vad + self._config = config + self._call_logger = call_logger + self._timeline = timeline + self._retry_active = False + self._current_mode = "baseline" + self._current_activation_threshold = config.baseline_activation_threshold + self._current_deactivation_threshold = config.baseline_deactivation_threshold + + @property + def enabled(self) -> bool: + return bool(self._config.enabled) + + @property + def retry_active(self) -> bool: + return self._retry_active + + def current_thresholds(self) -> dict[str, Any]: + return { + "mode": self._current_mode, + "activation_threshold": self._current_activation_threshold, + "deactivation_threshold": self._current_deactivation_threshold, + } + + def activate_agent_wait_timeout_retry(self, *, attempt: int, reason: str) -> bool: + if not self.enabled or self._retry_active: + return False + applied = self._apply( + activation_threshold=self._config.retry_activation_threshold, + deactivation_threshold=self._config.retry_deactivation_threshold, + mode="agent_wait_timeout_retry", + reason=reason, + attempt=attempt, + ) + if applied: + self._retry_active = True + return applied + + def restore(self, *, reason: str) -> bool: + if not self.enabled or not self._retry_active: + return False + applied = self._apply( + activation_threshold=self._config.baseline_activation_threshold, + deactivation_threshold=self._config.baseline_deactivation_threshold, + mode="baseline", + reason=reason, + attempt=None, + ) + if applied: + self._retry_active = False + return applied + + def _apply( + self, + *, + activation_threshold: float, + deactivation_threshold: float, + mode: str, + reason: str, + attempt: int | None, + ) -> bool: + update_options = getattr(self._vad, "update_options", None) + if not callable(update_options): + self._emit( + "vad_dynamic_threshold_failed", + mode=mode, + reason=reason, + attempt=attempt, + error="update_options_unavailable", + ) + return False + + try: + update_options( + activation_threshold=activation_threshold, + deactivation_threshold=deactivation_threshold, + ) + except Exception as exc: + if self._call_logger is not None: + self._call_logger.exception("[vad] failed updating dynamic threshold") + self._emit( + "vad_dynamic_threshold_failed", + mode=mode, + reason=reason, + attempt=attempt, + error=type(exc).__name__, + ) + return False + + self._current_mode = mode + self._current_activation_threshold = activation_threshold + self._current_deactivation_threshold = deactivation_threshold + self._emit( + "vad_dynamic_threshold_updated", + mode=mode, + reason=reason, + attempt=attempt, + activation_threshold=activation_threshold, + deactivation_threshold=deactivation_threshold, + ) + return True + + def _emit(self, event: str, **fields: Any) -> None: + clean_fields = {key: value for key, value in fields.items() if value is not None} + if self._call_logger is not None: + log_flow_event(self._call_logger, event, **clean_fields) + if self._timeline is not None: + self._timeline.emit(event, **clean_fields) diff --git a/src/app/models/schemas.py b/src/app/models/schemas.py new file mode 100644 index 0000000..cc082bb --- /dev/null +++ b/src/app/models/schemas.py @@ -0,0 +1,46 @@ +from pydantic import BaseModel +from typing import Literal, Dict + + +class AgentData(BaseModel): + idFatura: str | None = None + current_invoice_number: str | None = None + currentInvoiceNumber: str | None = None + channel: str | None = None + + +class StartData(BaseModel): + agent: str + ani: str + gsm: str + routerCallKeyDay: str + routerCallKey: str + callIdGed: str + session_id: str + sessionId: str | None = None + protocolo: str | None = None + agentData: AgentData | None = None + + +class AudioFormat(BaseModel): + encoding: str + sampleRateHz: int + channels: int + +class StartMessage(BaseModel): + type: Literal["start"] + data: StartData + audioFormat: Dict[str, object] | AudioFormat | None = None + callConfig: Dict[str, object] | None = None + +class StopMessage(BaseModel): + type: Literal["stop"] + + +class TransferenciaSessionIdData(BaseModel): + session_id: str + + +class TransferenciaSessionIdMessage(BaseModel): + type: Literal["transferencia_session_id"] + data: TransferenciaSessionIdData diff --git a/src/app/models/stages.py b/src/app/models/stages.py new file mode 100644 index 0000000..0a3cfe5 --- /dev/null +++ b/src/app/models/stages.py @@ -0,0 +1,18 @@ +from enum import Enum + +class Stages(Enum): + presentation = "Faz perguntas do usuário para saber se está falando com o cliente alvo, pode receber respostas do tipo: sim, não, sou eu, espere um pouco etc." + argumentation = "Conversa com o usuário tentando vender um plano de telefone, possui algumas tentativas de vendas, pode receber respostas do tipo: tipo: sim, não, aceito comprar, não quero, não tenho interesse etc." + data_confirmation = "Confirma os dados do cliente, pode receber nomes, datas e números." + formalization = "Faz a confirmação final da venda, vocalizando um pedindo para o cliente confirmar, pode receber respostas do tipo: sim, não, aceito comprar, não quero, não tenho interesse etc." + + @classmethod + def get_value(cls, key: str = "") -> str: + """ + Retorna o value (descrição) direto a partir do nome da stage. + Ex: Stages.get_value("formalization") + """ + try: + return cls[key].value + except KeyError: + return "" diff --git a/src/app/providers/stt_config.py b/src/app/providers/stt_config.py new file mode 100644 index 0000000..bd5f30d --- /dev/null +++ b/src/app/providers/stt_config.py @@ -0,0 +1,551 @@ +override_config = { + "pre_processors": [ + { + "strategy": "selective_short_low_peak", + "config": {} + } + ], + "processor": { + "strategy": "faster_default", + "config": { + "extra_params": { + "temperature": 0, + "without_timestamps": True, + "word_timestamps": False + }, + "model": "igorcouto/sofya_sft_600h_no_spec_augment_v2-ctranslate2" + } + }, + "post_processors": [ + { + "strategy": "similarity_errors_default", + "config": { + "threshold": 90, + "errors": { + "Já, confio": "É confirmo", + "Ja, confio": "É confirmo", + "confio": "confirmo", + "Confio": "Confirmo", + "YIN": "TIM", + "a tema": "a TIM", + "a Teana": "a TIM", + "Timpratx": "TIM pra TIM", + "Atendimento e controle": "Atendimento TIM Controle", + "Assistente Batinho": "Assistente TIM", + "Bom dia, putinho": "Bom dia, TIM", + "loja ti ninha": "Loja TIM", + "débite": "débito", + "a Lagoa": "Alagoas", + "DD": "DDD", + "código de barro": "código de barras", + "cliente tia": "cliente TIM", + "Ouvidoria, tio": "Ouvidoria TIM", + "Nome complexo": "Nome completo", + "Pelo assinamento especial": "Atendimento especial", + "operadora Eixinha": "operadora TIM", + "a datinha": "a da TIM", + "prepédio": "pré-pago", + "Aprendimento do time": "Atendimento TIM", + "Dimei, Tutim": "Atendimento TIM", + "Servimento Saque": "Atendimento SAC", + "Período de Chimbo": "Atendimento TIM", + "Convidoria aqui": "Ouvidoria TIM", + "Comprindo seu nome": "Confirma seu nome", + "cliente na Aline": "cliente na linha", + "mês é atual titular": "mas é atual titular", + "Bem, Aitinho": "Atendimento TIM", + "chique": "chip", + "lá na Goa": "Alagoas", + "Ação de crédito": "cartão de crédito", + "nova recada": "nova recarga", + "Pagamento via fixo": "pagamento via pix", + "Miltin": "meu TIM", + "Paranáute": "Paramount", + "para mont": "Paramount", + "outurro": "outubro", + "operador Petinho": "operadora TIM", + "cayer": "cair", + "pás- pago": "pós-pago", + "Ligação admitada": "Ligação ilimitada", + "telefono": "telefone", + "a VULSO": "avulso", + "ligização": "ligação", + "aparelho das horas ": "aparelho da senhora", + "alugação cair": "ligação cair", + "pusei um código": "puxei um código", + "duas contras": "duas contas", + "No meu interior": "No meu anterior", + "Atim": "a TIM", + "acidente anterior": "atendente anterior", + "Te encontrou-lhe": "TIM Controle", + "Central Tema": "Central TIM", + "meu tema": "meu TIM", + "aplicativo Estufim": "aplicativo Meu TIM", + "aplicativo do meu time": "aplicativo do Meu TIM", + "aplicativo meu tinha": "aplicativo Meu TIM", + "w.tema.com.BR": "www.tim.com.br", + "notando o número": "anotando o número", + "time controle": "TIM Controle", + "TeamControle": "TIM Controle", + "TeamControleSmart": "TIM Controle Smart", + "Tema Com trol Ela": "TIM Controle", + "Tinha controle redes sociais": "TIM Controle redes sociais", + "tinha controle de redes sociais": "TIM Controle redes sociais", + "fazer uma potabilidade": "fazer uma portabilidade", + "Ablaca": "Black", + "Meu Team": "Meu TIM", + "DVD": "DDD", + "Atendimento Sem Controle": "Atendimento TIM Controle", + "MS": "SMS", + "CCB MS": "SMS", + "fiderizado": "fidelizado", + "A P N U S": "A PLUS", + "marga": "carga", + "linha BB": "linha DDD", + "Outubro Premi um": "YouTube Premium", + "Steam Control Smart": "Tim Controle Smart", + "Outubro prêmio": "YouTube Premium", + "e-amil": "e-mail", + "da TIN": "da TIM", + "diesel": "Deezer", + "funeros": "números", + "tendente": "atendente", + "Aitim": "a TIM", + "desastendimento": "atendimento", + "Comprimando": "Confirmando", + "mentinho": "minutinho", + "ilumitada": "ilimitada", + "atendimenta": "atendimento", + "corringir": "corrigir", + "sehora": "senhora", + "planus": "planos", + "cancionamento": "cancelamento", + "atendeimento": "atendimento", + "fielidade": "fidelidade", + "natinho": "na TIM", + "bloqueo": "bloqueio", + "bloqueiro": "bloqueio", + "opição": "opção", + "pagimento": "pagamento", + "reajausto": "reajuste", + "severeir": "fevereiro", + "arrecimento": "vencimento", + "cansele": "cancele", + "cancelimento": "cancelamento", + "cabando": "acabando", + "futbol": "futebol", + "concuído": "concluído", + "imidiato": "imediato", + "aparelo": "aparelho", + "promoción": "promoção", + "outobro": "outubro", + "atrazadas": "atrasadas", + "transferendo": "transferindo", + "pajo": "pago", + "descortos": "descontos", + "sociales": "sociais", + "inoutubra": "outubro", + "deslugar": "desligar", + "cartón": "cartão", + "SNS": "SMS", + "prépaca": "pré pago", + "pessoalamento": "pessoalmente", + "canceloamento": "cancelamento", + "ilumitado": "ilimitado", + "gentilesza": "gentileza", + "promacional": "promocional", + "pás-plago": "pós pago", + "arroma": "arroba", + "contacto": "contato", + "cancola": "cancela", + "benefitios": "benefícios", + "cogrado": "cobrado", + "pasado": "passado", + "fator de comunicação": "falta de comunicação", + "Dizer": "Deezer", + "Dizzer": "Deezer", + "atragado": "atrasado", + "sem CPIN": "sem ser TIM", + "mas como alinhar": "mas como a linha", + "Ei-Fi": "Wi-Fi", + "DND": "DDD", + "CMPJ": "CNPJ", + "fatora": "fatura", + "filmando alguns dados": "confirmando alguns dados", + "Insta gram": "Instagram", + "Aceboca": "Facebook", + "chifre": "chip", + "chave PI": "chave PIX", + "plan": "plano", + "rec carga": "recarga", + "portibilidade": "portabilidade", + "canselamento": "cancelamento", + "cogrou": "cobrou", + "febreiro": "fevereiro", + "pagamenta": "pagamento", + "alinhão": "a linha", + "vo": "vou", + "Aliguação": "A ligação", + "cartõe": "cartões", + "Setembra": "Setembro", + "clientela": "cliente ela", + "primeirinhos": "primeiros dígitos", + "via Pi": "via PIX", + "retaca por aplicativo": "recarga por aplicativo", + "concentração de fatura": "contestação de fatura", + "canal de crédito": "cartão de crédito", + "saldo de leve dor": "saldo devedor", + "a receita da fatura": "a respeito da fatura", + "prescrita de satisfação": "pesquisa de satisfação", + "traca de plano": "troca de plano", + "Conquem, falo": "com quem falo", + "mesmo que vem": "mês que vem", + "se não me enchamos": "se não me engano", + "As terístico": "Asterisco", + "Patinho": "TIM", + "senadora": "senhora", + "promoçal": "promoção", + "jeneiro": "janeiro", + "promoça": "promoção", + "Team black": "TIM Black", + "Team black Cell light": "TIM Black C Light", + "desnoquear": "desbloquear", + "Relígio": "Religa", + "aguardada": "aguardando", + "CIM": "TIM", + "iClap": "iCloud", + "aliar": "avaliar", + "consultra": "consulta", + "do Controlo": "do Controle", + "Batim": "TIM", + "a afilhação caiu": "a ligação caiu", + "a guardinha": "aguarde", + "meu titinho": "Atendimento TIM", + "TeamControlia": "TIM Controle", + "TeamControlia Plus": "TIM Controle Plus", + "Ouvidaria a Atendimento": "Ouvidoria atendimento", + "deputado do": "debitado do", + "Central, te encontrou": "Central TIM Controle", + "botinho da ocorrência": "boletim de ocorrência", + "Bondinha": "bom dia", + "asteristico": "asterisco", + "cum": "com", + "podi transferi": "pode transferir", + "num ti falei": "não te falei", + "não tendi": "não entendi", + "tendi": "entendi", + "UTI": "TIM", + "ubi": "Uber", + "ocilando": "oscilando", + "Team Controle": "TIM Controle", + "Team Controle Smart": "TIM Controle Smart", + "Team Controle A Plus": "TIM Controle A Plus", + "código de barra": "código de barras", + "Team Black C Light": "TIM Black C Light", + "Dá um fatinho. Amém, Tim": "Atendimento TIM", + "do meu PIN": "do meu TIM", + "LÁ Plus” / “La Plus": "Tim Plus", + "código de base": "código de barras", + "código de baixo": "código de barras", + "discólito": "desconto", + "com que a falsa ente lesa": "com quem falo", + "Prefeitura o pagamento": "prefere o pagamento", + "se inscreva no canal": "", + "Seraca": "Serasa", + "Dia PIX": "via PIX", + "fiquei na PIN mesmo": "fiquei na TIM mesmo", + "Ouvidoria é tímido": "Ouvidoria TIM", + "NG": "4G", + "minha vida": "minha linha", + "Ouvidoria, sim": "Ouvidoria TIM", + "ela goa": "Alagoas", + "partura": "fatura", + "Team Controller": "TIM Controle", + "titilaridade": "titularidade", + "Boas tarde": "Boa tarde", + "não sei o que eu falo": "com quem eu falo", + "operora": "operadora", + "Lacoa": "Alagoas", + "ligagem": "ligação", + "canceledado": "cancelado", + "fraud": "fraude", + "Receta": "Receita", + "anteriormento": "anteriormente", + "proprocional": "proporcional", + "procedamento": "procedimento", + "praço": "prazo", + "11gigas": "11 gigas", + "PIM": "TIM", + "ligração": "ligação", + "agorda": "aguarda", + "apareлho": "aparelho", + "senora": "senhora", + "Suha": "sua", + "confirmoção": "confirmação", + "concruir": "concluir", + "fidalidade": "fidelidade", + "correcção": "correção", + "inactivo": "inativo", + "cicl": "ciclo", + "muta": "multa", + "factura": "fatura", + "facturada": "fatura", + "facture": "fatura", + "Novembra": "Novembro", + "Pront": "Pronto", + "faturra": "fatura", + "imprinou": "imprimiu", + "factur": "fatura", + "cancelamenta": "cancelamento", + "opción": "opção", + "cancelamiento": "cancelamento", + "Asterisk": "asterisco", + "ligução": "ligação", + "mudrou": "mudou", + "recalga": "recarga", + "díos": "dias", + "presentidas": "apresentadas", + "bairo": "bairro", + "Team": "TIM", + "pôs-pargo": "pós-pago", + "pó-spago": "pós-pago", + "planhos": "planos", + "setор": "setup", + "fidedilização": "fidelização", + "clientele": "cliente ele", + "disconto": "desconto", + "fidalizado": "fidelizado", + "direcimar": "direcionar", + "cancelamente": "cancelamento", + "beneficios": "benefícios", + "setebro": "setembro", + "iliminada": "eliminada", + "Outubra": "Outubro", + "iliminadas": "eliminadas", + "aletrônico": "eletrônico", + "ligguei": "liguei", + "conclue": "conclui", + "disculpe": "desculpe", + "entrezi": "entendi", + "mensage": "mensagem", + "promorcional": "promocional", + "reajauste": "reajuste", + "TIN": "TIM", + "planas": "planos", + "os panos aumentaram": "os planos aumentaram", + "ocrido": "ocorrido", + "fibrilidade": "fidelidade", + "fidedilidade": "fidelidade", + "sidelidad": "fidelidade", + "sidelizado": "fidelidade", + "fecereiro": "fevereiro", + "vevereira": "fevereiro", + "feveiro": "fevereiro", + "atendimentar": "atendimento", + "pagei": "paguei", + "mismo": "mesmo", + "nômero": "número", + "consulamento": "cancelamento", + "fatural": "fatura", + "salvatura": "sua fatura", + "salvaatura": "sua fatura", + "gigabas": "giga de", + "avalhar": "avaliar", + "pergânticas": "perguntinhas", + "Ateimento": "Atendimento", + "pré-pac": "pré-pago", + "atentimento": "atendimento", + "nuevo": "novo", + "menuna": "menina", + "ontes": "antes", + "Técnoco": "Técnico", + "ontam": "ontem", + "retragir": "retroagir", + "antivalor": "ativação", + "protocoldo": "protocolo", + "Pospag": "Pós-pago", + "Núnmeros": "números", + "aplicativ": "aplicativo", + "perifico": "verifico", + "Ethernet": "internet", + "consigue": "consegue", + "pagado": "pagando", + "namo": "nome", + "gigais": "gigas", + "mudançada": "mudança da", + "distanciaamento": "distanciamento", + "cortada": "contando", + "que eu li aí": "que eu ligo aí", + "bônos": "bônus", + "bónus": "bônus", + "descontro": "desconto", + "septembra": "setembro", + "atingimento": "atendimento", + "pelo favor": "por favor", + "contura": "fatura", + "pacoto": "pacote", + "recargos": "recargas", + "formamento": "atendimento", + "daram": "deram", + "tentiva": "tentativa", + "perifique": "verifiquei", + "faturinha": "fatura", + "cancelar o ciclano": "cancelar esse plano", + "confirmas": "confirmar", + "ligações delimitadas": "ligações ilimitadas", + "Rp": "R$", + "emberto": "aberto", + "receendo": "recebendo", + "fideilização": "fidelização", + "TeamPretop": "TIM Pré Top", + "Prano novo": "Plano novo", + "atendimiento": "atendimento", + "Hoje não": "Pois não", + "meu plano é posto": "meu plano é pós", + "pagote": "pacote", + "oferte": "oferta", + "portbilidade": "portabilidade", + "suportabilidad": "portabilidade", + "chique de atraso": "chip da claro", + "fiz a possibilidade": "fiz a portabilidade", + "gente leza": "gentileza", + "se tem alguma tendência": "se tem alguma pendência", + "a sapatura": "essa fatura", + "Black Day Light": "Black D Light", + "carturas": "faturas", + "Central Outim": "Central TIM", + "código PISC": "código PIX", + "da Tinha": "da TIM", + "home nacional": "roaming nacional", + "Homem internacional": "roaming internacional", + "loja da Tinha": "loja da TIM", + "na TIN": "na TIM", + "número TIN": "número TIM", + "ouvedoria": "ouvidoria", + "para a Tini": "para a TIM", + "Pixi": "PIX", + "PPF": "CPF", + "Presship": "Pré Chip", + "Rendimento Team": "Atendimento Team", + "Simpress Top": "TIM Pre Top", + "Simpresship": "TIM Pre Chip", + "site da Tia": "site da TIM", + "Team Beta": "Tim Beta", + "Team Black": "TIM Black", + "Team Black Alight": "TIM Black A Light", + "Team Black Belight": "Tim Black B Light", + "Team Black Cellite": "Team Black C Light", + "Team Black Delight": "Team Black D Light", + "Team Black Família": "TIM Black Família", + "Team Black Sea Ultra": "Team Black C Ultra", + "Team Black ser ultra": "Team Black C Ultra", + "team família": "TIM Família", + "Team Finanças Mensal": "TIM Finanças Mensal", + "Team pra Team": "TIM pra TIM", + "Team Pre-Chip Plus": "TIM Pre-Chip Plus", + "Team Pre-Chip Plus 1.0": "TIM Pre-Chip Plus 1.0", + "Team Presship Plus": "TIM Pre-Chip Plus", + "Team Pre-Top": "TIM Pre-Top", + "Team Turismo": "TIM Turismo", + "TeamFan": "TIM FUN", + "Tim BlackBelight": "Tim Black B Light", + "Tim BlackC Ultra": "Tim Black C Ultra", + "vencionamento": "vencimento", + "Eu confio": "Eu confirmo", + "Eu confumo": "Eu confirmo", + "Atenso": "Aceito", + "Noun": "Não", + "Nouns": "Não", + "A, X e banca": "AYA, EXA e BANCAH", + "para mal": "Paramount", + "para malte": "Paramount", + "paramalte": "Paramount", + "para monte": "Paramount", + "paramonte": "Paramount", + "paramont": "Paramount", + "para mount": "Paramount", + "para maunt": "Paramount", + "peramount": "Paramount", + "paramounti": "Paramount", + "AIA, Hexa e Banca": "AYA, EXA e BANCAH", + "Hexacloud": "Exa Cloud", + "ExaCloud": "Exa Cloud", + "Tanochi mensal": "Tanoshi Mensal", + "Tanoche mensal": "Tanoshi Mensal", + "Tanois Dimensão": "Tanoshi Mensal", + "TIM Passion": "TIM Fashion", + "TIM Crowd Gaming": "TIM Cloud Gaming", + "TeamCloud Game": "TIM Cloud Gaming", + "PIN Cloud Game": "TIM Cloud Gaming", + "Crowd Gaming": "Cloud Gaming", + "Game Loft": "Gameloft", + "Gabeloft": "Gameloft", + "Instarena": "Insta Arena", + "Instar ainda": "InstArena", + "o book": "Ubook", + "o Bob": "Ubook", + "e book": "Ubook", + "Black Night": "Blacknut", + "Black Nut": "Blacknut", + "Black Nuts": "Blacknut", + "Babel": "Babbel", + "Sem Clube": "Zenklub", + "Zen Clube": "Zenklub", + "Cultive": "Kultivi", + "A WG": "AWG", + "Banca Prime ou Mais": "Bancah Premium", + "Pair Months Mais": "Paramount", + "Permute Mais": "Paramount", + "E Burn": "WeBurn", + "E Born": "WeBurn", + "Ibor": "WeBurn", + "Heading": "Reading", + "Reding": "Reading", + "Buy Lock": "By Looke", + "Luck": "Looke", + "Lut": "Looke", + "Esquilo": "Skeelo", + "Dizze Premium": "Deezer Premium", + "DJ Premium": "Deezer Premium", + "Leo's Gate": "Lionsgate", + "Mônica verso": "Monicaverso", + "Curta 1": "CurtaOn", + "Chefs Club": "ChefsClub", + "Fuso e Forge": "Fuze Forge", + "Mini Boost": "Minibooks", + "Aia e Ensinar": "Aya Ensinah", + "Aia em cima": "Aya Ensinah", + "Zoar em cima": "Aya Ensinah", + "Aion em cima": "Aya Ensinah", + "Aí a box": "Aya Books", + "Que cai a box": "Aya Books", + "Ayabucks": "Aya Books", + "Tem mais topem": "TIM Thopen", + "PIN mas topem": "TIM Thopen" + }, + "max_window": 8, + "exact_first": True, + "full_replacement": True, + "separator_equivalence": True, + "include_metadata": True + } + }, + { + "strategy": "two_stage_hardneg", + "config": { + "artifact_dir": "/usr/src/app/artifacts/two_stage_hardneg", + "device": "auto", + "local_files_only": True, + "transcription_language": "portuguese", + "fail_open": True, + "duration_threshold_ms": 4000 + } + }, + { + "strategy": "clean_default", + "config": { + "regex": "(?i)(?:^\\s*[\\.,!?;:()\\[\\]{}—…\"'`-]+\\s*$|\\b(?:e\\s*a[ií]|ent[aã]o\\s*t[aá]|tchau(?:\\s*[\\.,!?;:()\\[\\]{}—…\"'`-]*\\s*galera)?|valeu|obrigado|se\\s*inscreva\\s*no\\s*canal)\\b[\\.,!?;:()\\[\\]{}—…\"'`-]*)" + } + } + ] + } diff --git a/src/app/providers/stt_fake.py b/src/app/providers/stt_fake.py new file mode 100644 index 0000000..4083a3d --- /dev/null +++ b/src/app/providers/stt_fake.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import asyncio +import os +import time +import uuid +from typing import Any, Callable, List + +from livekit.agents.stt.stt import ( + STT, + STTCapabilities, + SpeechData, + SpeechEvent, + SpeechEventType, +) +from livekit.agents.types import APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS, NOT_GIVEN, NotGivenOr +from livekit.agents.utils.audio import AudioBuffer, calculate_audio_duration + +from app.utils.logging import ( + EVENT_RECEBIMENTO_MSG, + event_latency_ms, + log_structured_event, + setup_minimal_logging, +) +from app.utils.turn_ids import ( + next_turn_message_id, + register_started_turn_message_id, + register_transcribed_turn, +) + +logger = setup_minimal_logging() + + +def _parse_transcripts(raw_value: str) -> List[str]: + parts: List[str] = [] + for chunk in (raw_value or "").replace("\n", "|").split("|"): + text = chunk.strip() + if text: + parts.append(text) + return parts + + +class FakeSTT(STT): + def __init__( + self, + *, + language: str = "pt-BR", + timeline: Any | None = None, + structured_log_context: Any | None = None, + structured_logger: Any | None = None, + structured_interruption_flag: Callable[[], bool] | None = None, + ) -> None: + super().__init__(capabilities=STTCapabilities(streaming=False, interim_results=False)) + self._language = (language or "pt-BR").strip() or "pt-BR" + self._timeline = timeline + self._structured_log_context = structured_log_context + self._structured_logger = structured_logger + self._structured_interruption_flag = structured_interruption_flag + self._transcripts = _parse_transcripts( + os.getenv( + "FAKE_STT_TRANSCRIPTS", + "alô|quero seguir com a simulacao|sim|obrigado, tchau", + ) + ) + if not self._transcripts: + self._transcripts = ["alô", "sim", "obrigado, tchau"] + self._mode = (os.getenv("FAKE_STT_MODE", "repeat_last") or "repeat_last").strip().lower() + self._min_audio_ms = max(0, int(os.getenv("FAKE_STT_MIN_AUDIO_MS", "150") or "150")) + self._cursor = 0 + self._lock = asyncio.Lock() + + def _structured_interruption_value(self) -> int: + if self._structured_interruption_flag is None: + return 0 + try: + return 1 if bool(self._structured_interruption_flag()) else 0 + except Exception: + return 0 + + @property + def model(self) -> str: + return "fake-script" + + @property + def provider(self) -> str: + return "fake" + + async def _recognize_impl( + self, + buffer: AudioBuffer, + *, + language: NotGivenOr[str] = NOT_GIVEN, + conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS, + ) -> SpeechEvent: + del conn_options + + started_ns = time.time_ns() + request_id = uuid.uuid4().hex + audio_duration_ms = round(calculate_audio_duration(buffer) * 1000) + resolved_language = language if isinstance(language, str) and language.strip() else self._language + + if audio_duration_ms < self._min_audio_ms: + if self._timeline is not None: + self._timeline.emit( + "stt_fake_skipped", + request_id=request_id, + duration_ms=audio_duration_ms, + reason="too_short", + ) + return SpeechEvent( + type=SpeechEventType.FINAL_TRANSCRIPT, + request_id=request_id, + alternatives=[SpeechData(language=resolved_language, text="", confidence=0.0)], + ) + + async with self._lock: + current_index = self._cursor + if self._mode == "cycle": + transcript = self._transcripts[current_index % len(self._transcripts)] + self._cursor += 1 + else: + bounded_index = min(current_index, len(self._transcripts) - 1) + transcript = self._transcripts[bounded_index] + if self._cursor < len(self._transcripts) - 1: + self._cursor += 1 + + if transcript.lower() in {"__silence__", "__empty__"}: + transcript = "" + + if self._timeline is not None: + self._timeline.emit( + "stt_fake_completed", + request_id=request_id, + duration_ms=audio_duration_ms, + transcript=transcript, + transcript_index=current_index, + ) + if transcript: + finished_ns = time.time_ns() + logger.info( + "[stt][fake_transcript] req_id=%s duration_ms=%s audio_duration_ms=%s text=%r", + request_id, + event_latency_ms(started_ns, finished_ns), + audio_duration_ms, + transcript, + ) + message_id = "" + if transcript: + message_id = next_turn_message_id(self._structured_log_context) + register_started_turn_message_id(self._structured_log_context, message_id) + register_transcribed_turn( + self._structured_log_context, + message_id=message_id, + transcription=transcript, + text=transcript, + ) + + if transcript and self._structured_logger is not None: + total_ms = event_latency_ms(started_ns, finished_ns) + log_structured_event( + self._structured_logger, + self._structured_log_context, + tipo_evento=EVENT_RECEBIMENTO_MSG, + message_id=message_id, + inicio_ns=started_ns, + fim_ns=finished_ns, + latencia_total_ms=total_ms, + latencia_tffb_ms=total_ms, + interrupcao=self._structured_interruption_value(), + ) + + return SpeechEvent( + type=SpeechEventType.FINAL_TRANSCRIPT, + request_id=request_id, + alternatives=[SpeechData(language=resolved_language, text=transcript, confidence=1.0)], + ) + + async def aclose(self) -> None: + return None diff --git a/src/app/providers/stt_internal_livekit.py b/src/app/providers/stt_internal_livekit.py new file mode 100644 index 0000000..ae37633 --- /dev/null +++ b/src/app/providers/stt_internal_livekit.py @@ -0,0 +1,1180 @@ +from __future__ import annotations + +import re +import unicodedata +import asyncio +import copy +import threading +import os +import io +import json +import uuid +import wave +import time +import logging +from dataclasses import dataclass +from typing import Any, Callable, Dict, Optional, Tuple + +import httpx +from livekit import rtc +from livekit.agents.stt.stt import ( + STT, + STTCapabilities, + SpeechEvent, + SpeechEventType, + SpeechData, + APIConnectOptions, +) + +from app.utils.pcm import pcm_duration_ms, dbfs_pcm16le +from app.utils.dump import _dump_audio_for_debug +from app.utils.stt_audio_upload import enqueue_stt_vad_audio_upload +from app.providers.stt_config import override_config +from app.providers.stt_vosk import VoskSTT +from app.providers.stt_word_dictionary import ( + SHORT_WORD_ALLOWLIST, + SHORT_WORD_ALLOWLIST_MIN_PROB, +) +from app.common.timed import timed +from app.utils.logging import ( + EVENT_RECEBIMENTO_MSG, + event_latency_ms, + log_flow_event, + log_structured_event, + setup_minimal_logging, +) +from app.utils.turn_ids import ( + clear_started_turn_message_id, + next_turn_message_id, + register_started_turn_message_id, + register_transcribed_turn, +) + +logger = setup_minimal_logging() + +_HTTP_500_MAX_RETRIES = 1 + +# --- single-word rewrite (apenas para raw_json) --- +_SINGLE_WORD_REWRITE_MAP = { + "bom": "Não", + "bem": "Sim", + "falou": "Alô", +} + +_FINAL_PUNCT_RE = re.compile(r"[\s]*[.!?…,:;]+[\s]*$") + +def _env_non_negative_int(name: str, default: int) -> int: + try: + return max(0, int(os.getenv(name, str(default)))) + except ValueError: + return max(0, default) + + +def _json_for_log(payload: Any) -> str: + try: + return json.dumps(payload, ensure_ascii=False, default=str) + except Exception: + return str(payload) + + +def _prepend_pcm16le_silence( + pcm: bytes, + *, + sample_rate: int, + channels: int, + padding_ms: int, +) -> tuple[bytes, int]: + if not pcm or padding_ms <= 0: + return pcm, 0 + sample_frames = round(sample_rate * (padding_ms / 1000.0)) + silence = b"\x00" * max(0, sample_frames) * max(1, channels) * 2 + if not silence: + return pcm, 0 + actual_padding_ms = round(pcm_duration_ms(silence, sample_rate, channels)) + return silence + pcm, actual_padding_ms + + +def strip_final_punctuation(text: str) -> str: + return _FINAL_PUNCT_RE.sub("", (text or "").strip()) + +def _normalize_token(s: str) -> str: + s = strip_final_punctuation(s) # remove pontuação final + s = (s or "").strip().lower() + s = unicodedata.normalize("NFD", s) + s = "".join(ch for ch in s if unicodedata.category(ch) != "Mn") # remove acentos + return s + + +_NORMALIZED_SHORT_WORD_ALLOWLIST = frozenset( + _normalize_token(word) + for word in SHORT_WORD_ALLOWLIST + if _normalize_token(word) +) + + +def _is_short_word_allowlisted(text: str) -> bool: + return _normalize_token(text) in _NORMALIZED_SHORT_WORD_ALLOWLIST + + +def _short_word_allowlist_accepts(text: str, _probability: float) -> bool: + return _is_short_word_allowlisted(text) + + +def apply_single_word_rewrite_for_raw_json(norm_payload: Dict[str, Any]) -> Dict[str, Any]: + """ + Se o payload (já normalizado em {"data":{"text","words"}}) tiver 1 palavra, + troca data.text conforme _SINGLE_WORD_REWRITE_MAP. + """ + try: + data = norm_payload.get("data") if isinstance(norm_payload.get("data"), dict) else {} + text = (data.get("text") or "").strip() + words = data.get("words") if isinstance(data.get("words"), list) else [] + + # "retorno for 1": prioriza words.length, senão cai pra contagem de tokens do text + if words: + is_single = (len(words) == 1) + else: + is_single = (len([t for t in _normalize_token(text).split() if t]) == 1) + + if not is_single or not text: + return norm_payload + + key = _normalize_token(text) + replacement = _SINGLE_WORD_REWRITE_MAP.get(key) + if replacement: + data["text"] = replacement + norm_payload["data"] = data + + # Se quiser manter consistência com words[0]["word"], descomente: + # if isinstance(words, list) and len(words) == 1 and isinstance(words[0], dict): + # words[0]["word"] = f" {replacement}" + # data["words"] = words + + return norm_payload + except Exception: + return norm_payload + + +def extract_text_from_transcript(transcript: str) -> str: + """ + Aceita: + - texto puro: "oi tudo bem" + - JSON string novo: {"data":{"text":"...","words":[...]}} + - JSON string legado: {"text":"..."} ou {"data":{"text":"..."}} + + Retorna SEMPRE texto limpo (ou "" se não achar texto). + """ + s = (transcript or "").strip() + if not s: + return "" + + if s[:1] in "{[": + try: + obj = json.loads(s) + + if isinstance(obj, dict): + data = obj.get("data") + if isinstance(data, dict): + t = data.get("text") + if isinstance(t, str) and t.strip(): + return t.strip() + + t = obj.get("text") + if isinstance(t, str) and t.strip(): + return t.strip() + + return "" + + if isinstance(obj, list): + for it in obj: + if isinstance(it, dict): + data = it.get("data") + if isinstance(data, dict): + t = data.get("text") + if isinstance(t, str) and t.strip(): + return t.strip() + t = it.get("text") + if isinstance(t, str) and t.strip(): + return t.strip() + return "" + except Exception: + pass + + return s + + +def normalize_to_data_words_format(payload: Dict[str, Any]) -> Dict[str, Any]: + """ + Normaliza diferentes formatos para: + {"data": {"text": "...", "words": [{"word","start","end","probability"}]}} + + Suporta: + - {"data": {"text": "...", "words":[{"probability":...}]}} + - {"text": "...", "words":[...]} (move pra dentro de data) + - {"text": "...", "result":[{"word","start","end","conf"}]} (Vosk padrão) + - {"text": "...", "Probabilites"/"Probabilities":[{"words":[...]}]} (legado) + """ + if not isinstance(payload, dict): + return {"data": {"text": "", "words": []}} + + # já é novo + if isinstance(payload.get("data"), dict): + data = payload["data"] + text = (data.get("text") or "").strip() + words = data.get("words") if isinstance(data.get("words"), list) else [] + return {"data": {"text": text, "words": words}} + + root = payload + text = (root.get("text") or "").strip() + + # words direto + if isinstance(root.get("words"), list): + return {"data": {"text": text, "words": root["words"]}} + + # vosk "result" + if isinstance(root.get("result"), list): + out_words = [] + for w in root["result"]: + if not isinstance(w, dict): + continue + out_words.append({ + "word": w.get("word", ""), + "start": float(w.get("start", 0.0) or 0.0), + "end": float(w.get("end", 0.0) or 0.0), + "probability": float(w.get("conf", w.get("probability", 0.0)) or 0.0), + }) + return {"data": {"text": text, "words": out_words}} + + # legado Probabilites/Probabilities + probs = ( + root.get("Probabilites") + or root.get("Probabilities") + or root.get("probabilites") + or root.get("probabilities") + or [] + ) + if isinstance(probs, list) and probs: + first = probs[0] if isinstance(probs[0], dict) else {} + words = first.get("words") if isinstance(first.get("words"), list) else [] + seg_text = (first.get("text") or text or "").strip() + return {"data": {"text": seg_text, "words": words}} + + return {"data": {"text": text, "words": []}} + + +def stt_text_with_single_word_threshold( + payload: Dict[str, Any], + min_prob_single_word: float = 0.10, +) -> Optional[str]: + """ + Regra: + - allowlist passa independente da probabilidade + - 1 palavra: só aceita se prob > min_prob_single_word + - >1 palavra: aceita + - sem words: aceita text + """ + try: + norm = normalize_to_data_words_format(payload) + data = norm.get("data") if isinstance(norm.get("data"), dict) else {} + text = (data.get("text") or "").strip() + if not text: + return None + + words = data.get("words") if isinstance(data.get("words"), list) else [] + if not words: + return text + + if len(words) == 1: + prob = words[0].get("probability", 0.0) + try: + prob = float(prob) + except Exception: + prob = 0.0 + if prob > min_prob_single_word: + return text + if _short_word_allowlist_accepts(text, prob): + return text + return None + + return text + except Exception: + return None + + +@dataclass +class InternalSTTConfig: + url: str + api_key: str + language: str = "pt" + sample_rate: int = 16000 + channels: int = 1 + timeout_s: float = 30.0 + min_prob_single_word: float = 0.10 + initial_prompt: str = "" + config_override: str = "" + output_mode: str = "threshold_text" # "threshold_text" | "raw_json" | "api_text" + + +def _normalize_override_config_json(raw_config: str) -> str: + text = (raw_config or "").strip() + if not text: + return "" + + payload = json.loads(text) + if not isinstance(payload, dict): + raise ValueError("STT config override must be a JSON object") + return json.dumps(payload, ensure_ascii=False) + + +class InternalHTTPSTT(STT): + """ + STT batch (não-streaming). + O LiveKit Agents vai usar VAD/turn_detection para segmentar e chamar recognize() por trecho. + """ + + def __init__( + self, + cfg: InternalSTTConfig, + *, + client: httpx.AsyncClient, + vosk: Optional[VoskSTT] = None, + vosk_lock: Optional[threading.Lock] = None, + timeline: Any | None = None, + structured_log_context: Any | None = None, + structured_logger: Any | None = None, + structured_interruption_flag: Callable[[], bool] | None = None, + empty_transcript_handler: Callable[[], None] | None = None, + metrics_handler: Callable[[dict[str, Any]], None] | None = None, + ): + super().__init__(capabilities=STTCapabilities(streaming=False, interim_results=False)) + self._cfg = cfg + self._client = client + self._vosk = vosk + self._vosk_lock = vosk_lock + self._timeline = timeline + self._structured_log_context = structured_log_context + self._structured_logger = structured_logger + self._structured_interruption_flag = structured_interruption_flag + self._empty_transcript_handler = empty_transcript_handler + self._metrics_handler = metrics_handler + self._resampler: Optional[rtc.AudioResampler] = None + + def set_empty_transcript_handler(self, handler: Callable[[], None] | None) -> None: + self._empty_transcript_handler = handler + + def set_metrics_handler( + self, handler: Callable[[dict[str, Any]], None] | None + ) -> None: + self._metrics_handler = handler + + def _notify_metrics(self, **fields: Any) -> None: + handler = self._metrics_handler + if handler is None: + return + try: + handler(fields) + except Exception: + logger.exception("[stt][metrics_handler_error]") + + def _notify_empty_transcript(self, *, request_id: str, source: str) -> None: + handler = self._empty_transcript_handler + if handler is None: + return + try: + handler() + except Exception: + logger.exception( + "[stt][empty_transcript_handler_error] req_id=%s source=%s", + request_id, + source, + ) + + def _structured_interruption_value(self) -> int: + if self._structured_interruption_flag is None: + return 0 + try: + return 1 if bool(self._structured_interruption_flag()) else 0 + except Exception: + return 0 + + def _log_recebimento_msg( + self, + *, + request_id: str, + started_ns: int, + message_id: str = "", + finished_ns: int | None = None, + erro_msg: str | None = None, + erro_detalhe: str | None = None, + http_cod_status: int | None = None, + http_cod_desc: str | None = None, + ) -> str: + resolved_message_id = str(message_id or "").strip() or next_turn_message_id( + self._structured_log_context + ) + register_started_turn_message_id(self._structured_log_context, resolved_message_id) + if self._structured_logger is None: + return resolved_message_id + + ended_ns = int(finished_ns if finished_ns is not None else time.time_ns()) + total_ms = event_latency_ms(started_ns, ended_ns) + log_structured_event( + self._structured_logger, + self._structured_log_context, + tipo_evento=EVENT_RECEBIMENTO_MSG, + message_id=resolved_message_id, + inicio_ns=started_ns, + fim_ns=ended_ns, + latencia_total_ms=total_ms, + latencia_tffb_ms=total_ms, + interrupcao=self._structured_interruption_value(), + erro_msg=erro_msg, + erro_detalhe=erro_detalhe, + http_cod_status=http_cod_status, + http_cod_desc=http_cod_desc, + ) + return resolved_message_id + + def _log_detected_text( + self, + *, + request_id: str, + output_text: str, + engine: str, + mode: str | None = None, + duration_ms: float | int | None = None, + audio_duration_ms: float | int | None = None, + level_dbfs: float | None = None, + ) -> None: + detected_text = extract_text_from_transcript(output_text) + log_flow_event( + self._structured_logger or logger, + "stt_done", + request_id=request_id, + engine=engine, + mode=mode or self._stt_output_mode(), + returned=True, + duration_ms=round(float(duration_ms)) if duration_ms is not None else None, + audio_ms=round(float(audio_duration_ms)) if audio_duration_ms is not None else None, + dbfs=round(float(level_dbfs), 2) if level_dbfs is not None else None, + text_len=len(detected_text), + text=detected_text, + ) + + def _register_transcribed_turn(self, *, message_id: str, transcription: str) -> None: + detected_text = extract_text_from_transcript(transcription) + if not detected_text: + return + + register_transcribed_turn( + self._structured_log_context, + message_id=message_id, + transcription=transcription, + text=detected_text, + ) + + def _stt_output_mode(self) -> str: + return (os.getenv("STT_OUTPUT_MODE", self._cfg.output_mode) or "threshold_text").strip().lower() + + def _format_stt_output(self, payload: Dict[str, Any], *, request_id: str = "") -> str: + """ + Converte o payload para uma string, dependendo do modo: + + - raw_json: devolve JSON string no formato {"data":{...}} + - api_text: devolve apenas data.text + - threshold_text: aplica regra de 1 palavra com prob + """ + mode = self._stt_output_mode() + + norm = normalize_to_data_words_format(payload) + data = norm.get("data") if isinstance(norm.get("data"), dict) else {} + api_text = (data.get("text") or "").strip() + words = data.get("words") if isinstance(data.get("words"), list) else [] + single_word_prob = None + if len(words) == 1 and isinstance(words[0], dict): + try: + single_word_prob = float(words[0].get("probability", 0.0) or 0.0) + except Exception: + single_word_prob = 0.0 + + if mode == "raw_json": + logger.info("[stt][raw_json_before_rewrite] %s", norm) + norm = apply_single_word_rewrite_for_raw_json(norm) + norm["STT"] = "Sofya" + logger.info("[stt][raw_json_after_rewrite] %s", norm) + return json.dumps(norm, ensure_ascii=False) + + + if mode == "api_text": + return (data.get("text") or "").strip() + + out_text = stt_text_with_single_word_threshold(norm, self._cfg.min_prob_single_word) or "" + filter_reason = "accepted" + if ( + out_text + and len(words) == 1 + and single_word_prob is not None + and single_word_prob <= self._cfg.min_prob_single_word + and _short_word_allowlist_accepts(api_text, single_word_prob) + ): + filter_reason = "single_word_allowlist_low_confidence" + if not out_text: + if not api_text: + filter_reason = "api_text_empty" + elif len(words) == 1 and single_word_prob is not None: + filter_reason = "single_word_below_threshold" + else: + filter_reason = "threshold_text_empty" + + log_flow_event( + self._structured_logger or logger, + "stt_payload", + request_id=request_id or None, + mode=mode, + api_text=api_text, + api_text_len=len(api_text), + words_count=len(words), + min_prob_single_word=self._cfg.min_prob_single_word, + single_word_prob=( + f"{single_word_prob:.3f}" if single_word_prob is not None else None + ), + allowlist_min_prob=SHORT_WORD_ALLOWLIST_MIN_PROB, + filter_reason=filter_reason, + ) + return out_text + + def _build_override_config_json(self) -> str: + forced_override = (os.getenv("STT_FORCE_OVERRIDE_CONFIG", "") or "").strip() + if forced_override: + return _normalize_override_config_json(forced_override) + + inline_override = (self._cfg.config_override or "").strip() + if inline_override: + return _normalize_override_config_json(inline_override) + + base = override_config + ovr = copy.deepcopy(base) + + proc = ovr.setdefault("processor", {}).setdefault("config", {}).setdefault("extra_params", {}) + + forced = (os.getenv("STT_FORCE_INITIAL_PROMPT", "").strip() or "").strip() + prompt = forced or (self._cfg.initial_prompt or "").strip() or "Aguarda confimação de números do cpf" + proc["initial_prompt"] = prompt + + return json.dumps(ovr, ensure_ascii=False) + + async def _try_vosk(self, pcm: bytes, req_id: str) -> Tuple[str, Dict[str, Any]]: + """ + Roda Vosk em thread e serializa com lock. + Retorna: (texto, payload_padronizado_em_data) + """ + if self._vosk is None: + return "", {} + if self._timeline is not None: + self._timeline.emit( + "stt_vosk_started", + request_id=req_id, + pcm_bytes=len(pcm), + ) + + lock = self._vosk_lock + + def _run() -> Tuple[str, Dict[str, Any]]: + if lock is not None: + with lock: + raw = self._vosk.transcribe_pcm16le(pcm) + else: + raw = self._vosk.transcribe_pcm16le(pcm) + + # raw já vem padronizado se seu VoskSTT estiver atualizado, + # mas garantimos aqui também: + txt, payload = VoskSTT.result_to_text_and_payload(raw) + payload = normalize_to_data_words_format(payload) + return (txt or "").strip(), payload + + try: + text, payload = await asyncio.to_thread(_run) + if self._timeline is not None: + self._timeline.emit( + "stt_vosk_completed", + request_id=req_id, + text=text, + text_len=len(text), + has_payload=bool(payload), + ) + return text, payload + except Exception: + logger.exception("[stt][vosk_error] req_id=%s", req_id) + if self._timeline is not None: + self._timeline.emit("stt_vosk_failed", request_id=req_id) + return "", {} + + async def _post_internal_http( + self, + pcm: bytes, + wav_bytes: bytes, + req_id: str, + started_ns: int, + language: Optional[str], + message_id: str = "", + audio_duration_ms: float | int | None = None, + original_audio_duration_ms: float | int | None = None, + input_padding_ms: float | int | None = None, + level_dbfs: float | None = None, + ) -> SpeechEvent: + files = {"file": ("audio.wav", wav_bytes, "audio/wav")} + headers = {"x-api-key": self._cfg.api_key, "Connection": "close"} + data = {"override_config": self._build_override_config_json()} + timeout = httpx.Timeout(self._cfg.timeout_s) + + if self._timeline is not None: + self._timeline.emit( + "stt_http_started", + request_id=req_id, + url=self._cfg.url, + ) + log_flow_event( + self._structured_logger or logger, + "stt_dispatch", + request_id=req_id, + engine="http", + audio_ms=round(float(audio_duration_ms)) if audio_duration_ms is not None else None, + dbfs=round(float(level_dbfs), 2) if level_dbfs is not None else None, + ) + attempt = 0 + try: + while True: + t0 = time.perf_counter() + r = await self._client.post( + self._cfg.url, + files=files, + data=data, + headers=headers, + timeout=timeout, + ) + dt_ms = (time.perf_counter() - t0) * 1000.0 + try: + r.raise_for_status() + finished_ns = time.time_ns() + break + except httpx.HTTPStatusError as e: + http_status = getattr(e.response, "status_code", None) + if http_status == 500 and attempt < _HTTP_500_MAX_RETRIES: + attempt += 1 + logger.warning( + "[stt][http_500_retry] req_id=%s attempt=%s/%s took=%.0fms url=%s", + req_id, + attempt, + _HTTP_500_MAX_RETRIES, + dt_ms, + str(getattr(e.request, "url", self._cfg.url)), + ) + log_flow_event( + self._structured_logger or logger, + "stt_http_retry", + request_id=req_id, + engine="http", + status=http_status, + attempt=attempt, + max_retries=_HTTP_500_MAX_RETRIES, + duration_ms=round(dt_ms), + ) + if self._timeline is not None: + self._timeline.emit( + "stt_http_retry", + request_id=req_id, + status=http_status, + attempt=attempt, + max_retries=_HTTP_500_MAX_RETRIES, + duration_ms=round(dt_ms), + ) + continue + raise + except httpx.HTTPStatusError as e: + dt_ms = (time.perf_counter() - t0) * 1000.0 + http_status = getattr(e.response, "status_code", None) + http_desc = str(getattr(e.response, "reason_phrase", "") or "").strip() or None + body_preview = "" + try: + body_preview = (e.response.text or "")[:800] + except Exception: + pass + logger.error( + "[stt][http_error] req_id=%s status=%s took=%.0fms url=%s body(800)=%s", + req_id, + getattr(e.response, "status_code", "NA"), + dt_ms, + str(getattr(e.request, "url", self._cfg.url)), + body_preview, + ) + log_flow_event( + self._structured_logger or logger, + "stt_error", + request_id=req_id, + engine="http", + status=http_status or "NA", + duration_ms=round(dt_ms), + ) + if self._timeline is not None: + self._timeline.emit( + "stt_http_failed", + request_id=req_id, + status=getattr(e.response, "status_code", "NA"), + duration_ms=round(dt_ms), + ) + self._log_recebimento_msg( + request_id=req_id, + started_ns=started_ns, + message_id=message_id, + erro_msg="Falha STT", + erro_detalhe=( + f"http_status={http_status if http_status is not None else 'NA'} " + f"| url={str(getattr(e.request, 'url', self._cfg.url))} " + f"| body={body_preview}" + ), + http_cod_status=http_status, + http_cod_desc=http_desc, + ) + self._notify_metrics( + event="failed", + request_id=req_id, + provider="internal_http", + duration_ms=round(dt_ms), + audio_duration_ms=round(float(audio_duration_ms)) if audio_duration_ms is not None else None, + original_audio_duration_ms=( + round(float(original_audio_duration_ms)) + if original_audio_duration_ms is not None + else None + ), + input_padding_ms=round(float(input_padding_ms)) if input_padding_ms is not None else None, + input_dbfs=round(float(level_dbfs), 2) if level_dbfs is not None else None, + retry_count=attempt, + http_status=http_status, + error="http_error", + ) + if http_status == 500: + log_flow_event( + self._structured_logger or logger, + "stt_error_nonfatal", + request_id=req_id, + engine="http", + status=http_status, + action="return_empty_transcript", + max_retries=_HTTP_500_MAX_RETRIES, + ) + if self._timeline is not None: + self._timeline.emit( + "stt_http_failed_nonfatal", + request_id=req_id, + status=http_status, + max_retries=_HTTP_500_MAX_RETRIES, + ) + return SpeechEvent( + type=SpeechEventType.FINAL_TRANSCRIPT, + request_id=req_id, + alternatives=[ + SpeechData( + language=language or self._cfg.language, + text="", + confidence=0.0, + ) + ], + ) + raise + except Exception: + dt_ms = (time.perf_counter() - t0) * 1000.0 + logger.exception("[stt][error] req_id=%s took=%.0fms", req_id, dt_ms) + log_flow_event( + self._structured_logger or logger, + "stt_error", + request_id=req_id, + engine="http", + status="exception", + duration_ms=round(dt_ms), + ) + if self._timeline is not None: + self._timeline.emit( + "stt_http_failed", + request_id=req_id, + status="exception", + duration_ms=round(dt_ms), + ) + self._log_recebimento_msg( + request_id=req_id, + started_ns=started_ns, + message_id=message_id, + erro_msg="Falha STT", + erro_detalhe=f"exception | took={round(dt_ms)}ms", + ) + self._notify_metrics( + event="failed", + request_id=req_id, + provider="internal_http", + duration_ms=round(dt_ms), + audio_duration_ms=round(float(audio_duration_ms)) if audio_duration_ms is not None else None, + original_audio_duration_ms=( + round(float(original_audio_duration_ms)) + if original_audio_duration_ms is not None + else None + ), + input_padding_ms=round(float(input_padding_ms)) if input_padding_ms is not None else None, + input_dbfs=round(float(level_dbfs), 2) if level_dbfs is not None else None, + retry_count=attempt, + http_status=None, + error="exception", + ) + raise + + payload = r.json() + payload_json = _json_for_log(payload) + logger.info( + "[stt][response_json] req_id=%s status=%s took=%.0fms json=%s", + req_id, + r.status_code, + dt_ms, + payload_json, + ) + out_text = self._format_stt_output(payload, request_id=req_id) + if self._timeline is not None: + self._timeline.emit( + "stt_http_completed", + request_id=req_id, + duration_ms=round(dt_ms), + text=out_text, + text_len=len(out_text), + ) + empty_transcript = not bool(extract_text_from_transcript(out_text)) + self._notify_metrics( + event="completed", + request_id=req_id, + provider="internal_http", + mode=self._stt_output_mode(), + duration_ms=round(dt_ms), + audio_duration_ms=round(float(audio_duration_ms)) if audio_duration_ms is not None else None, + original_audio_duration_ms=( + round(float(original_audio_duration_ms)) + if original_audio_duration_ms is not None + else None + ), + input_padding_ms=round(float(input_padding_ms)) if input_padding_ms is not None else None, + input_dbfs=round(float(level_dbfs), 2) if level_dbfs is not None else None, + retry_count=attempt, + http_status=r.status_code, + empty_transcript=empty_transcript, + text_length=len(extract_text_from_transcript(out_text)), + ) + if empty_transcript: + # Um resultado sem texto nao constitui turno de conversa. Nao publique + # recebimento msg nem deixe o identificador tecnico do upload ocupar o + # estado de message_id ativo, pois uma fala proativa do agente poderia + # herda-lo antes da chegada de user_input_transcribed. + clear_started_turn_message_id( + self._structured_log_context, + message_id=message_id, + ) + else: + message_id = self._log_recebimento_msg( + request_id=req_id, + started_ns=started_ns, + message_id=message_id, + finished_ns=finished_ns, + erro_detalhe=None, + http_cod_status=r.status_code, + http_cod_desc=str(getattr(r, "reason_phrase", "") or "").strip() or None, + ) + self._register_transcribed_turn(message_id=message_id, transcription=out_text) + self._log_detected_text( + request_id=req_id, + output_text=out_text, + engine="http", + duration_ms=dt_ms, + audio_duration_ms=audio_duration_ms, + level_dbfs=level_dbfs, + ) + if empty_transcript: + self._notify_empty_transcript(request_id=req_id, source="http_success") + + # log reduzido + preview = out_text if len(out_text) < 250 else out_text[:250] + "..." + + return SpeechEvent( + type=SpeechEventType.FINAL_TRANSCRIPT, + request_id=req_id, + alternatives=[SpeechData(language=language or self._cfg.language, text=out_text, confidence=0.0)], + ) + + async def aclose(self) -> None: + return + + def _frames_to_pcm16le_16k(self, buffer) -> bytes: + """ + Junta frames e garante PCM16 mono 16k (re-amostra se precisar). + """ + if isinstance(buffer, rtc.AudioFrame): + frames = [buffer] + else: + frames = list(buffer) + + if not frames: + return b"" + + in_sr = frames[0].sample_rate + in_ch = frames[0].num_channels + + target_sr = self._cfg.sample_rate + target_ch = self._cfg.channels + + # resample se necessário + if in_sr != target_sr: + if self._resampler is None: + self._resampler = rtc.AudioResampler(in_sr, target_sr, quality=rtc.AudioResamplerQuality.HIGH) + out_frames = [] + for f in frames: + out_frames.extend(self._resampler.push(f)) + out_frames.extend(self._resampler.flush()) + frames = out_frames + + # canais (se precisar no futuro, faz stereo->mono aqui) + if in_ch != target_ch: + pass + + return b"".join(bytes(f.data) for f in frames) + + @timed("STT") + async def _recognize_impl( + self, + buffer, + *, + language: str | None, + conn_options: APIConnectOptions, + ) -> SpeechEvent: + started_ns = time.time_ns() + pcm = self._frames_to_pcm16le_16k(buffer) + req_id = uuid.uuid4().hex + + min_ms = int(os.getenv("STT_MIN_AUDIO_MS", "160")) + min_dbfs = float(os.getenv("STT_MIN_DBFS", "-50.0")) + + dur_ms = pcm_duration_ms(pcm, self._cfg.sample_rate, self._cfg.channels) + level_dbfs = dbfs_pcm16le(pcm) + if self._timeline is not None: + self._timeline.emit( + "stt_recognize_started", + request_id=req_id, + duration_ms=round(dur_ms), + level_dbfs=round(level_dbfs, 2), + ) + if not pcm or dur_ms < min_ms or level_dbfs < min_dbfs: + if (os.getenv("FLOW_LOG_STT_SKIPS", "0") or "0").strip() == "1": + log_flow_event( + self._structured_logger or logger, + "stt_skip", + request_id=req_id, + reason="too_short_or_silent", + audio_ms=round(dur_ms), + dbfs=round(level_dbfs, 2), + ) + if self._timeline is not None: + self._timeline.emit( + "stt_recognize_skipped", + request_id=req_id, + reason="too_short_or_silent", + duration_ms=round(dur_ms), + level_dbfs=round(level_dbfs, 2), + ) + return SpeechEvent( + type=SpeechEventType.FINAL_TRANSCRIPT, + request_id=req_id, + alternatives=[SpeechData(language=language or self._cfg.language, text="", confidence=0.0)], + ) + log_flow_event( + self._structured_logger or logger, + "stt_start", + request_id=req_id, + audio_ms=round(dur_ms), + dbfs=round(level_dbfs, 2), + ) + + # tenta Vosk para trechos curtos + vosk_max_ms = int(os.getenv("STT_VOSK_MAX_MS", "2000")) + if dur_ms < vosk_max_ms: + vosk_started = time.perf_counter() + txt_vosk, payload_vosk = await self._try_vosk(pcm, req_id=req_id) + vosk_duration_ms = (time.perf_counter() - vosk_started) * 1000.0 + + if payload_vosk: + mode = self._stt_output_mode() + + if mode == "raw_json": + payload_vosk = apply_single_word_rewrite_for_raw_json(payload_vosk) + payload_vosk["STT"] = "Vosk" + out_text = json.dumps(payload_vosk, ensure_ascii=False) + if self._timeline is not None: + self._timeline.emit( + "stt_recognize_completed", + request_id=req_id, + engine="vosk", + mode=mode, + text=out_text, + text_len=len(out_text), + ) + message_id = self._log_recebimento_msg( + request_id=req_id, + started_ns=started_ns, + ) + self._register_transcribed_turn(message_id=message_id, transcription=out_text) + self._log_detected_text( + request_id=req_id, + output_text=out_text, + engine="vosk", + mode=mode, + duration_ms=vosk_duration_ms, + audio_duration_ms=dur_ms, + level_dbfs=level_dbfs, + ) + return SpeechEvent( + type=SpeechEventType.FINAL_TRANSCRIPT, + request_id=req_id, + alternatives=[SpeechData(language=language or self._cfg.language, text=out_text, confidence=0.0)], + ) + + if mode == "api_text": + data = payload_vosk.get("data") if isinstance(payload_vosk.get("data"), dict) else {} + out_text = (data.get("text") or "").strip() + if out_text: + if self._timeline is not None: + self._timeline.emit( + "stt_recognize_completed", + request_id=req_id, + engine="vosk", + mode=mode, + text=out_text, + text_len=len(out_text), + ) + message_id = self._log_recebimento_msg( + request_id=req_id, + started_ns=started_ns, + ) + self._register_transcribed_turn(message_id=message_id, transcription=out_text) + self._log_detected_text( + request_id=req_id, + output_text=out_text, + engine="vosk", + mode=mode, + duration_ms=vosk_duration_ms, + audio_duration_ms=dur_ms, + level_dbfs=level_dbfs, + ) + return SpeechEvent( + type=SpeechEventType.FINAL_TRANSCRIPT, + request_id=req_id, + alternatives=[SpeechData(language=language or self._cfg.language, text=out_text, confidence=0.0)], + ) + + # threshold_text + filtered = stt_text_with_single_word_threshold(payload_vosk, self._cfg.min_prob_single_word) + if filtered: + if self._timeline is not None: + self._timeline.emit( + "stt_recognize_completed", + request_id=req_id, + engine="vosk", + mode="threshold_text", + text=filtered, + text_len=len(filtered), + ) + message_id = self._log_recebimento_msg( + request_id=req_id, + started_ns=started_ns, + ) + self._register_transcribed_turn(message_id=message_id, transcription=filtered) + self._log_detected_text( + request_id=req_id, + output_text=filtered, + engine="vosk", + mode="threshold_text", + duration_ms=vosk_duration_ms, + audio_duration_ms=dur_ms, + level_dbfs=level_dbfs, + ) + return SpeechEvent( + type=SpeechEventType.FINAL_TRANSCRIPT, + request_id=req_id, + alternatives=[SpeechData(language=language or self._cfg.language, text=filtered, confidence=0.0)], + ) + + input_padding_ms = _env_non_negative_int("STT_INPUT_PREFIX_PADDING_MS", 250) + http_pcm, applied_input_padding_ms = _prepend_pcm16le_silence( + pcm, + sample_rate=self._cfg.sample_rate, + channels=self._cfg.channels, + padding_ms=input_padding_ms, + ) + http_dur_ms = pcm_duration_ms(http_pcm, self._cfg.sample_rate, self._cfg.channels) + http_level_dbfs = dbfs_pcm16le(http_pcm) + if applied_input_padding_ms: + log_flow_event( + self._structured_logger or logger, + "stt_input_padding", + request_id=req_id, + padding_ms=applied_input_padding_ms, + original_audio_ms=round(dur_ms), + sent_audio_ms=round(http_dur_ms), + ) + if self._timeline is not None: + self._timeline.emit( + "stt_input_padding", + request_id=req_id, + padding_ms=applied_input_padding_ms, + original_audio_ms=round(dur_ms), + sent_audio_ms=round(http_dur_ms), + ) + + wav_buf = io.BytesIO() + with wave.open(wav_buf, "wb") as wf: + wf.setnchannels(self._cfg.channels) + wf.setsampwidth(2) + wf.setframerate(self._cfg.sample_rate) + wf.writeframes(http_pcm) + wav_bytes = wav_buf.getvalue() + + _dump_audio_for_debug( + req_id=req_id, + pcm=http_pcm, + wav_bytes=wav_bytes, + sample_rate=self._cfg.sample_rate, + channels=self._cfg.channels, + ) + + message_id = next_turn_message_id(self._structured_log_context) + enqueue_stt_vad_audio_upload( + wav_bytes=wav_bytes, + req_id=req_id, + message_id=message_id, + structured_log_context=self._structured_log_context, + metadata={ + "sample_rate": self._cfg.sample_rate, + "channels": self._cfg.channels, + "original_audio_ms": round(dur_ms), + "sent_audio_ms": round(http_dur_ms), + "original_dbfs": round(level_dbfs, 2), + "sent_dbfs": round(http_level_dbfs, 2), + "input_padding_ms": applied_input_padding_ms, + }, + logger_override=self._structured_logger or logger, + timeline=self._timeline, + ) + + # fallback: API interna + return await self._post_internal_http( + http_pcm, + wav_bytes, + req_id=req_id, + started_ns=started_ns, + language=language, + message_id=message_id, + audio_duration_ms=http_dur_ms, + original_audio_duration_ms=dur_ms, + input_padding_ms=applied_input_padding_ms, + level_dbfs=http_level_dbfs, + ) diff --git a/src/app/providers/stt_vosk.py b/src/app/providers/stt_vosk.py new file mode 100644 index 0000000..9c4b3b0 --- /dev/null +++ b/src/app/providers/stt_vosk.py @@ -0,0 +1,286 @@ +import json +from pathlib import Path +from typing import Optional, Dict, Any, Tuple, List + +import numpy as np +from vosk import Model, KaldiRecognizer, SetLogLevel +SetLogLevel(-1) + +DEFAULT_GRAMMAR = json.dumps([ + "sim", + "não", + "ok", + "cancela", + "cancelar", + "ligar", + "desligar", + + # +150 variações + "claro", + "claro que sim", + "com certeza", + "isso", + "isso mesmo", + "isso aí", + "isso ai", + "exato", + "exatamente", + "perfeito", + "beleza", + "certo", + "certinho", + "tá", + "ta", + "tá certo", + "ta certo", + "tá bom", + "ta bom", + "tudo bem", + "pode", + "pode sim", + "pode ser", + "pode ser sim", + "pode deixar", + "deixa", + "de acordo", + "concordo", + "concordo sim", + "confirmo", + "eu confirmo", + "confirmado", + "positivo", + "quero", + "manda ver", + "interessante", + "legal", + "bom", + + "negativo", + "de jeito nenhum", + "de forma alguma", + "nem pensar", + "não quero", + "não mesmo", + "não pode", + "não dá", + "não dá não", + "não quero não", + "quero não", + "não quero isso", + "não precisa", + "não preciso", + "dispenso", + "para", + "pare", + "parar", + "tá bom do jeito que tá", + + "tá ok", + "ta ok", + "tudo certo", + "tudo ok", + "certo então", + "beleza então", + "entendi", + "entendido", + "entendi sim", + "entendido sim", + "fechou", + "fechado", + "combinado", + "show", + "show de bola", + "top", + "maravilha", + "tranquilo", + "tranquila", + "tá tranquilo", + "ta tranquilo", + + "cancelamento", + "cancele", + "cancela aí", + "cancela ai", + "cancela agora", + "cancelar agora", + "pode cancelar", + "pode cancelar aí", + "pode cancelar ai", + "quero cancelar", + "eu quero cancelar", + "quero o cancelamento", + "faz o cancelamento", + "fazer cancelamento", + "solicitar cancelamento", + "desisto", + "desistir", + "quero desistir", + "para tudo", + "pare tudo", + "interrompe", + "interromper", + "encerra", + "encerrar", + "finaliza", + "caro", + + "liga", + "ligue", + "liga aí", + "liga ai", + "liga agora", + "ligar agora", + "pode ligar", + "pode ligar aí", + "pode ligar ai", + "pode ligar agora", + "conecta", + "conectar", + "conecte", + "inicia", + "iniciar", + "inicie", + "ativar", + "ativa", + "ative", + "habilitar", + + "desliga", + "desligue", + "desliga aí", + "desliga ai", + "desliga agora", + "desligar agora", + "pode desligar", + "pode desligar aí", + "pode desligar ai", + "pode desligar agora", + "desconecta", + "desconectar", + "desconecte", + "encerra a ligação", + "finaliza a ligação", + "desativar", + "desativa", + "desative" +], ensure_ascii=False) + + +class VoskSTT: + """ + STT com Vosk para CPU. + + Retorna SEMPRE: + {"data": {"text": "...", "words": [{"word","start","end","probability"}]}} + + Observação: + - Vosk retorna confiança por palavra em "conf" dentro de "result". + - A gente converte "conf" -> "probability". + """ + + def __init__( + self, + model_path: str, + sample_rate: int = 16000, + grammar_json: Optional[str] = DEFAULT_GRAMMAR, + ): + mp = Path(model_path) + if not mp.exists(): + raise FileNotFoundError(f"Vosk model_path não encontrado: {model_path}") + + self.sample_rate = sample_rate + self.grammar_json = grammar_json + self.model = Model(str(mp)) + + def transcribe_pcm16le(self, pcm_bytes: bytes) -> Dict[str, Any]: + """ + Recebe bytes PCM16LE mono (16k) e retorna dict no formato padrão. + """ + if not pcm_bytes: + return {"data": {"text": "", "words": []}} + + pcm16 = np.frombuffer(pcm_bytes, dtype=np.int16) # zero-copy + return self.recognize_pcm16(pcm16) + + def recognize_pcm16(self, pcm16: np.ndarray) -> Dict[str, Any]: + pcm16 = np.asarray(pcm16) + + if pcm16.dtype != np.int16: + raise TypeError(f"pcm16 must be int16, got {pcm16.dtype}") + + if pcm16.ndim == 2: + if pcm16.shape[1] != 1: + raise ValueError("Only mono supported; pass a single channel.") + pcm16 = pcm16[:, 0] + + # usa grammar se configurado + if self.grammar_json: + rec = KaldiRecognizer(self.model, self.sample_rate, self.grammar_json) + else: + rec = KaldiRecognizer(self.model, self.sample_rate) + + rec.SetWords(True) + rec.AcceptWaveform(pcm16.tobytes()) + raw = json.loads(rec.FinalResult()) + + return self.to_data_words_format(raw) + + @staticmethod + def to_data_words_format(raw: Dict[str, Any]) -> Dict[str, Any]: + """ + Converte o retorno do Vosk (raw) para: + {"data": {"text": "...", "words": [...]}} + """ + if not isinstance(raw, dict): + return {"data": {"text": str(raw), "words": []}} + + text = (raw.get("text") or "").strip() + words_out: List[Dict[str, Any]] = [] + + # formato padrão do Vosk com SetWords(True) + # {"text":"...", "result":[{"word","start","end","conf"}]} + if isinstance(raw.get("result"), list): + for w in raw["result"]: + if not isinstance(w, dict): + continue + words_out.append({ + "word": w.get("word", ""), + "start": float(w.get("start", 0.0) or 0.0), + "end": float(w.get("end", 0.0) or 0.0), + "probability": float(w.get("conf", w.get("probability", 0.0)) or 0.0), + }) + # fallback: se vier "words" já no formato desejado + elif isinstance(raw.get("words"), list): + for w in raw["words"]: + if not isinstance(w, dict): + continue + words_out.append({ + "word": w.get("word", ""), + "start": float(w.get("start", 0.0) or 0.0), + "end": float(w.get("end", 0.0) or 0.0), + "probability": float(w.get("probability", 0.0) or 0.0), + }) + + return {"data": {"text": text, "words": words_out}} + + @staticmethod + def result_to_text(res: Dict[str, Any]) -> str: + """ + Retorna apenas o texto do payload padronizado (ou do raw). + """ + if not isinstance(res, dict): + return str(res) + if isinstance(res.get("data"), dict): + t = res["data"].get("text") + return t.strip() if isinstance(t, str) else "" + t = res.get("text") + return t.strip() if isinstance(t, str) else "" + + @staticmethod + def result_to_text_and_payload(res: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]: + """ + Retorna (texto, payload_padronizado). + """ + payload = VoskSTT.to_data_words_format(res) if not (isinstance(res, dict) and isinstance(res.get("data"), dict)) else res + text = VoskSTT.result_to_text(payload) + return text, payload diff --git a/src/app/providers/stt_word_dictionary.py b/src/app/providers/stt_word_dictionary.py new file mode 100644 index 0000000..69d59ae --- /dev/null +++ b/src/app/providers/stt_word_dictionary.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +SHORT_WORD_ALLOWLIST = { + "sim", + "não", + "nao", + "ok", + "tá", + "ta", + "certo", + "isso", + "confirmo", + "positivo", + "negativo", +} + +SHORT_WORD_ALLOWLIST_MIN_PROB = 0.0 diff --git a/src/app/providers/tts.py b/src/app/providers/tts.py new file mode 100644 index 0000000..33dbfc3 --- /dev/null +++ b/src/app/providers/tts.py @@ -0,0 +1,324 @@ +# app/providers/tts.py +from __future__ import annotations + +import os +import math +import struct +from math import ceil +from typing import TYPE_CHECKING, Any, Generator, List, Optional, Tuple + +from app.common.timed import timed + +if TYPE_CHECKING: + from elevenlabs import VoiceSettings + +SAMPLE_RATE = 16_000 +BYTES_PER_SAMPLE = 2 # PCM16 +CHANNELS = 1 + + +def frame_bytes_for_ms( + ms: int, + sr: int = SAMPLE_RATE, + ch: int = CHANNELS, + bps: int = BYTES_PER_SAMPLE, +) -> int: + if ms <= 0: + raise ValueError("frame_ms must be > 0") + samples_per_channel = round(sr * (ms / 1000.0)) + return int(samples_per_channel * ch * bps) + + +def _iter_pcm_frames( + pcm: bytes, + *, + frame_ms: int, + strict_frame_size: bool, +) -> Generator[bytes, None, None]: + if not pcm: + return + yield # pragma: no cover + + if frame_ms % 10 != 0: + raise ValueError("frame_ms should be a multiple of 10ms for telephony pipelines") + + frame_bytes = frame_bytes_for_ms(frame_ms) + offset = 0 + pcm_len = len(pcm) + while offset + frame_bytes <= pcm_len: + yield pcm[offset:offset + frame_bytes] + offset += frame_bytes + + if offset >= pcm_len: + return + + tail = pcm[offset:] + if strict_frame_size: + pad = frame_bytes - len(tail) + if pad > 0: + tail = tail + (b"\x00" * pad) + else: + tail = tail[:frame_bytes] + yield tail + + +def _frames_with_optional_silence( + *, + pcm_frames: Generator[bytes, None, None], + frame_ms: int, + pre_silence_ms: int, + post_silence_ms: int, +) -> List[bytes]: + frame_bytes = frame_bytes_for_ms(frame_ms) + silence_frame = b"\x00" * frame_bytes + frames: List[bytes] = [] + + if pre_silence_ms > 0: + frames.extend([silence_frame] * ceil(pre_silence_ms / frame_ms)) + + for frame in pcm_frames: + if len(frame) != frame_bytes: + if len(frame) < frame_bytes: + frame = frame + (b"\x00" * (frame_bytes - len(frame))) + else: + frame = frame[:frame_bytes] + frames.append(frame) + + if post_silence_ms > 0: + frames.extend([silence_frame] * ceil(post_silence_ms / frame_ms)) + + return frames + + +class TTS: + """ + ElevenLabs TTS -> PCM16 16kHz (mono). + """ + + def __init__( + self, + api_key: str, + voice_id: str, + model_id: str, + *, + voice_settings: Optional["VoiceSettings"] = None, + ) -> None: + if not api_key: + raise ValueError("api_key is required") + if not voice_id: + raise ValueError("voice_id is required") + + elevenlabs_sdk = _load_elevenlabs_sdk() + self.client = elevenlabs_sdk["client_cls"](api_key=api_key) + self.voice_id = voice_id + self.model_id = model_id + self.voice_settings = voice_settings or elevenlabs_sdk["voice_settings_cls"]( + stability=0.45, + speed=1.02, + similarity_boost=0.75, + use_speaker_boost=True, + style=0.7, + ) + + @timed("TTS") + def synthesize_pcm16k(self, text: str) -> bytes: + text = (text or "").strip() + if not text: + return b"" + + stream = self.client.text_to_speech.convert( + voice_id=self.voice_id, + output_format="pcm_16000", + text=text, + model_id=self.model_id, + voice_settings=self.voice_settings, + ) + + out = bytearray() + for chunk in stream: + if chunk: + out.extend(chunk) + return bytes(out) + + @timed("TTS") + def synthesize_frames( + self, + text: str, + frame_ms: int = 20, + *, + strict_frame_size: bool = True, + ) -> Generator[bytes, None, None]: + yield from _iter_pcm_frames( + self.synthesize_pcm16k(text), + frame_ms=frame_ms, + strict_frame_size=strict_frame_size, + ) + + @timed("Intro TTS") + def synthesize_ws_frames( + self, + text: str, + *, + frame_ms: int = 20, + pre_silence_ms: int = 0, + post_silence_ms: int = 0, + ) -> List[bytes]: + text = (text or "").strip() + if not text: + return [] + + return _frames_with_optional_silence( + pcm_frames=self.synthesize_frames(text, frame_ms=frame_ms, strict_frame_size=True), + frame_ms=frame_ms, + pre_silence_ms=pre_silence_ms, + post_silence_ms=post_silence_ms, + ) + + +class FakeTTS: + """ + Provider local para smoke tests. + + Gera um tom PCM16 simples com duracao proporcional ao texto para validar o + pipeline sem depender de servicos externos de TTS. + """ + + def __init__( + self, + *, + tone_hz: int = 440, + amplitude: float = 0.12, + min_duration_ms: int = 320, + max_duration_ms: int = 2200, + char_duration_ms: int = 35, + ) -> None: + self.tone_hz = max(120, int(tone_hz)) + self.amplitude = min(max(float(amplitude), 0.0), 0.95) + self.min_duration_ms = max(80, int(min_duration_ms)) + self.max_duration_ms = max(self.min_duration_ms, int(max_duration_ms)) + self.char_duration_ms = max(5, int(char_duration_ms)) + + def _duration_ms_for_text(self, text: str) -> int: + text = (text or "").strip() + if not text: + return 0 + + estimated = max(self.min_duration_ms, len(text) * self.char_duration_ms) + return min(estimated, self.max_duration_ms) + + def _envelope(self, index: int, total_samples: int) -> float: + fade_samples = max(1, int(SAMPLE_RATE * 0.02)) + if index < fade_samples: + return index / fade_samples + tail_start = max(0, total_samples - fade_samples) + if index >= tail_start: + return max(0.0, (total_samples - index) / fade_samples) + return 1.0 + + @timed("TTS") + def synthesize_pcm16k(self, text: str) -> bytes: + duration_ms = self._duration_ms_for_text(text) + if duration_ms <= 0: + return b"" + + total_samples = max(1, int(SAMPLE_RATE * duration_ms / 1000)) + out = bytearray() + angular = (2.0 * math.pi * self.tone_hz) / SAMPLE_RATE + + for sample_index in range(total_samples): + env = self._envelope(sample_index, total_samples) + value = math.sin(sample_index * angular) * self.amplitude * env + out.extend(struct.pack(" Generator[bytes, None, None]: + yield from _iter_pcm_frames( + self.synthesize_pcm16k(text), + frame_ms=frame_ms, + strict_frame_size=strict_frame_size, + ) + + @timed("Intro TTS") + def synthesize_ws_frames( + self, + text: str, + *, + frame_ms: int = 20, + pre_silence_ms: int = 0, + post_silence_ms: int = 0, + ) -> List[bytes]: + text = (text or "").strip() + if not text: + return [] + + return _frames_with_optional_silence( + pcm_frames=self.synthesize_frames(text, frame_ms=frame_ms, strict_frame_size=True), + frame_ms=frame_ms, + pre_silence_ms=pre_silence_ms, + post_silence_ms=post_silence_ms, + ) + + +def build_tts_provider_from_env( + provider: str, + *, + voice_id: str = "", + model_id: str = "", + language: str = "", +) -> Tuple[object | None, str | None]: + normalized = (provider or "elevenlabs").strip().lower() + + if normalized == "fake": + return ( + FakeTTS( + tone_hz=int(os.getenv("FAKE_TTS_TONE_HZ", "440") or "440"), + amplitude=float(os.getenv("FAKE_TTS_AMPLITUDE", "0.12") or "0.12"), + min_duration_ms=int(os.getenv("FAKE_TTS_MIN_DURATION_MS", "320") or "320"), + max_duration_ms=int(os.getenv("FAKE_TTS_MAX_DURATION_MS", "2200") or "2200"), + char_duration_ms=int(os.getenv("FAKE_TTS_CHAR_DURATION_MS", "35") or "35"), + ), + None, + ) + + if normalized in {"", "elevenlabs"}: + api_key = (os.getenv("ELEVENLABS_API_KEY", "") or "").strip() + resolved_voice_id = (voice_id or os.getenv("ELEVENLABS_VOICE_ID", "")).strip() + resolved_model_id = (model_id or os.getenv("ELEVENLABS_MODEL_ID", "eleven_flash_v2_5")).strip() + if not api_key or not resolved_voice_id: + return None, "missing_elevenlabs_config" + if not _is_elevenlabs_available(): + return None, "missing_elevenlabs_sdk" + return TTS(api_key=api_key, voice_id=resolved_voice_id, model_id=resolved_model_id), None + + return None, f"unsupported_tts_provider:{normalized}" + + +def _load_elevenlabs_sdk() -> dict[str, Any]: + try: + from elevenlabs import VoiceSettings + from elevenlabs.client import ElevenLabs + except ModuleNotFoundError as exc: + raise RuntimeError( + "ElevenLabs SDK is not installed. Install dependency `elevenlabs` to enable this provider." + ) from exc + + return { + "voice_settings_cls": VoiceSettings, + "client_cls": ElevenLabs, + } + + +def _is_elevenlabs_available() -> bool: + try: + _load_elevenlabs_sdk() + except RuntimeError: + return False + return True diff --git a/src/app/services/session_context.py b/src/app/services/session_context.py new file mode 100644 index 0000000..6cc1f95 --- /dev/null +++ b/src/app/services/session_context.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import Any, Mapping + + +def _pick_value(payload: Mapping[str, Any] | None, *keys: str) -> str: + if not isinstance(payload, Mapping): + return "" + + for key in keys: + value = payload.get(key) + if value not in (None, ""): + return str(value).strip() + + lowered = {str(key).lower(): value for key, value in payload.items()} + for key in keys: + value = lowered.get(str(key).lower()) + if value not in (None, ""): + return str(value).strip() + + return "" + + +def extract_protocol( + start_payload: Mapping[str, Any] | None, + session_data: Mapping[str, Any] | None = None, +) -> str: + data = start_payload.get("data") if isinstance(start_payload, Mapping) else {} + if not isinstance(data, Mapping): + data = {} + + protocol = _pick_value( + data, + "protocol_id", + "protocolId", + "protocolo", + "PROTOCOLO", + "protocol", + "Protocol", + "routerCallKey", + "RouterCallKey", + "router_call_key", + ) + if protocol: + return protocol + + return _pick_value( + session_data, + "protocol_id", + "protocolId", + "protocolo", + "PROTOCOLO", + "protocol", + "Protocol", + "routerCallKey", + "RouterCallKey", + "router_call_key", + ) diff --git a/src/app/services/text_pipeline.py b/src/app/services/text_pipeline.py new file mode 100644 index 0000000..6339db2 --- /dev/null +++ b/src/app/services/text_pipeline.py @@ -0,0 +1,162 @@ +import asyncio +import json +import logging +import time + +from fastapi import WebSocket, WebSocketDisconnect + +from agent.pipeline.customer_pipeline_langgraph import CustomerPipeline + +from app.services.session_context import extract_protocol + +logger = logging.getLogger(__name__) + + +class TextSessionPipeline: + """ + Pipeline puramente texto: + - envia "ready" + - envia a abertura do agente via agent.start() + - para cada mensagem de texto recebida, responde com agent.run(text) + Protocolo sugerido (JSON): + -> {"type":"start", "data": {...}} # primeiro pacote + -> {"type":"text", "text":"..."} # mensagens do usuário + -> {"type":"stop"} # encerra + Eventos enviados pelo servidor: + <- {"type":"ready", "session_id": "..."} + <- {"type":"agent_start", "text": "..."} + <- {"type":"agent_reply", "text": "..."} + <- {"type":"bye"} + <- {"type":"error","message":"..."} + """ + def __init__( + self, + ws: WebSocket, + start_payload: dict, + session_id: str, + *, + session_data: dict, + intro: str, + ): + self.ws = ws + self.start_payload = start_payload or {} + self.session_id = session_id + self.session_data = dict(session_data or {}) + self.intro = (intro or "").strip() + self.protocol = extract_protocol(self.start_payload, self.session_data) + self.agent = CustomerPipeline(self.session_data) + self._close = False + + async def send_json(self, payload: dict): + await self.ws.send_text(json.dumps(payload, ensure_ascii=False)) + + async def run(self): + # 1) sinaliza pronto + await self.send_json({"type": "ready", "session_id": self.session_id}) + + # 2) mensagem inicial do agente + stage = "" + try: + stage, opening = self.agent.start() + except Exception as e: + logger.exception("Erro no agent.start()") + await self.send_json({"type": "error", "message": f"start_failed: {e}"}) + opening = None + + if not opening: + opening = self.intro or None + + if opening: + logger.info( + "TEXT_AGENT_START | session_id=%s | stage=%s | text=%r", + self.session_id, + stage, + opening, + ) + await self.send_json({"type": "agent_start", "text": opening}) + + self.agent.prepare(True, self.protocol) + # 3) loop de interação + while not self._close: + try: + msg = await asyncio.wait_for(self.ws.receive(), timeout=300) + except asyncio.TimeoutError: + # mantém conexão viva (opcional) + try: + await self.ws.send_text("keepalive") + except Exception: + pass + continue + except WebSocketDisconnect: + logger.info(f"Cliente desconectou: {self.session_id}") + break + except Exception as e: + logger.exception("Erro no loop text ws") + await self.send_json({"type":"error", "message": str(e)}) + break + + if msg.get("type") == "websocket.disconnect": + break + + if msg.get("type") != "websocket.receive": + continue + + # Só tratamos conteúdo textual nessa rota + raw_text = msg.get("text") + if not raw_text: + # ignora binários nesta rota + continue + + # Aceita texto puro ou JSON + try: + data = json.loads(raw_text) + except Exception: + data = {"type": "text", "text": raw_text} + + mtype = data.get("type") + if mtype in ("stop", "bye"): + await self.send_json({"type": "bye"}) + break + + if mtype in ("text", "user_input", "message"): + text = (data.get("text") or "").strip() + if not text: + continue + logger.info( + "TEXT_USER | session_id=%s | protocol=%s | text=%r", + self.session_id, + self.protocol, + text, + ) + + try: + agent_started = time.perf_counter() + stage, reply = self.agent.run(text) + agent_duration_ms = round((time.perf_counter() - agent_started) * 1000) + self.agent.set_interruption(was_interrupted=False) + #print(f"stage <> {stage}") + self._close = str(stage) in ("Concluído", "DONE", "DONE_CONCLUIDO") + + + except Exception as e: + logger.exception("Erro no agent.run()") + await self.send_json({"type": "error", "message": f"run_failed: {e}"}) + continue + + logger.info( + "TEXT_AGENT_REPLY | session_id=%s | stage=%s | close=%s | duration_ms=%s | text=%r", + self.session_id, + stage, + self._close, + agent_duration_ms, + reply, + ) + await self.send_json({"type": "agent_reply", "text": reply}) + if(self._close): break + else: + await self.send_json({"type": "error", "message": f"unknown_type:{mtype}"}) + try: + await self.ws.close() + output = await self.agent._end_service() + except Exception: + logger.exception("Erro ao encerrar pipeline de texto") diff --git a/src/app/services/text_pipeline_stream.py b/src/app/services/text_pipeline_stream.py new file mode 100644 index 0000000..6aa148f --- /dev/null +++ b/src/app/services/text_pipeline_stream.py @@ -0,0 +1,195 @@ +import asyncio +import json +import logging +import time + +from fastapi import WebSocket, WebSocketDisconnect + +from agent.pipeline.pipeline_streaming_text import CustomerPipeline +from app.services.session_context import extract_protocol + +logger = logging.getLogger(__name__) + + +class TextSessionPipelineStream: + """ + Pipeline para comunicação texto via WebSocket com streaming token a token. + + Protocolo sugerido (JSON): + -> {"type":"start", "data": {...}} + -> {"type":"text", "text":"..."} + -> {"type":"stop"} + + Eventos enviados pelo servidor: + <- {"type":"ready", "session_id": "..."} + <- {"type":"agent_start", "text": "..."} + <- {"type":"agent_reply_chunk", "delta": "token"} + <- {"type":"agent_reply_end", "text": "resposta completa"} + <- {"type":"bye"} + <- {"type":"error","message":"..."} + """ + + def __init__( + self, + ws: WebSocket, + start_payload: dict, + session_id: str, + *, + session_data: dict | None = None, + intro: str = "", + ): + self.ws = ws + self.start_payload = start_payload or {} + self.session_id = session_id + self.session_data = dict(session_data or {}) + self.intro = (intro or "").strip() + self.protocol = extract_protocol(self.start_payload, self.session_data) + self.agent = CustomerPipeline() + self._close = False + + async def send_json(self, payload: dict): + await self.ws.send_text(json.dumps(payload, ensure_ascii=False)) + + # ============================================================ + # STREAMING TOKEN-A-TOKEN + # ============================================================ + + async def _to_async_gen(self, generator): + """Transforma generator síncrono em assíncrono.""" + for item in generator: + yield item + + async def stream_reply(self, text: str): + """ + Envia resposta token-a-token via WebSocket: + - {"type":"agent_reply_chunk", "delta": token} + - {"type":"agent_reply_end", "text": full_response} + """ + try: + agent_started = time.perf_counter() + token_generator = self.agent.run(text) + except Exception as e: + await self.send_json({"type": "error", "message": f"stream_init_failed: {e}"}) + return + + full_text = "" + + try: + async for token in self._to_async_gen(token_generator): + full_text += token + await self.send_json({"type": "agent_reply_chunk", "delta": token}) + await asyncio.sleep(0) + except Exception as e: + await self.send_json({"type": "error", "message": f"stream_failed: {e}"}) + return + + # Finaliza streaming + agent_duration_ms = round((time.perf_counter() - agent_started) * 1000) + logger.info( + "TEXT_STREAM_AGENT_REPLY | session_id=%s | duration_ms=%s | text=%r", + self.session_id, + agent_duration_ms, + full_text, + ) + await self.send_json({ + "type": "agent_reply_end", + "text": full_text + }) + + # ============================================================ + + async def run(self): + # 1) envia ready + await self.send_json({"type": "ready", "session_id": self.session_id}) + + # 2) mensagem inicial do agente + stage = "" + try: + stage, opening = self.agent.start() + except Exception as e: + logger.exception("Erro no agent.start()") + await self.send_json({"type": "error", "message": f"start_failed: {e}"}) + opening = None + + if not opening: + opening = self.intro or None + + if opening: + logger.info( + "TEXT_STREAM_AGENT_START | session_id=%s | stage=%s | text=%r", + self.session_id, + stage, + opening, + ) + await self.send_json({"type": "agent_start", "text": opening}) + + self.agent.prepare(True, self.protocol) + + # 3) loop WS + while not self._close: + try: + msg = await asyncio.wait_for(self.ws.receive(), timeout=300) + except asyncio.TimeoutError: + # ping opcional + try: + await self.ws.send_text("keepalive") + except Exception: + pass + continue + except WebSocketDisconnect: + logger.info(f"Cliente desconectou: {self.session_id}") + break + except Exception as e: + logger.exception("Erro no loop text ws") + await self.send_json({"type": "error", "message": str(e)}) + break + + if msg.get("type") == "websocket.disconnect": + break + + if msg.get("type") != "websocket.receive": + continue + + raw_text = msg.get("text") + if not raw_text: + continue + + # aceita JSON ou texto puro + try: + data = json.loads(raw_text) + except Exception: + data = {"type": "text", "text": raw_text} + + mtype = data.get("type") + + if mtype in ("stop", "bye"): + await self.send_json({"type": "bye"}) + break + + if mtype in ("text", "user_input", "message"): + text = (data.get("text") or "").strip() + if not text: + continue + logger.info( + "TEXT_STREAM_USER | session_id=%s | protocol=%s | text=%r", + self.session_id, + self.protocol, + text, + ) + + # STREAMING TOKEN A TOKEN + await self.stream_reply(text) + + # depois do streaming, atualiza stage + try: + self._close = str(self.agent.stage) in ("Concluído", "DONE", "DONE_CONCLUIDO") + except Exception as e: + await self.send_json({"type": "error", "message": f"run_failed: {e}"}) + + else: + await self.send_json({"type": "error", "message": f"unknown_type:{mtype}"}) + + try: + await self.ws.close() + except Exception: + pass diff --git a/src/app/tools/__init__.py b/src/app/tools/__init__.py new file mode 100644 index 0000000..80fe0d2 --- /dev/null +++ b/src/app/tools/__init__.py @@ -0,0 +1 @@ +"""Operational tooling entrypoints for the TIA application.""" diff --git a/src/app/tools/local_stresstest/__init__.py b/src/app/tools/local_stresstest/__init__.py new file mode 100644 index 0000000..af61387 --- /dev/null +++ b/src/app/tools/local_stresstest/__init__.py @@ -0,0 +1,10 @@ +"""Local operational stress test for STT, TTS, Bridge, and LiveKit.""" + +from app.tools.local_stresstest.text import TextComparison, compare_text, normalize_text, word_error_rate + +__all__ = [ + "TextComparison", + "compare_text", + "normalize_text", + "word_error_rate", +] diff --git a/src/app/tools/local_stresstest/__main__.py b/src/app/tools/local_stresstest/__main__.py new file mode 100644 index 0000000..93ee8ee --- /dev/null +++ b/src/app/tools/local_stresstest/__main__.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import asyncio + +from app.tools.local_stresstest.runner import run + + +def main() -> None: + raise SystemExit(asyncio.run(run())) + + +if __name__ == "__main__": + main() diff --git a/src/app/tools/local_stresstest/audio.py b/src/app/tools/local_stresstest/audio.py new file mode 100644 index 0000000..8c4728e --- /dev/null +++ b/src/app/tools/local_stresstest/audio.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +import audioop +import math +import random +import wave +from dataclasses import dataclass +from pathlib import Path + + +TARGET_SAMPLE_RATE = 16_000 +TARGET_CHANNELS = 1 +SAMPLE_WIDTH = 2 +FRAME_MS = 20 + + +@dataclass(frozen=True, slots=True) +class AudioSample: + name: str + pcm: bytes + sample_rate: int = TARGET_SAMPLE_RATE + channels: int = TARGET_CHANNELS + + +@dataclass(frozen=True, slots=True) +class AudioMetrics: + duration_ms: int + rms_dbfs: float + peak_dbfs: float + clipping_ratio: float + bytes_len: int + + @property + def audible(self) -> bool: + return self.rms_dbfs > -55.0 + + +@dataclass(frozen=True, slots=True) +class VADProxyMetrics: + threshold_dbfs: float + prefix_padding_ms: int + min_speech_ms: int + first_voice_ms: int + last_voice_ms: int + speech_ms: int + padded_start_ms: int + unrecovered_prefix_ms: int + initial_200ms_dbfs: float + initial_500ms_dbfs: float + low_start_risk: bool + + +def _dbfs(value: float) -> float: + if value <= 0: + return -120.0 + return 20.0 * math.log10(value / 32768.0) + + +def _samples_from_pcm(pcm: bytes) -> list[int]: + if not pcm: + return [] + count = len(pcm) // SAMPLE_WIDTH + return list(audioop.getsample(pcm, SAMPLE_WIDTH, idx) for idx in range(count)) + + +def _pcm_from_samples(samples: list[int]) -> bytes: + out = bytearray() + for value in samples: + clipped = max(-32768, min(32767, int(round(value)))) + out.extend(clipped.to_bytes(2, byteorder="little", signed=True)) + return bytes(out) + + +def _silence_pcm(sample_rate: int, duration_ms: int) -> bytes: + return b"\x00" * round(sample_rate * duration_ms / 1000) * SAMPLE_WIDTH + + +def _split_pcm_at_ms(pcm: bytes, *, sample_rate: int, split_ms: int) -> tuple[bytes, bytes]: + byte_count = max(0, round(sample_rate * split_ms / 1000) * SAMPLE_WIDTH) + byte_count -= byte_count % SAMPLE_WIDTH + return pcm[:byte_count], pcm[byte_count:] + + +def audio_metrics(pcm: bytes, *, sample_rate: int = TARGET_SAMPLE_RATE, channels: int = TARGET_CHANNELS) -> AudioMetrics: + samples = _samples_from_pcm(pcm) + if not samples: + return AudioMetrics(duration_ms=0, rms_dbfs=-120.0, peak_dbfs=-120.0, clipping_ratio=0.0, bytes_len=0) + + rms = audioop.rms(pcm, SAMPLE_WIDTH) + peak = max(abs(item) for item in samples) + clipped = sum(1 for item in samples if abs(item) >= 32760) + sample_count_per_channel = len(samples) / max(1, channels) + duration_ms = round((sample_count_per_channel / max(1, sample_rate)) * 1000) + return AudioMetrics( + duration_ms=duration_ms, + rms_dbfs=round(_dbfs(rms), 2), + peak_dbfs=round(_dbfs(peak), 2), + clipping_ratio=round(clipped / len(samples), 6), + bytes_len=len(pcm), + ) + + +def _window_dbfs( + pcm: bytes, + *, + sample_rate: int = TARGET_SAMPLE_RATE, + duration_ms: int, +) -> float: + byte_count = max(0, round(sample_rate * duration_ms / 1000) * SAMPLE_WIDTH) + byte_count -= byte_count % SAMPLE_WIDTH + window = pcm[:byte_count] if byte_count else b"" + if not window: + return -120.0 + return round(_dbfs(audioop.rms(window, SAMPLE_WIDTH)), 2) + + +def vad_proxy_metrics( + sample: AudioSample, + *, + threshold_dbfs: float = -45.0, + prefix_padding_ms: int = 1000, + min_speech_ms: int = 100, + frame_ms: int = FRAME_MS, +) -> VADProxyMetrics: + frame_bytes = max(1, round(sample.sample_rate * frame_ms / 1000)) * SAMPLE_WIDTH + threshold_rms = int(32768 * (10 ** (threshold_dbfs / 20.0))) + active_frames: list[int] = [] + total_frames = max(1, math.ceil(len(sample.pcm) / frame_bytes)) + for frame_idx in range(total_frames): + frame = sample.pcm[frame_idx * frame_bytes : (frame_idx + 1) * frame_bytes] + if len(frame) < frame_bytes: + frame = frame + b"\x00" * (frame_bytes - len(frame)) + if audioop.rms(frame, SAMPLE_WIDTH) >= threshold_rms: + active_frames.append(frame_idx) + + initial_200ms_dbfs = _window_dbfs(sample.pcm, sample_rate=sample.sample_rate, duration_ms=200) + initial_500ms_dbfs = _window_dbfs(sample.pcm, sample_rate=sample.sample_rate, duration_ms=500) + if not active_frames: + return VADProxyMetrics( + threshold_dbfs=threshold_dbfs, + prefix_padding_ms=prefix_padding_ms, + min_speech_ms=min_speech_ms, + first_voice_ms=-1, + last_voice_ms=-1, + speech_ms=0, + padded_start_ms=-1, + unrecovered_prefix_ms=-1, + initial_200ms_dbfs=initial_200ms_dbfs, + initial_500ms_dbfs=initial_500ms_dbfs, + low_start_risk=True, + ) + + first_voice_ms = active_frames[0] * frame_ms + last_voice_ms = (active_frames[-1] + 1) * frame_ms + speech_ms = max(0, last_voice_ms - first_voice_ms) + padded_start_ms = max(0, first_voice_ms - prefix_padding_ms) + unrecovered_prefix_ms = padded_start_ms + low_start_risk = ( + speech_ms < min_speech_ms + or first_voice_ms > 0 + or unrecovered_prefix_ms > 0 + or initial_200ms_dbfs < threshold_dbfs + ) + return VADProxyMetrics( + threshold_dbfs=threshold_dbfs, + prefix_padding_ms=prefix_padding_ms, + min_speech_ms=min_speech_ms, + first_voice_ms=first_voice_ms, + last_voice_ms=last_voice_ms, + speech_ms=speech_ms, + padded_start_ms=padded_start_ms, + unrecovered_prefix_ms=unrecovered_prefix_ms, + initial_200ms_dbfs=initial_200ms_dbfs, + initial_500ms_dbfs=initial_500ms_dbfs, + low_start_risk=low_start_risk, + ) + + +def read_wav_mono16(path: Path | str, *, target_sample_rate: int = TARGET_SAMPLE_RATE) -> AudioSample: + wav_path = Path(path) + with wave.open(str(wav_path), "rb") as handle: + channels = handle.getnchannels() + sample_width = handle.getsampwidth() + sample_rate = handle.getframerate() + pcm = handle.readframes(handle.getnframes()) + + if sample_width != SAMPLE_WIDTH: + raise ValueError(f"{wav_path} must be PCM16 WAV, got sample_width={sample_width}") + if channels != TARGET_CHANNELS: + pcm = audioop.tomono(pcm, SAMPLE_WIDTH, 0.5, 0.5) + channels = TARGET_CHANNELS + if sample_rate != target_sample_rate: + pcm, _ = audioop.ratecv(pcm, SAMPLE_WIDTH, channels, sample_rate, target_sample_rate, None) + sample_rate = target_sample_rate + + return AudioSample(name=wav_path.stem, pcm=pcm, sample_rate=sample_rate, channels=channels) + + +def write_wav(path: Path | str, sample: AudioSample | bytes, *, sample_rate: int = TARGET_SAMPLE_RATE) -> Path: + wav_path = Path(path) + wav_path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(sample, AudioSample): + pcm = sample.pcm + sample_rate = sample.sample_rate + channels = sample.channels + else: + pcm = sample + channels = TARGET_CHANNELS + + with wave.open(str(wav_path), "wb") as handle: + handle.setnchannels(channels) + handle.setsampwidth(SAMPLE_WIDTH) + handle.setframerate(sample_rate) + handle.writeframes(pcm) + return wav_path + + +def pcm_to_wav_bytes(sample: AudioSample) -> bytes: + import io + + buffer = io.BytesIO() + with wave.open(buffer, "wb") as handle: + handle.setnchannels(sample.channels) + handle.setsampwidth(SAMPLE_WIDTH) + handle.setframerate(sample.sample_rate) + handle.writeframes(sample.pcm) + return buffer.getvalue() + + +def _mulaw_roundtrip(pcm: bytes) -> bytes: + mulaw = audioop.lin2ulaw(pcm, SAMPLE_WIDTH) + return audioop.ulaw2lin(mulaw, SAMPLE_WIDTH) + + +def _apply_gain(pcm: bytes, gain: float) -> bytes: + return audioop.mul(pcm, SAMPLE_WIDTH, gain) + + +def _add_gaussian_noise_to_pcm(pcm: bytes, *, snr_db: float, seed: int) -> bytes: + samples = _samples_from_pcm(pcm) + if not samples: + return pcm + + rng = random.Random(seed) + signal_rms = math.sqrt(sum(float(item) * item for item in samples) / len(samples)) + noise_rms = signal_rms / (10 ** (snr_db / 20.0)) if signal_rms > 0 else 0.0 + out = [] + for value in samples: + out.append(value + rng.gauss(0.0, noise_rms)) + return _pcm_from_samples(out) + + +def _noise_pcm(*, duration_ms: int, sample_rate: int, dbfs: float, seed: int) -> bytes: + rng = random.Random(seed) + amplitude = 32768 * (10 ** (dbfs / 20.0)) + samples = round(sample_rate * duration_ms / 1000) + return _pcm_from_samples([rng.gauss(0.0, amplitude) for _ in range(samples)]) + + +def _add_silence(sample: AudioSample, *, before_ms: int, after_ms: int) -> AudioSample: + before = _silence_pcm(sample.sample_rate, before_ms) + after = _silence_pcm(sample.sample_rate, after_ms) + return AudioSample(name="leading_trailing_silence", pcm=before + sample.pcm + after) + + +def _add_noise(sample: AudioSample, *, snr_db: float, seed: int) -> AudioSample: + return AudioSample( + name=f"noise_snr_{round(snr_db)}", + pcm=_add_gaussian_noise_to_pcm(sample.pcm, snr_db=snr_db, seed=seed), + ) + + +def _with_name(name: str, pcm: bytes, sample: AudioSample) -> AudioSample: + return AudioSample(name=name, pcm=pcm, sample_rate=sample.sample_rate, channels=sample.channels) + + +def _add_named_silence(sample: AudioSample, *, name: str, before_ms: int, after_ms: int) -> AudioSample: + return _with_name( + name, + _silence_pcm(sample.sample_rate, before_ms) + sample.pcm + _silence_pcm(sample.sample_rate, after_ms), + sample, + ) + + +def _add_prefix_noise(sample: AudioSample, *, name: str, before_ms: int, dbfs: float, seed: int) -> AudioSample: + return _with_name( + name, + _noise_pcm(duration_ms=before_ms, sample_rate=sample.sample_rate, dbfs=dbfs, seed=seed) + sample.pcm, + sample, + ) + + +def _apply_prefix_gain(sample: AudioSample, *, name: str, prefix_ms: int, gain: float) -> AudioSample: + prefix, rest = _split_pcm_at_ms(sample.pcm, sample_rate=sample.sample_rate, split_ms=prefix_ms) + return _with_name(name, _apply_gain(prefix, gain) + rest, sample) + + +def _fade_in(sample: AudioSample, *, name: str, duration_ms: int) -> AudioSample: + samples = _samples_from_pcm(sample.pcm) + fade_samples = max(1, round(sample.sample_rate * duration_ms / 1000)) + out = [] + for idx, value in enumerate(samples): + if idx < fade_samples: + gain = idx / fade_samples + out.append(value * gain) + else: + out.append(value) + return _with_name(name, _pcm_from_samples(out), sample) + + +def _prefix_gain_with_noise( + sample: AudioSample, + *, + name: str, + prefix_ms: int, + gain: float, + snr_db: float, + seed: int, +) -> AudioSample: + prefix, rest = _split_pcm_at_ms(sample.pcm, sample_rate=sample.sample_rate, split_ms=prefix_ms) + quiet_prefix = _apply_gain(prefix, gain) + noisy_prefix = _add_gaussian_noise_to_pcm(quiet_prefix, snr_db=snr_db, seed=seed) + return _with_name(name, noisy_prefix + rest, sample) + + +def build_variations(sample: AudioSample) -> list[AudioSample]: + return [ + AudioSample(name="clean", pcm=sample.pcm), + AudioSample(name="low_volume", pcm=_apply_gain(sample.pcm, 0.45)), + AudioSample(name="very_low_volume", pcm=_apply_gain(sample.pcm, 0.22)), + AudioSample(name="high_volume", pcm=_apply_gain(sample.pcm, 1.55)), + AudioSample(name="clipped_high_volume", pcm=_apply_gain(sample.pcm, 2.6)), + _add_silence(sample, before_ms=600, after_ms=900), + _add_named_silence(sample, name="short_leading_silence", before_ms=120, after_ms=400), + _add_named_silence(sample, name="long_leading_silence", before_ms=1600, after_ms=900), + _add_noise(sample, snr_db=20, seed=20), + _add_noise(sample, snr_db=15, seed=15), + _add_noise(sample, snr_db=10, seed=10), + _add_prefix_noise(sample, name="pre_noise_300ms", before_ms=300, dbfs=-34.0, seed=300), + _add_prefix_noise(sample, name="pre_noise_800ms", before_ms=800, dbfs=-34.0, seed=800), + AudioSample(name="telephony_profile", pcm=_mulaw_roundtrip(sample.pcm)), + AudioSample(name="telephony_low_volume", pcm=_apply_gain(_mulaw_roundtrip(sample.pcm), 0.45)), + _fade_in(sample, name="initial_fade_in_250ms", duration_ms=250), + _fade_in(sample, name="initial_fade_in_500ms", duration_ms=500), + _fade_in(sample, name="initial_fade_in_900ms", duration_ms=900), + _apply_prefix_gain(sample, name="initial_dip_300ms", prefix_ms=300, gain=0.20), + _apply_prefix_gain(sample, name="initial_dip_700ms", prefix_ms=700, gain=0.25), + _apply_prefix_gain(sample, name="prefix_300ms_15pct", prefix_ms=300, gain=0.15), + _apply_prefix_gain(sample, name="prefix_600ms_20pct", prefix_ms=600, gain=0.20), + _apply_prefix_gain(sample, name="prefix_900ms_25pct", prefix_ms=900, gain=0.25), + _prefix_gain_with_noise( + sample, + name="low_prefix_noise_600ms", + prefix_ms=600, + gain=0.22, + snr_db=8, + seed=608, + ), + _with_name( + "low_prefix_telephony_700ms", + _mulaw_roundtrip(_apply_prefix_gain(sample, name="unused", prefix_ms=700, gain=0.25).pcm), + sample, + ), + ] diff --git a/src/app/tools/local_stresstest/report.py b/src/app/tools/local_stresstest/report.py new file mode 100644 index 0000000..6ea34cf --- /dev/null +++ b/src/app/tools/local_stresstest/report.py @@ -0,0 +1,436 @@ +from __future__ import annotations + +import csv +import json +from dataclasses import asdict, is_dataclass +from html import escape +from pathlib import Path +from typing import Any + +from app.tools.local_stresstest.scenarios import scenario_description + + +def _svg_pipeline(title: str, steps: list[str]) -> str: + box_w = 150 + box_h = 56 + gap = 34 + margin_x = 34 + margin_y = 64 + width = margin_x * 2 + len(steps) * box_w + (len(steps) - 1) * gap + height = 170 + title_x = width // 2 + parts = [ + f'', + "", + '', + '', + "", + "", + '', + f'{escape(title)}', + ] + y = margin_y + for idx, step in enumerate(steps): + x = margin_x + idx * (box_w + gap) + parts.append(f'') + words = step.split() + if len(words) > 2: + mid = (len(words) + 1) // 2 + lines = [" ".join(words[:mid]), " ".join(words[mid:])] + else: + lines = [step] + text_y = y + 28 - (len(lines) - 1) * 9 + for line in lines: + parts.append( + f'{escape(line)}' + ) + text_y += 18 + if idx < len(steps) - 1: + x1 = x + box_w + 6 + x2 = x + box_w + gap - 8 + y_mid = y + box_h / 2 + parts.append( + f'' + ) + parts.append("") + return "\n".join(parts) + "\n" + + +STT_MERMAID = "\n".join( + [ + "flowchart LR", + " A[WAV base] --> B[Variacao de audio]", + " B --> C[VAD proxy RMS]", + " C --> D[Sofya STT HTTP]", + " D --> E[Normalizar transcricao]", + " E --> F[Prefixo WER termos]", + " F --> G[Linha no CSV e Markdown]", + ] +) + +TTS_MERMAID = "\n".join( + [ + "flowchart LR", + " A[Texto de teste] --> B[xAI websocket TTS]", + " B --> C[PCM audio]", + " C --> D[Artefato WAV]", + " C --> E[Duracao, RMS e clipping]", + " E --> F[Linha no CSV e Markdown]", + ] +) + +E2E_MERMAID = "\n".join( + [ + "sequenceDiagram", + " participant Runner", + " participant Bridge", + " participant LiveKit", + " participant Agent", + " participant Sofya", + " participant XAI", + " Runner->>Bridge: start WS + frames PCM", + " Bridge->>LiveKit: publica audio do cliente", + " LiveKit->>Agent: track de audio", + " Agent->>Sofya: reconhece fala", + " Sofya-->>Agent: transcricao", + " Agent->>Agent: resposta remote_ws_fake", + " Agent->>XAI: sintetiza resposta", + " XAI-->>Agent: audio PCM", + " Agent-->>LiveKit: track de audio", + " LiveKit-->>Bridge: audio do agente", + " Bridge-->>Runner: frames PCM + stop", + ] +) + +STT_SVG = _svg_pipeline( + "Fluxo STT", + ["WAV base", "Variacao audio", "VAD proxy", "Sofya STT", "Prefixo WER", "CSV Markdown"], +) + +TTS_SVG = _svg_pipeline( + "Fluxo TTS", + ["Texto teste", "xAI TTS", "PCM audio", "WAV gerado", "Metricas audio", "CSV Markdown"], +) + +E2E_SVG = _svg_pipeline( + "Fluxo E2E Bridge LiveKit", + ["Runner", "Bridge", "LiveKit", "Agent", "Sofya STT", "xAI TTS", "Bridge"], +) + + +def _json_default(value: Any) -> Any: + if isinstance(value, Path): + return str(value) + if is_dataclass(value): + return asdict(value) + return str(value) + + +def write_json(path: Path | str, payload: Any) -> Path: + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=_json_default), encoding="utf-8") + return out + + +def write_csv(path: Path | str, rows: list[dict[str, Any]]) -> Path: + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + fieldnames: list[str] = [] + for row in rows: + for key in row: + if key not in fieldnames: + fieldnames.append(key) + with out.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow({key: row.get(key, "") for key in fieldnames}) + return out + + +def write_mermaid_files(report_dir: Path | str) -> dict[str, str]: + base_dir = Path(report_dir) / "diagrams" + base_dir.mkdir(parents=True, exist_ok=True) + files = { + "stt_mermaid": base_dir / "stt_flow.mmd", + "tts_mermaid": base_dir / "tts_flow.mmd", + "e2e_mermaid": base_dir / "e2e_flow.mmd", + "stt_svg": base_dir / "stt_flow.svg", + "tts_svg": base_dir / "tts_flow.svg", + "e2e_svg": base_dir / "e2e_flow.svg", + } + files["stt_mermaid"].write_text(STT_MERMAID + "\n", encoding="utf-8") + files["tts_mermaid"].write_text(TTS_MERMAID + "\n", encoding="utf-8") + files["e2e_mermaid"].write_text(E2E_MERMAID + "\n", encoding="utf-8") + files["stt_svg"].write_text(STT_SVG, encoding="utf-8") + files["tts_svg"].write_text(TTS_SVG, encoding="utf-8") + files["e2e_svg"].write_text(E2E_SVG, encoding="utf-8") + return {key: value.as_posix() for key, value in files.items()} + + +def _status(value: bool) -> str: + return "APROVADO" if value else "FALHOU" + + +def _human_bool(value: Any) -> str: + if value is True: + return "sim" + if value is False: + return "nao" + return str(value) + + +def _translate_error(value: Any) -> str: + text = str(value or "") + translations = { + "missing expected ready/stop/audio/timeline signal": ( + "faltou algum sinal esperado: ready, stop, audio ou timeline" + ), + } + return translations.get(text, text) + + +def _md_table(rows: list[dict[str, Any]], columns: list[str | tuple[str, str]]) -> str: + if not rows: + return "_Nenhuma linha._" + keys = [item[0] if isinstance(item, tuple) else item for item in columns] + labels = [item[1] if isinstance(item, tuple) else item for item in columns] + header = "| " + " | ".join(labels) + " |" + sep = "| " + " | ".join("---" for _ in labels) + " |" + body = [] + for row in rows: + values = [] + for key in keys: + value = row.get(key, "") + if key == "description" and not value: + value = scenario_description(str(row.get("scenario") or "")) + if key == "passed": + value = _status(value is True) + elif isinstance(value, bool): + value = _human_bool(value) + elif key == "error": + value = _translate_error(value) + text = str(value).replace("\n", " ").replace("|", "\\|") + values.append(text) + body.append("| " + " | ".join(values) + " |") + return "\n".join([header, sep, *body]) + + +def render_markdown_report(summary: dict[str, Any]) -> str: + stt_rows = summary.get("stt_results", []) + tts_rows = summary.get("tts_results", []) + e2e_rows = summary.get("e2e_results", []) + startup_rows = list((summary.get("startup_checks") or {}).values()) + synthetic = bool(summary.get("baseline", {}).get("synthetic")) + overall_passed = bool(summary.get("passed")) + + lines = [ + "# Relatorio do Teste Local de Estresse", + "", + "## Resumo Executivo", + "", + f"- Status geral: **{_status(overall_passed)}**", + f"- Inicio: `{summary.get('started_at', '')}`", + f"- Duracao: `{summary.get('duration_ms', 0)} ms`", + f"- Audio base: `{summary.get('baseline', {}).get('path', '')}`", + f"- Texto esperado: `{summary.get('expected_text', '')}`", + f"- Texto sintetizado: `{summary.get('synthesis_text') or summary.get('expected_text', '')}`", + f"- Chamadas STT: `{summary.get('stt_total_calls', len(stt_rows))}`", + f"- Prefixo monitorado: `{summary.get('stt_prefix_text', '')}`", + ] + vad_proxy = summary.get("vad_proxy") or {} + if vad_proxy: + lines.append( + "- VAD proxy: " + f"`threshold={vad_proxy.get('threshold_dbfs')} dBFS`, " + f"`prefix_padding={vad_proxy.get('prefix_padding_ms')} ms`, " + f"`min_speech={vad_proxy.get('min_speech_ms')} ms`" + ) + diagnostics = summary.get("diagnostics") or {} + if diagnostics: + lines.append( + "- Diagnostico audio/VAD: " + f"`STT_DUMP_DIR={diagnostics.get('stt_dump_dir', '')}`, " + f"`FLOW_LOG_VAD_DECISIONS={diagnostics.get('flow_log_vad_decisions', '')}`, " + f"`FLOW_LOG_VAD_ACTIVITY={diagnostics.get('flow_log_vad_activity', '')}`" + ) + if synthetic: + lines.extend( + [ + "", + "> Aviso: esta execucao usou um audio base sintetico gerado pelo xAI TTS. Quando houver uma gravacao humana, substitua com `STRESS_AUDIO`.", + ] + ) + if startup_rows: + lines.extend( + [ + "", + "## Prontidao Local", + "", + _md_table( + startup_rows, + [ + ("service", "servico"), + ("url", "url"), + ("status", "status"), + ("attempts", "tentativas"), + ("detail", "detalhe"), + ], + ), + ] + ) + + lines.extend( + [ + "", + "## Cenarios STT", + "", + _md_table( + stt_rows, + [ + ("scenario", "cenario"), + ("description", "descricao"), + ("passed", "status"), + ("prefix_ok", "prefixo_ok"), + ("vad_proxy_low_start_risk", "risco_inicio"), + ("vad_proxy_first_voice_ms", "1a_voz_ms"), + ("vad_proxy_unrecovered_prefix_ms", "prefixo_descoberto_ms"), + ("wer", "WER"), + ("duration_ms", "duracao_ms"), + ("rms_dbfs", "rms_dbfs"), + ("transcript", "transcricao"), + ("missing_terms", "termos_ausentes"), + ("error", "erro"), + ], + ), + "", + "## Cenarios TTS", + "", + _md_table( + tts_rows, + [ + ("scenario", "cenario"), + ("description", "descricao"), + ("passed", "status"), + ("duration_ms", "duracao_ms"), + ("latency_ms", "latencia_ms"), + ("rms_dbfs", "rms_dbfs"), + ("clipping_ratio", "taxa_clipping"), + ("audio_path", "audio"), + ("error", "erro"), + ], + ), + "", + "## Cenarios E2E Bridge/LiveKit", + "", + _md_table( + e2e_rows, + [ + ("scenario", "cenario"), + ("description", "descricao"), + ("passed", "status"), + ("ready_received", "recebeu_ready"), + ("stop_received", "recebeu_stop"), + ("non_silent_frames", "frames_com_audio"), + ("timeline_transcript", "transcricao_timeline"), + ("audio_path", "audio"), + ("error", "erro"), + ], + ), + "", + "## Fluxo STT", + "", + "Imagem estatica para preview no VSCode:", + "", + "![Fluxo STT](diagrams/stt_flow.svg)", + "", + "Versao textual:", + "", + "1. O runner carrega o WAV base.", + "2. Gera uma variacao de audio para o cenario.", + "3. Calcula um VAD proxy por energia RMS para estimar risco de inicio fraco.", + "4. Envia o audio para o Sofya STT HTTP.", + "5. Normaliza a transcricao retornada.", + "6. Verifica prefixo esperado, WER e termos criticos.", + "7. Escreve a linha correspondente no CSV e no Markdown.", + "", + f"Arquivo Mermaid separado: `{summary.get('artifacts', {}).get('stt_mermaid', '')}`", + "", + "Codigo Mermaid:", + "", + "```mermaid", + STT_MERMAID, + "```", + "", + "## Fluxo TTS", + "", + "Imagem estatica para preview no VSCode:", + "", + "![Fluxo TTS](diagrams/tts_flow.svg)", + "", + "Versao textual:", + "", + "1. O runner envia um texto de teste para o xAI TTS.", + "2. O xAI retorna audio PCM pelo websocket.", + "3. O runner salva o audio como WAV.", + "4. Calcula duracao, RMS e clipping.", + "5. Escreve a linha correspondente no CSV e no Markdown.", + "", + f"Arquivo Mermaid separado: `{summary.get('artifacts', {}).get('tts_mermaid', '')}`", + "", + "Codigo Mermaid:", + "", + "```mermaid", + TTS_MERMAID, + "```", + "", + "## Fluxo E2E", + "", + "Imagem estatica para preview no VSCode:", + "", + "![Fluxo E2E](diagrams/e2e_flow.svg)", + "", + "Versao textual:", + "", + "1. O runner abre websocket no Bridge e envia `start`.", + "2. O Bridge publica o audio do cliente no LiveKit.", + "3. O Agent recebe o audio e chama Sofya STT.", + "4. O backend fake gera a resposta de negocio.", + "5. O Agent chama xAI TTS para sintetizar a resposta.", + "6. O audio volta pelo LiveKit para o Bridge.", + "7. O Bridge devolve audio PCM e, ao final ideal, envia `stop`.", + "", + f"Arquivo Mermaid separado: `{summary.get('artifacts', {}).get('e2e_mermaid', '')}`", + "", + "Codigo Mermaid:", + "", + "```mermaid", + E2E_MERMAID, + "```", + "", + "## Artefatos", + "", + f"- Summary JSON: `{summary.get('artifacts', {}).get('summary_json', '')}`", + f"- CSV STT: `{summary.get('artifacts', {}).get('stt_csv', '')}`", + f"- CSV TTS: `{summary.get('artifacts', {}).get('tts_csv', '')}`", + f"- CSV E2E: `{summary.get('artifacts', {}).get('e2e_csv', '')}`", + f"- Recorte da timeline: `{summary.get('artifacts', {}).get('timeline_excerpt', '')}`", + f"- Mermaid STT: `{summary.get('artifacts', {}).get('stt_mermaid', '')}`", + f"- Mermaid TTS: `{summary.get('artifacts', {}).get('tts_mermaid', '')}`", + f"- Mermaid E2E: `{summary.get('artifacts', {}).get('e2e_mermaid', '')}`", + f"- SVG STT: `{summary.get('artifacts', {}).get('stt_svg', '')}`", + f"- SVG TTS: `{summary.get('artifacts', {}).get('tts_svg', '')}`", + f"- SVG E2E: `{summary.get('artifacts', {}).get('e2e_svg', '')}`", + ] + ) + return "\n".join(lines) + "\n" + + +def write_markdown_report(path: Path | str, summary: dict[str, Any]) -> Path: + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(render_markdown_report(summary), encoding="utf-8") + return out diff --git a/src/app/tools/local_stresstest/runner.py b/src/app/tools/local_stresstest/runner.py new file mode 100644 index 0000000..114ee18 --- /dev/null +++ b/src/app/tools/local_stresstest/runner.py @@ -0,0 +1,755 @@ +from __future__ import annotations + +import asyncio +import json +import os +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Awaitable, Callable +from urllib.parse import urlsplit, urlunsplit + +import aiohttp +import httpx +import websockets +from dotenv import load_dotenv +from livekit.agents.types import DEFAULT_API_CONNECT_OPTIONS + +from app.livekit.adapters.xai_tts import OraclexAITTS +from app.providers.stt_config import override_config +from app.providers.stt_internal_livekit import InternalHTTPSTT, InternalSTTConfig, extract_text_from_transcript +from app.tools.local_stresstest.audio import ( + AudioSample, + audio_metrics, + build_variations, + pcm_to_wav_bytes, + read_wav_mono16, + vad_proxy_metrics, + write_wav, +) +from app.tools.local_stresstest.report import ( + render_markdown_report, + write_csv, + write_json, + write_markdown_report, + write_mermaid_files, +) +from app.tools.local_stresstest.scenarios import scenario_description +from app.tools.local_stresstest.text import compare_text, normalize_text +from app.tools.local_stresstest.timeline import excerpt_timeline, timeline_has_error, wait_for_timeline + + +DEFAULT_EXPECTED_TEXT = ( + "Por que os serviços Aia, Hexa e Banca aparecem com valor na Danf " + "se eles são incluídos no meu plano" +) +DEFAULT_CRITICAL_TERMS = ("por", "que", "aia", "hexa", "banca", "danf", "incluídos", "plano") +DEFAULT_SHORT_TTS_TEXT = "Teste de voz do xAI para validar áudio sintetizado." +DEFAULT_LONG_TTS_TEXT = ( + "Este é um teste operacional do TTS xAI para validar latência, duração, volume e entrega " + "de áudio antes do relatório local." +) +TERMINAL_EVENTS = { + "bridge_failed", + "stop_stt_unavailable", + "stop_tts_unavailable", + "stop_bridge_failed", + "stop_agent_runtime_unavailable", +} + + +@dataclass(frozen=True, slots=True) +class StressConfig: + env_file: Path + report_dir: Path + expected_text: str + synthesis_text: str + critical_terms: tuple[str, ...] + stt_wer_threshold: float + bridge_url: str + bridge_health_url: str + agent_health_url: str + startup_wait_s: float + startup_poll_s: float + skip_local_wait: bool + repeat: int + concurrency: int + e2e_turns: int + e2e_timeout_s: float + stress_audio: Path | None + prefix_text: str + prefix_words: int + vad_proxy_threshold_dbfs: float + vad_proxy_prefix_padding_ms: int + vad_proxy_min_speech_ms: int + + +def _env_str(name: str, default: str = "") -> str: + return str(os.getenv(name, default) or default).strip() + + +def _env_int(name: str, default: int) -> int: + try: + return int(_env_str(name, str(default))) + except ValueError: + return default + + +def _env_float(name: str, default: float) -> float: + try: + return float(_env_str(name, str(default))) + except ValueError: + return default + + +def _env_bool(name: str, default: bool = False) -> bool: + value = _env_str(name) + if not value: + return default + return value.lower() in {"1", "true", "yes", "on", "sim"} + + +def _first_words(text: str, count: int) -> str: + words = [item for item in str(text or "").strip().split() if item] + return " ".join(words[: max(1, count)]) + + +def _derive_bridge_health_url(bridge_url: str) -> str: + explicit = _env_str("STRESS_BRIDGE_HEALTH_URL") + if explicit: + return explicit + parsed = urlsplit(bridge_url) + if not parsed.netloc: + return "http://127.0.0.1:8000/health" + scheme = "https" if parsed.scheme == "wss" else "http" + return urlunsplit((scheme, parsed.netloc, "/health", "", "")) + + +def _derive_agent_health_url() -> str: + return _env_str( + "STRESS_AGENT_HEALTH_URL", + f"http://127.0.0.1:{_env_int('AGENT_SERVER_PORT', 18081)}/", + ) + + +def load_config() -> StressConfig: + env_file = Path(_env_str("STRESS_ENV_FILE", ".env.dev")) + if env_file.exists(): + load_dotenv(env_file, override=False) + + report_dir = Path(_env_str("STRESS_REPORT_DIR", ".run/local-stresstest")) + os.environ.setdefault("STT_DUMP_DIR", str(report_dir / "stt_dumps")) + os.environ.setdefault("FLOW_LOG_VAD_DECISIONS", "1") + os.environ.setdefault("FLOW_LOG_VAD_ACTIVITY", "1") + os.environ.setdefault("FLOW_LOG_VAD_ACTIVITY_MIN_PROB", "0.03") + audio_value = _env_str("STRESS_AUDIO") + critical_terms = tuple( + item.strip() + for item in _env_str("STRESS_CRITICAL_TERMS", ",".join(DEFAULT_CRITICAL_TERMS)).split(",") + if item.strip() + ) + bridge_url = _env_str("STRESS_BRIDGE_URL", "ws://127.0.0.1:8000/ws/agent") + expected_text = _env_str("STRESS_EXPECTED_TEXT", DEFAULT_EXPECTED_TEXT) + synthesis_text = _env_str("STRESS_SYNTH_TEXT", expected_text) + prefix_words = max(1, _env_int("STRESS_PREFIX_WORDS", 2)) + vad_prefix_padding_default_ms = round(_env_float("VAD_PREFIX_PADDING_DURATION", 1.5) * 1000) + return StressConfig( + env_file=env_file, + report_dir=report_dir, + expected_text=expected_text, + synthesis_text=synthesis_text, + critical_terms=critical_terms, + stt_wer_threshold=_env_float("STRESS_WER_THRESHOLD", 0.20), + bridge_url=bridge_url, + bridge_health_url=_derive_bridge_health_url(bridge_url), + agent_health_url=_derive_agent_health_url(), + startup_wait_s=max(0.0, _env_float("STRESS_STARTUP_WAIT_S", 180.0)), + startup_poll_s=max(0.5, _env_float("STRESS_STARTUP_POLL_S", 3.0)), + skip_local_wait=_env_bool("STRESS_SKIP_LOCAL_WAIT", False), + repeat=max(1, _env_int("STRESS_REPEAT", 1)), + concurrency=max(1, _env_int("STRESS_CONCURRENCY", 2)), + e2e_turns=max(1, _env_int("STRESS_E2E_TURNS", 3)), + e2e_timeout_s=max(5.0, _env_float("STRESS_E2E_TIMEOUT_S", 45.0)), + stress_audio=Path(audio_value) if audio_value else None, + prefix_text=_env_str("STRESS_PREFIX_TEXT", _first_words(expected_text, prefix_words)), + prefix_words=prefix_words, + vad_proxy_threshold_dbfs=_env_float("STRESS_VAD_PROXY_DBFS", -45.0), + vad_proxy_prefix_padding_ms=max( + 0, + _env_int("STRESS_VAD_PREFIX_PADDING_MS", vad_prefix_padding_default_ms), + ), + vad_proxy_min_speech_ms=max(0, _env_int("STRESS_VAD_MIN_SPEECH_MS", 100)), + ) + + +def _require_env(*names: str) -> str: + for name in names: + value = _env_str(name) + if value: + return value + raise RuntimeError(f"Missing required environment variable: {' or '.join(names)}") + + +ServiceProbe = Callable[[str], Awaitable[tuple[bool, str]]] +SleepFn = Callable[[float], Awaitable[None]] + + +async def _http_ready_probe(url: str) -> tuple[bool, str]: + timeout = httpx.Timeout(2.5, connect=1.0) + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get(url) + ok = 200 <= response.status_code < 300 + return ok, f"HTTP {response.status_code}" + + +async def wait_for_local_services( + config: StressConfig, + *, + probe: ServiceProbe | None = None, + sleep: SleepFn = asyncio.sleep, +) -> dict[str, dict[str, Any]]: + services = { + "bridge": config.bridge_health_url, + "agent_runtime": config.agent_health_url, + } + states: dict[str, dict[str, Any]] = { + name: { + "service": name, + "url": url, + "ok": False, + "status": "pending", + "attempts": 0, + "detail": "", + } + for name, url in services.items() + } + if config.skip_local_wait: + for state in states.values(): + state.update({"ok": True, "status": "skipped", "detail": "STRESS_SKIP_LOCAL_WAIT habilitado"}) + return states + + active_probe = probe or _http_ready_probe + deadline = time.monotonic() + config.startup_wait_s + print( + "[local-stresstest] Aguardando servicos locais " + f"(timeout={config.startup_wait_s:.0f}s, poll={config.startup_poll_s:.1f}s)" + ) + while True: + for name, url in services.items(): + state = states[name] + if state["ok"]: + continue + state["attempts"] += 1 + try: + ok, detail = await active_probe(url) + except Exception as exc: + ok = False + detail = f"{type(exc).__name__}: {exc}" + state.update( + { + "ok": ok, + "status": "ready" if ok else "waiting", + "detail": detail, + } + ) + + if all(state["ok"] for state in states.values()): + print("[local-stresstest] Servicos locais prontos: bridge, agent_runtime") + return states + + if time.monotonic() >= deadline: + failed = "; ".join( + f"{name} em {state['url']} -> {state['detail'] or state['status']}" + for name, state in states.items() + if not state["ok"] + ) + raise RuntimeError( + f"Timeout aguardando servicos locais por {config.startup_wait_s:.0f}s: {failed}" + ) + + remaining = max(0.1, deadline - time.monotonic()) + await sleep(min(config.startup_poll_s, remaining)) + + +def _stt_config() -> InternalSTTConfig: + return InternalSTTConfig( + url=_require_env("STT_URL"), + api_key=_env_str("STT_KEY") or _env_str("STT_API_KEY", "unknow"), + language=_env_str("STT_LANG", "portuguese"), + timeout_s=_env_float("STRESS_STT_TIMEOUT_S", _env_float("STT_TIMEOUT_S", 30.0)), + min_prob_single_word=_env_float("STT_MIN_PROB_SINGLE_WORD", 0.10), + initial_prompt=_env_str("STT_INITIAL_PROMPT"), + config_override=_env_str("STT_CONFIG_OVERRIDE"), + output_mode=_env_str("STT_OUTPUT_MODE", "threshold_text"), + ) + + +def _stt_preprocessor_profile(cfg: InternalSTTConfig) -> str: + raw = (os.getenv("STT_FORCE_OVERRIDE_CONFIG", "") or cfg.config_override or "").strip() + payload: dict[str, Any] + if raw: + try: + parsed = json.loads(raw) + payload = parsed if isinstance(parsed, dict) else {} + except Exception: + return "custom_override_unparseable" + else: + payload = override_config + + preprocessors = payload.get("pre_processors") + if not isinstance(preprocessors, list): + return "none" + names = [ + str(item.get("strategy") or "").strip() + for item in preprocessors + if isinstance(item, dict) and str(item.get("strategy") or "").strip() + ] + return ",".join(names) if names else "none" + + +def _prefix_ok(*, expected_prefix: str, transcript: str) -> bool: + normalized_expected = normalize_text(expected_prefix) + normalized_actual = normalize_text(transcript) + return bool(normalized_expected) and normalized_actual.startswith(normalized_expected) + + +async def _collect_tts_bytes(tts: OraclexAITTS, text: str) -> bytes: + frame = await tts.synthesize(text, conn_options=DEFAULT_API_CONNECT_OPTIONS).collect() + return bytes(getattr(frame, "data", b"")) + + +async def _synthesize_xai_to_sample(text: str) -> AudioSample: + async with aiohttp.ClientSession() as session: + tts = OraclexAITTS( + api_key=_require_env("XAI_API_KEY"), + voice=_env_str("XAI_TTS_VOICE"), + language=_env_str("XAI_TTS_LANGUAGE", "pt-BR"), + http_session=session, + ) + try: + pcm = await _collect_tts_bytes(tts, text) + sample = AudioSample(name="xai_tts", pcm=pcm, sample_rate=tts.sample_rate, channels=tts.num_channels) + tmp_path = Path(".run/local-stresstest/.tmp_xai_collect.wav") + write_wav(tmp_path, sample) + return read_wav_mono16(tmp_path) + finally: + await tts.aclose() + + +async def prepare_baseline(config: StressConfig) -> dict[str, Any]: + assets_dir = config.report_dir / "assets" + assets_dir.mkdir(parents=True, exist_ok=True) + if config.stress_audio is not None: + sample = read_wav_mono16(config.stress_audio) + path = write_wav(assets_dir / "baseline_from_stress_audio.wav", sample) + return {"sample": sample, "path": path, "synthetic": False} + + sample = await _synthesize_xai_to_sample(config.synthesis_text) + path = write_wav(assets_dir / "synthetic_baseline.wav", sample) + return {"sample": sample, "path": path, "synthetic": True} + + +async def run_stt_scenario( + *, + config: StressConfig, + scenario: AudioSample, + output_path: Path, +) -> dict[str, Any]: + started = time.perf_counter() + metrics = audio_metrics(scenario.pcm, sample_rate=scenario.sample_rate, channels=scenario.channels) + vad_metrics = vad_proxy_metrics( + scenario, + threshold_dbfs=config.vad_proxy_threshold_dbfs, + prefix_padding_ms=config.vad_proxy_prefix_padding_ms, + min_speech_ms=config.vad_proxy_min_speech_ms, + ) + write_wav(output_path, scenario) + stt_cfg = _stt_config() + row: dict[str, Any] = { + "scenario": scenario.name, + "description": scenario_description(scenario.name), + "audio_path": str(output_path), + "duration_ms": metrics.duration_ms, + "rms_dbfs": metrics.rms_dbfs, + "peak_dbfs": metrics.peak_dbfs, + "clipping_ratio": metrics.clipping_ratio, + "vad_proxy_threshold_dbfs": vad_metrics.threshold_dbfs, + "vad_proxy_prefix_padding_ms": vad_metrics.prefix_padding_ms, + "vad_proxy_first_voice_ms": vad_metrics.first_voice_ms, + "vad_proxy_padded_start_ms": vad_metrics.padded_start_ms, + "vad_proxy_unrecovered_prefix_ms": vad_metrics.unrecovered_prefix_ms, + "vad_proxy_speech_ms": vad_metrics.speech_ms, + "vad_proxy_initial_200ms_dbfs": vad_metrics.initial_200ms_dbfs, + "vad_proxy_initial_500ms_dbfs": vad_metrics.initial_500ms_dbfs, + "vad_proxy_low_start_risk": vad_metrics.low_start_risk, + "expected_prefix": config.prefix_text, + "stt_preprocessors": _stt_preprocessor_profile(stt_cfg), + } + client = httpx.AsyncClient() + try: + stt = InternalHTTPSTT(stt_cfg, client=client) + event = await stt._post_internal_http( + scenario.pcm, + pcm_to_wav_bytes(scenario), + req_id=f"stress-{scenario.name}", + started_ns=time.time_ns(), + language=_env_str("STT_LANG", "portuguese"), + audio_duration_ms=metrics.duration_ms, + level_dbfs=metrics.rms_dbfs, + ) + transcript = event.alternatives[0].text if event.alternatives else "" + transcript = extract_text_from_transcript(transcript) + prefix_ok = _prefix_ok(expected_prefix=config.prefix_text, transcript=transcript) + comparison = compare_text( + expected=config.expected_text, + actual=transcript, + critical_terms=config.critical_terms, + ) + passed = bool(transcript) and comparison.terms_ok and comparison.wer <= config.stt_wer_threshold + row.update( + { + "passed": passed, + "transcript": transcript, + "actual_prefix": _first_words(transcript, config.prefix_words), + "prefix_ok": prefix_ok, + "wer": round(comparison.wer, 4), + "substitutions": comparison.substitutions, + "deletions": comparison.deletions, + "insertions": comparison.insertions, + "missing_terms": ",".join(comparison.missing_terms), + "latency_ms": round((time.perf_counter() - started) * 1000), + "error": "", + } + ) + except Exception as exc: + row.update( + { + "passed": False, + "transcript": "", + "actual_prefix": "", + "prefix_ok": False, + "wer": "", + "missing_terms": "", + "latency_ms": round((time.perf_counter() - started) * 1000), + "error": str(exc), + } + ) + finally: + await client.aclose() + return row + + +async def run_stt_suite(config: StressConfig, baseline: AudioSample) -> list[dict[str, Any]]: + audio_dir = config.report_dir / "stt_audio" + variations = [] + for repeat_idx in range(1, config.repeat + 1): + for variation in build_variations(baseline): + if config.repeat > 1: + variations.append( + AudioSample( + name=f"{variation.name}_r{repeat_idx}", + pcm=variation.pcm, + sample_rate=variation.sample_rate, + channels=variation.channels, + ) + ) + else: + variations.append(variation) + rows: list[dict[str, Any]] = [] + semaphore = asyncio.Semaphore(config.concurrency) + + async def _run(item: AudioSample, idx: int) -> dict[str, Any]: + async with semaphore: + return await run_stt_scenario( + config=config, + scenario=item, + output_path=audio_dir / f"{idx:02d}_{item.name}.wav", + ) + + tasks = [ + _run(variation, idx) + for idx, variation in enumerate(variations, start=1) + ] + for result in await asyncio.gather(*tasks): + rows.append(result) + return rows + + +async def run_tts_suite(config: StressConfig) -> list[dict[str, Any]]: + scenarios = [ + ("short", DEFAULT_SHORT_TTS_TEXT), + ("long", DEFAULT_LONG_TTS_TEXT), + ("reuse_1", DEFAULT_SHORT_TTS_TEXT), + ("reuse_2", DEFAULT_SHORT_TTS_TEXT), + ] + rows: list[dict[str, Any]] = [] + out_dir = config.report_dir / "tts_audio" + async with aiohttp.ClientSession() as session: + tts = OraclexAITTS( + api_key=_require_env("XAI_API_KEY"), + voice=_env_str("XAI_TTS_VOICE"), + language=_env_str("XAI_TTS_LANGUAGE", "pt-BR"), + http_session=session, + ) + try: + for name, text in scenarios: + started = time.perf_counter() + row: dict[str, Any] = {"scenario": name, "text_len": len(text)} + row["description"] = scenario_description(name) + try: + pcm = await _collect_tts_bytes(tts, text) + sample = AudioSample(name=name, pcm=pcm, sample_rate=tts.sample_rate, channels=tts.num_channels) + path = write_wav(out_dir / f"{name}.wav", sample) + normalized = read_wav_mono16(path) + metrics = audio_metrics(normalized.pcm) + expected_min_ms = max(250, len(text) * 18) + expected_max_ms = max(2500, len(text) * 180) + passed = ( + bool(pcm) + and metrics.audible + and metrics.clipping_ratio <= _env_float("STRESS_TTS_MAX_CLIPPING", 0.02) + and expected_min_ms <= metrics.duration_ms <= expected_max_ms + ) + row.update( + { + "passed": passed, + "audio_path": str(path), + "duration_ms": metrics.duration_ms, + "latency_ms": round((time.perf_counter() - started) * 1000), + "rms_dbfs": metrics.rms_dbfs, + "peak_dbfs": metrics.peak_dbfs, + "clipping_ratio": metrics.clipping_ratio, + "error": "", + } + ) + except Exception as exc: + row.update( + { + "passed": False, + "audio_path": "", + "duration_ms": 0, + "latency_ms": round((time.perf_counter() - started) * 1000), + "rms_dbfs": "", + "clipping_ratio": "", + "error": str(exc), + } + ) + rows.append(row) + finally: + await tts.aclose() + return rows + + +def _start_payload(config: StressConfig) -> dict[str, Any]: + stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + return { + "type": "start", + "data": { + "agent": "conta", + "ani": "5511999990000", + "gsm": "5511999990000", + "routerCallKeyDay": datetime.now().strftime("%Y%m%d"), + "routerCallKey": f"RCK-stress-{stamp}", + "callIdGed": f"GED-stress-{stamp}", + "session_id": f"stress-{stamp}", + "agentData": {"idFatura": "FAT-STRESS-001"}, + }, + "audioFormat": {"encoding": "linear16", "sampleRateHz": 16000, "channels": 1}, + "callConfig": { + "agentBackend": "remote_ws_fake", + "stt": {"provider": "internal_http"}, + "tts": {"provider": "xai"}, + }, + } + + +async def _send_pcm_turns(ws: Any, sample: AudioSample, *, turns: int) -> None: + frame_bytes = round(sample.sample_rate * 20 / 1000) * 2 + silence = b"\x00" * frame_bytes + for _ in range(turns): + for offset in range(0, len(sample.pcm), frame_bytes): + chunk = sample.pcm[offset : offset + frame_bytes] + if len(chunk) < frame_bytes: + chunk = chunk + b"\x00" * (frame_bytes - len(chunk)) + await ws.send(chunk) + await asyncio.sleep(0.02) + for _ in range(60): + await ws.send(silence) + await asyncio.sleep(0.02) + + +async def run_e2e_suite(config: StressConfig, baseline: AudioSample) -> list[dict[str, Any]]: + scenario = AudioSample(name="clean", pcm=baseline.pcm) + started = time.perf_counter() + row: dict[str, Any] = { + "scenario": scenario.name, + "description": scenario_description(scenario.name), + "ready_received": False, + "stop_received": False, + "non_silent_frames": 0, + "timeline_transcript": "", + "audio_path": "", + "error": "", + } + received_audio = bytearray() + text_messages: list[dict[str, Any]] = [] + room = "" + + try: + async with websockets.connect(config.bridge_url, max_size=2**24) as ws: + await ws.send(json.dumps(_start_payload(config), ensure_ascii=False)) + first = await asyncio.wait_for(ws.recv(), timeout=10.0) + if isinstance(first, bytes): + raise RuntimeError("Expected ready/stop text message, got binary") + payload = json.loads(first) + text_messages.append(payload) + if payload.get("type") == "stop": + row["error"] = json.dumps(payload, ensure_ascii=False) + row["stop_received"] = True + return [dict(row, passed=False)] + if payload.get("type") != "ready": + raise RuntimeError(f"Expected ready, got {payload}") + row["ready_received"] = True + room = str(payload.get("room") or "") + + sender = asyncio.create_task(_send_pcm_turns(ws, scenario, turns=config.e2e_turns)) + deadline = time.monotonic() + config.e2e_timeout_s + while time.monotonic() < deadline: + try: + message = await asyncio.wait_for(ws.recv(), timeout=0.5) + except asyncio.TimeoutError: + if sender.done(): + continue + continue + if isinstance(message, bytes): + received_audio.extend(message) + if audio_metrics(message).audible: + row["non_silent_frames"] += 1 + continue + + payload = json.loads(message) + text_messages.append(payload) + if payload.get("type") == "stop": + row["stop_received"] = True + break + + sender.cancel() + await asyncio.gather(sender, return_exceptions=True) + + if received_audio: + audio_path = config.report_dir / "e2e_audio" / f"{scenario.name}_bridge_response.wav" + write_wav(audio_path, AudioSample(name="bridge_response", pcm=bytes(received_audio))) + row["audio_path"] = str(audio_path) + + timeline_dir = Path(_env_str("CALL_TIMELINE_DIR", "./timeline")) + records = wait_for_timeline(timeline_dir / f"{room}.jsonl", timeout_s=5.0) if room else [] + transcripts = [ + str(record.get("text") or record.get("transcript") or "") + for record in records + if str(record.get("event") or "") in {"user_transcript_final", "stt_final"} + ] + row["timeline_transcript"] = " | ".join(item for item in transcripts if item) + row["timeline_path"] = str(timeline_dir / f"{room}.jsonl") if room else "" + row["timeline_error"] = timeline_has_error(records) + row["text_messages"] = json.dumps(text_messages, ensure_ascii=False) + terminal_error = any( + str(message.get("data", {}).get("status") or "") in TERMINAL_EVENTS + for message in text_messages + if isinstance(message, dict) + ) + row["passed"] = bool( + row["ready_received"] + and row["stop_received"] + and row["non_silent_frames"] > 0 + and row["timeline_transcript"] + and not row["timeline_error"] + and not terminal_error + ) + if not row["passed"] and not row["error"]: + row["error"] = "missing expected ready/stop/audio/timeline signal" + + excerpt_path = config.report_dir / "timeline_excerpt.jsonl" + excerpt_path.parent.mkdir(parents=True, exist_ok=True) + with excerpt_path.open("w", encoding="utf-8") as handle: + for record in excerpt_timeline(records): + handle.write(json.dumps(record, ensure_ascii=False)) + handle.write("\n") + except Exception as exc: + row["passed"] = False + row["error"] = str(exc) + + row["latency_ms"] = round((time.perf_counter() - started) * 1000) + return [row] + + +def _artifact_paths(config: StressConfig) -> dict[str, str]: + artifacts = { + "summary_json": str(config.report_dir / "summary.json"), + "stt_csv": str(config.report_dir / "stt_results.csv"), + "tts_csv": str(config.report_dir / "tts_results.csv"), + "e2e_csv": str(config.report_dir / "e2e_results.csv"), + "timeline_excerpt": str(config.report_dir / "timeline_excerpt.jsonl"), + "report_md": str(config.report_dir / "report.md"), + } + artifacts.update(write_mermaid_files(config.report_dir)) + return artifacts + + +async def run() -> int: + config = load_config() + config.report_dir.mkdir(parents=True, exist_ok=True) + started = time.perf_counter() + started_at = datetime.now(timezone.utc).isoformat() + + startup_checks = await wait_for_local_services(config) + baseline_info = await prepare_baseline(config) + baseline = baseline_info["sample"] + stt_results = await run_stt_suite(config, baseline) + tts_results = await run_tts_suite(config) + e2e_results = await run_e2e_suite(config, baseline) + + artifacts = _artifact_paths(config) + write_csv(artifacts["stt_csv"], stt_results) + write_csv(artifacts["tts_csv"], tts_results) + write_csv(artifacts["e2e_csv"], e2e_results) + + summary = { + "started_at": started_at, + "duration_ms": round((time.perf_counter() - started) * 1000), + "expected_text": config.expected_text, + "synthesis_text": config.synthesis_text, + "critical_terms": list(config.critical_terms), + "wer_threshold": config.stt_wer_threshold, + "stt_scenarios_per_repeat": len(build_variations(baseline)), + "stt_total_calls": len(stt_results), + "stt_prefix_text": config.prefix_text, + "vad_proxy": { + "threshold_dbfs": config.vad_proxy_threshold_dbfs, + "prefix_padding_ms": config.vad_proxy_prefix_padding_ms, + "min_speech_ms": config.vad_proxy_min_speech_ms, + "note": "Proxy por energia RMS; nao substitui o Silero VAD do LiveKit.", + }, + "diagnostics": { + "stt_dump_dir": _env_str("STT_DUMP_DIR"), + "flow_log_vad_decisions": _env_str("FLOW_LOG_VAD_DECISIONS"), + "flow_log_vad_activity": _env_str("FLOW_LOG_VAD_ACTIVITY"), + "flow_log_vad_activity_min_prob": _env_str("FLOW_LOG_VAD_ACTIVITY_MIN_PROB"), + }, + "startup_checks": startup_checks, + "baseline": { + "path": str(baseline_info["path"]), + "synthetic": bool(baseline_info["synthetic"]), + "metrics": audio_metrics(baseline.pcm), + }, + "stt_results": stt_results, + "tts_results": tts_results, + "e2e_results": e2e_results, + "artifacts": artifacts, + } + summary["passed"] = all(row.get("passed") is True for row in [*stt_results, *tts_results, *e2e_results]) + write_json(artifacts["summary_json"], summary) + write_markdown_report(artifacts["report_md"], summary) + + print(render_markdown_report(summary)) + return 0 if summary["passed"] else 1 diff --git a/src/app/tools/local_stresstest/scenarios.py b/src/app/tools/local_stresstest/scenarios.py new file mode 100644 index 0000000..05d3a8b --- /dev/null +++ b/src/app/tools/local_stresstest/scenarios.py @@ -0,0 +1,47 @@ +from __future__ import annotations + + +SCENARIO_DESCRIPTIONS = { + "clean": "Audio base sem degradacao; serve como linha de referencia do STT.", + "low_volume": "Mesmo audio com ganho reduzido para simular fala baixa ou captura distante.", + "very_low_volume": "Mesmo audio com ganho muito baixo para testar limite de audibilidade do STT.", + "high_volume": "Mesmo audio com ganho elevado, sem clipping esperado, para simular fala forte.", + "clipped_high_volume": "Audio com ganho alto o suficiente para provocar clipping e distorcao.", + "leading_trailing_silence": "Audio com silencio antes e depois da fala para validar recorte/VAD.", + "short_leading_silence": "Pequeno silencio antes da fala para validar inicio com pouca margem.", + "long_leading_silence": "Silencio longo antes da fala para observar trim e tempo de ativacao.", + "noise_snr_20": "Audio com ruido leve, SNR 20 dB, para medir robustez inicial.", + "noise_snr_15": "Audio com ruido moderado, SNR 15 dB, para observar degradacao.", + "noise_snr_10": "Audio com ruido forte, SNR 10 dB, para identificar limite de tolerancia.", + "pre_noise_300ms": "Ruido curto antes da fala para simular ambiente aberto antes do enunciado.", + "pre_noise_800ms": "Ruido mais longo antes da fala para testar se preprocessors confundem inicio.", + "telephony_profile": "Audio passado por perfil telefonico simples via u-law para simular compressao.", + "telephony_low_volume": "Perfil telefonico combinado com volume baixo.", + "initial_fade_in_250ms": "Ataque inicial suavizado por 250 ms para simular primeira palavra fraca.", + "initial_fade_in_500ms": "Ataque inicial suavizado por 500 ms para estressar captura do prefixo.", + "initial_fade_in_900ms": "Ataque inicial suavizado por 900 ms, perto do prefix padding local.", + "initial_dip_300ms": "Primeiros 300 ms com ganho baixo, depois volume normal.", + "initial_dip_700ms": "Primeiros 700 ms com ganho baixo, simulando pergunta iniciada fraca.", + "prefix_300ms_15pct": "Prefixo de 300 ms muito baixo para observar corte de inicio curto.", + "prefix_600ms_20pct": "Prefixo de 600 ms baixo para simular 'Por que' sem energia.", + "prefix_900ms_25pct": "Prefixo de 900 ms baixo, limite interessante para padding de 1s.", + "low_prefix_noise_600ms": "Prefixo baixo com ruido, combinando fala fraca e ambiente ruidoso.", + "low_prefix_telephony_700ms": "Prefixo baixo com perfil telefonico para simular URA/telefonia.", + "short": "Sintese curta no xAI TTS para validar resposta basica, volume e latencia.", + "long": "Sintese longa no xAI TTS para validar estabilidade e duracao plausivel.", + "reuse_1": "Primeira repeticao curta para observar reuso da conexao websocket do xAI.", + "reuse_2": "Segunda repeticao curta para confirmar reuso continuo da conexao websocket do xAI.", +} + + +def scenario_base_name(name: str) -> str: + value = str(name or "").strip() + if "_r" in value: + prefix, suffix = value.rsplit("_r", 1) + if suffix.isdigit(): + return prefix + return value + + +def scenario_description(name: str) -> str: + return SCENARIO_DESCRIPTIONS.get(scenario_base_name(name), "") diff --git a/src/app/tools/local_stresstest/text.py b/src/app/tools/local_stresstest/text.py new file mode 100644 index 0000000..f5d0ac7 --- /dev/null +++ b/src/app/tools/local_stresstest/text.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import re +import unicodedata +from dataclasses import dataclass + + +_TOKEN_RE = re.compile(r"[^0-9a-zA-Z]+") + + +@dataclass(frozen=True, slots=True) +class TextComparison: + expected: str + actual: str + normalized_expected: str + normalized_actual: str + wer: float + substitutions: int + deletions: int + insertions: int + missing_terms: tuple[str, ...] + + @property + def terms_ok(self) -> bool: + return not self.missing_terms + + +def normalize_text(text: str) -> str: + value = unicodedata.normalize("NFD", str(text or "").strip().lower()) + value = "".join(ch for ch in value if unicodedata.category(ch) != "Mn") + value = _TOKEN_RE.sub(" ", value) + return " ".join(value.split()) + + +def _edit_counts(expected_tokens: list[str], actual_tokens: list[str]) -> tuple[int, int, int]: + rows = len(expected_tokens) + 1 + cols = len(actual_tokens) + 1 + dp: list[list[tuple[int, int, int, int]]] = [ + [(0, 0, 0, 0) for _ in range(cols)] + for _ in range(rows) + ] + + for i in range(1, rows): + cost, sub, dele, ins = dp[i - 1][0] + dp[i][0] = (cost + 1, sub, dele + 1, ins) + for j in range(1, cols): + cost, sub, dele, ins = dp[0][j - 1] + dp[0][j] = (cost + 1, sub, dele, ins + 1) + + for i in range(1, rows): + for j in range(1, cols): + if expected_tokens[i - 1] == actual_tokens[j - 1]: + candidates = [dp[i - 1][j - 1]] + else: + cost, sub, dele, ins = dp[i - 1][j - 1] + candidates = [(cost + 1, sub + 1, dele, ins)] + + cost, sub, dele, ins = dp[i - 1][j] + candidates.append((cost + 1, sub, dele + 1, ins)) + + cost, sub, dele, ins = dp[i][j - 1] + candidates.append((cost + 1, sub, dele, ins + 1)) + + dp[i][j] = min(candidates, key=lambda item: (item[0], item[1], item[2], item[3])) + + _, substitutions, deletions, insertions = dp[-1][-1] + return substitutions, deletions, insertions + + +def word_error_rate(expected: str, actual: str) -> tuple[float, int, int, int]: + expected_tokens = normalize_text(expected).split() + actual_tokens = normalize_text(actual).split() + if not expected_tokens: + return (0.0 if not actual_tokens else 1.0, 0, 0, len(actual_tokens)) + + substitutions, deletions, insertions = _edit_counts(expected_tokens, actual_tokens) + wer = (substitutions + deletions + insertions) / len(expected_tokens) + return wer, substitutions, deletions, insertions + + +def compare_text( + *, + expected: str, + actual: str, + critical_terms: list[str] | tuple[str, ...], +) -> TextComparison: + normalized_expected = normalize_text(expected) + normalized_actual = normalize_text(actual) + actual_tokens = set(normalized_actual.split()) + missing_terms = tuple( + term + for term in (normalize_text(item) for item in critical_terms) + if term and term not in actual_tokens + ) + wer, substitutions, deletions, insertions = word_error_rate(expected, actual) + return TextComparison( + expected=expected, + actual=actual, + normalized_expected=normalized_expected, + normalized_actual=normalized_actual, + wer=wer, + substitutions=substitutions, + deletions=deletions, + insertions=insertions, + missing_terms=missing_terms, + ) diff --git a/src/app/tools/local_stresstest/timeline.py b/src/app/tools/local_stresstest/timeline.py new file mode 100644 index 0000000..39ed34f --- /dev/null +++ b/src/app/tools/local_stresstest/timeline.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any + + +IMPORTANT_EVENTS = { + "ready_sent", + "client_audio_first_frame_received", + "client_audio_first_frame_published", + "client_audio_activity_detected", + "agent_join", + "user_transcript_final", + "stt_final", + "stt_http_completed", + "tts_stage_started", + "tts_stage_result", + "done_packet_received", + "stop_sent", + "bridge_failed", + "call_end", +} + +ERROR_EVENTS = { + "bridge_failed", + "stt_http_failed", + "stt_http_failed_nonfatal", + "tts_error", + "tts_stage_failed", + "agent_reconnect_exhausted", +} + + +def read_timeline(path: Path | str) -> list[dict[str, Any]]: + timeline_path = Path(path) + if not timeline_path.exists(): + return [] + records: list[dict[str, Any]] = [] + for line in timeline_path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + records.append(payload) + return records + + +def wait_for_timeline(path: Path | str, *, timeout_s: float = 5.0) -> list[dict[str, Any]]: + deadline = time.monotonic() + timeout_s + timeline_path = Path(path) + while time.monotonic() < deadline: + records = read_timeline(timeline_path) + if records: + return records + time.sleep(0.1) + return read_timeline(timeline_path) + + +def excerpt_timeline(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + record + for record in records + if str(record.get("event") or "") in IMPORTANT_EVENTS + ] + + +def timeline_has_error(records: list[dict[str, Any]]) -> bool: + return any(str(record.get("event") or "") in ERROR_EVENTS for record in records) diff --git a/src/app/tools/oci_audio_download.py b/src/app/tools/oci_audio_download.py new file mode 100644 index 0000000..8201348 --- /dev/null +++ b/src/app/tools/oci_audio_download.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import argparse +import re +import sys +import zipfile +from dataclasses import dataclass, replace +from datetime import date +from pathlib import Path +from typing import Any, Iterable + +from app.utils.stt_audio_upload import OCIUploadConfig, _oci_client, _oci_upload_config_from_env + + +_SAFE_SESSION_ID = re.compile(r"^[A-Za-z0-9_.-]+$") + + +@dataclass(frozen=True, slots=True) +class DownloadResult: + prefix: str + output_dir: Path + object_count: int + downloaded_count: int + cached_count: int + total_bytes: int + zip_path: Path | None + + +def _parse_date(value: str) -> str: + try: + return date.fromisoformat(value).isoformat() + except ValueError as exc: + raise argparse.ArgumentTypeError("use uma data no formato AAAA-MM-DD") from exc + + +def _parse_session_id(value: str) -> str: + session_id = value.strip() + if not session_id or not _SAFE_SESSION_ID.fullmatch(session_id): + raise argparse.ArgumentTypeError( + "session-id deve conter apenas letras, numeros, ponto, hifen ou underscore" + ) + return session_id + + +def segments_prefix(day: str, session_id: str) -> str: + return f"{day}/{session_id}/" + + +def entire_calls_prefix(day: str) -> str: + return f"{day}/entire_call/" + + +def _list_objects(client: Any, config: OCIUploadConfig, prefix: str) -> list[Any]: + objects: list[Any] = [] + start: str | None = None + while True: + response = client.list_objects( + namespace_name=config.namespace, + bucket_name=config.bucket, + prefix=prefix, + start=start, + fields="name,size,etag", + ) + objects.extend( + item + for item in response.data.objects + if getattr(item, "name", "") and not item.name.endswith("/") + ) + start = getattr(response.data, "next_start_with", None) + if not start: + return objects + + +def _safe_relative_name(object_name: str, prefix: str) -> Path: + if not object_name.startswith(prefix): + raise ValueError(f"objeto fora do prefixo solicitado: {object_name}") + relative = Path(object_name[len(prefix) :]) + if not relative.parts or relative.is_absolute() or ".." in relative.parts: + raise ValueError(f"nome de objeto inseguro: {object_name}") + return relative + + +def _response_chunks(response: Any) -> Iterable[bytes]: + raw = getattr(getattr(response, "data", None), "raw", None) + if raw is not None and hasattr(raw, "stream"): + yield from raw.stream(1024 * 1024, decode_content=False) + return + data = getattr(response, "data", None) + content = getattr(data, "content", data) + if isinstance(content, bytes): + yield content + return + raise TypeError("resposta do Object Storage nao contem um corpo legivel") + + +def _create_zip(output_dir: Path, zip_path: Path) -> None: + zip_path.parent.mkdir(parents=True, exist_ok=True) + temporary = zip_path.with_suffix(zip_path.suffix + ".part") + try: + with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path in sorted(output_dir.rglob("*")): + if path.is_file() and not path.name.endswith(".part"): + archive.write(path, path.relative_to(output_dir)) + temporary.replace(zip_path) + finally: + temporary.unlink(missing_ok=True) + + +def download_prefix( + *, + client: Any, + config: OCIUploadConfig, + prefix: str, + output_dir: Path, + zip_path: Path | None, +) -> DownloadResult: + objects = _list_objects(client, config, prefix) + if not objects: + raise FileNotFoundError( + f"nenhum objeto encontrado em bucket={config.bucket} prefix={prefix}" + ) + + output_dir.mkdir(parents=True, exist_ok=True) + downloaded = cached = total_bytes = 0 + print(f"bucket={config.bucket} prefix={prefix} objetos={len(objects)}", flush=True) + + for index, item in enumerate(objects, 1): + destination = output_dir / _safe_relative_name(item.name, prefix) + destination.parent.mkdir(parents=True, exist_ok=True) + expected_size = int(getattr(item, "size", 0) or 0) + if destination.exists() and expected_size and destination.stat().st_size == expected_size: + cached += 1 + total_bytes += expected_size + print(f"[{index}/{len(objects)}] cache {item.name}", flush=True) + continue + + temporary = destination.with_suffix(destination.suffix + ".part") + try: + response = client.get_object( + namespace_name=config.namespace, + bucket_name=config.bucket, + object_name=item.name, + ) + with temporary.open("wb") as handle: + for chunk in _response_chunks(response): + handle.write(chunk) + actual_size = temporary.stat().st_size + if expected_size and actual_size != expected_size: + raise IOError( + f"tamanho divergente para {item.name}: " + f"esperado={expected_size} recebido={actual_size}" + ) + temporary.replace(destination) + finally: + temporary.unlink(missing_ok=True) + + downloaded += 1 + total_bytes += destination.stat().st_size + print(f"[{index}/{len(objects)}] baixado {item.name}", flush=True) + + if zip_path is not None: + _create_zip(output_dir, zip_path) + print(f"zip={zip_path} bytes={zip_path.stat().st_size}", flush=True) + + return DownloadResult( + prefix, output_dir, len(objects), downloaded, cached, total_bytes, zip_path + ) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Baixa audios do OCI Object Storage usados pelo TIA." + ) + parser.add_argument("--bucket", help="sobrescreve BUCKET_NAME (dev, fqa ou prd)") + parser.add_argument( + "--output-root", + type=Path, + default=Path("recordings/object_storage"), + help="diretorio raiz (padrao: recordings/object_storage)", + ) + parser.add_argument("--no-zip", action="store_true", help="nao gera o ZIP final") + commands = parser.add_subparsers(dest="command", required=True) + segments = commands.add_parser("segments", help="segmentos de uma chamada") + segments.add_argument("--date", required=True, type=_parse_date) + segments.add_argument("--session-id", required=True, type=_parse_session_id) + entire = commands.add_parser("entire-calls", help="chamadas completas de um dia") + entire.add_argument("--date", required=True, type=_parse_date) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = _parser().parse_args(argv) + config = _oci_upload_config_from_env() + if config is None: + print("erro: configuracao OCI BUCKET_* incompleta", file=sys.stderr) + return 2 + if args.bucket: + config = replace(config, bucket=args.bucket.strip()) + + if args.command == "segments": + prefix = segments_prefix(args.date, args.session_id) + output_dir = args.output_root / args.date / "segments" / args.session_id + zip_path = None if args.no_zip else args.output_root / f"{args.date}_{args.session_id}_segments.zip" + else: + prefix = entire_calls_prefix(args.date) + output_dir = args.output_root / args.date / "entire_call" + zip_path = None if args.no_zip else args.output_root / f"{args.date}_entire_calls.zip" + + try: + result = download_prefix( + client=_oci_client(config), + config=config, + prefix=prefix, + output_dir=output_dir, + zip_path=zip_path, + ) + except Exception as exc: + print(f"erro: {exc}", file=sys.stderr) + return 1 + print( + f"concluido objetos={result.object_count} baixados={result.downloaded_count} " + f"cache={result.cached_count} bytes={result.total_bytes}", + flush=True, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/app/tools/oci_logs_cli_search.py b/src/app/tools/oci_logs_cli_search.py new file mode 100644 index 0000000..75772a5 --- /dev/null +++ b/src/app/tools/oci_logs_cli_search.py @@ -0,0 +1,678 @@ +from __future__ import annotations + +import argparse +import configparser +import csv +import json +import re +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from zoneinfo import ZoneInfo + + +DEFAULT_LOG_SCOPE = ( + "ocid1.compartment.oc1..aaaaaaaau5raupdqclblygmdikwcqt2mglgrfkgdmca6kleven5maaqau22q/" + "ocid1.loggroup.oc1.sa-saopaulo-1.amaaaaaaaehl73aaude3eznx7pbzbilwwzay5ou2mcccr5aj5tz6rlbyamua" +) +DEFAULT_REGION = "sa-saopaulo-1" +TIA_AGENT_SUBJECT = "tim-ai-atend-agnt-integ-tia-app-agent/0.log" +TIA_BRIDGE_SUBJECT = "tim-ai-atend-agnt-integ-tia-app-bridge/0.log" +CONTAS_NAMESPACE = "agnt-ai-atendimento-contas" + +VAD_STT_PATTERNS = [ + "step=vad_activity", + "step=vad_speech_start", + "step=vad_speech_end", + "step=vad_user_pause", + "step=vad_interrupt_check", + "step=stt_audio_upload", + "step=stt_final", + "USER_STATE", + "interrupt", + "tts_start", + "tts_done", + "BOT_SAY", + "agent_input_snapshot", +] + +IMPORTANT_EVENTS = { + "vad_speech_start", + "vad_speech_end", + "vad_user_pause", + "vad_interrupt_check", + "stt_audio_upload_enqueued", + "stt_audio_upload_done", + "stt_final", + "USER_STATE", + "tts_start", + "tts_done", + "BOT_SAY_HANDLE", + "BOT_SAY_RESULT", + "agent_input_snapshot", +} + +LOG_LINE_RE = re.compile(r"^(?P\S+)\s+(?Pstdout|stderr)\s+(?P[FP])\s(?P.*)$") +KV_RE = re.compile(r"(?P[a-zA-Z_][a-zA-Z0-9_]*)=(?P[^|]+)") + + +def clean(value: str | None) -> str: + return (value or "").strip() + + +def quote(value: str) -> str: + return value.replace("\\", "\\\\").replace("'", "\\'") + + +def contains_wildcard(field: str, value: str) -> str: + return f"{field} = '*{quote(value)}*'" + + +def equals_value(field: str, value: str) -> str: + return f"{field} = '{quote(value)}'" + + +def starts_with_value(field: str, value: str) -> str: + return f"{field} = '{quote(value)}*'" + + +def contains_any(field: str, values: list[str]) -> str: + unique_values: list[str] = [] + for value in values: + value = clean(value) + if value and value not in unique_values: + unique_values.append(value) + return "(" + " or ".join(contains_wildcard(field, value) for value in unique_values) + ")" + + +def _filter_attr(item: Any, name: str, default: str = "") -> str: + if isinstance(item, dict): + return clean(str(item.get(name, default) or "")) + return clean(str(getattr(item, name, default) or "")) + + +def _simple_filter_values(field_name: str, value: str) -> tuple[str, list[str]]: + query_field = "data.message" + if field_name in {"subject", "container"}: + query_field = "subject" + + values = [value] + if field_name == "event" and value and "=" not in value: + values.append(f"step={value}") + if field_name in {"session_id", "message_id"}: + compact = hyphenless(value) + if compact: + values.append(compact) + return query_field, values + + +def simple_filter_expression(item: Any) -> str: + field_name = _filter_attr(item, "field", "message") + operator = _filter_attr(item, "operator", "contains") + value = _filter_attr(item, "value") + if not value: + return "" + + query_field, values = _simple_filter_values(field_name, value) + if operator == "not_contains": + return "not " + contains_any(query_field, values) + if operator == "equals": + return "(" + " or ".join(equals_value(query_field, item_value) for item_value in values) + ")" + if operator == "starts_with": + return "(" + " or ".join(starts_with_value(query_field, item_value) for item_value in values) + ")" + return contains_any(query_field, values) + + +def simple_filters_group(filters: list[Any]) -> str: + parts: list[str] = [] + for item in filters: + expression = simple_filter_expression(item) + if not expression: + continue + connector = _filter_attr(item, "connector", "and").lower() + connector = "or" if connector == "or" else "and" + if not parts: + parts.append(expression) + else: + parts.append(f"{connector} {expression}") + if not parts: + return "" + return "(" + " ".join(parts) + ")" + + +def hyphenless(value: str) -> str | None: + clean_value = clean(value) + if re.match(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", clean_value): + return clean_value.replace("-", "") + return None + + +def normalize_time(value: str, time_zone: str) -> str: + raw = clean(value) + if not raw: + raise ValueError("Horario vazio.") + raw = raw.replace(" ", "T") + if raw.endswith("Z"): + parsed = datetime.fromisoformat(raw[:-1] + "+00:00") + else: + parsed = datetime.fromisoformat(raw) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=ZoneInfo(time_zone)) + return parsed.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def windows_path_to_wsl(value: str) -> str: + match = re.match(r"^([a-zA-Z]):[\\/](.*)$", value.strip()) + if not match: + return value + drive = match.group(1).lower() + rest = match.group(2).replace("\\", "/") + return f"/mnt/{drive}/{rest}" + + +def pem_fingerprint(path: Path) -> str | None: + try: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.serialization import load_pem_private_key + + key = load_pem_private_key(path.read_bytes(), password=None) + der = key.public_key().public_bytes( + serialization.Encoding.DER, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + return ":".join(f"{byte:02x}" for byte in __import__("hashlib").md5(der).digest()) + except Exception: + return None + + +def find_matching_key_file(config_file: Path, key_file: str, fingerprint: str) -> str | None: + expected = clean(fingerprint).lower() + if not expected: + return None + + current = Path(windows_path_to_wsl(key_file)).expanduser() + if current.exists() and pem_fingerprint(current) == expected: + return str(current) + + search_dirs = [] + for candidate_dir in (current.parent, config_file.parent): + if candidate_dir and candidate_dir.exists() and candidate_dir not in search_dirs: + search_dirs.append(candidate_dir) + + matches: list[Path] = [] + for directory in search_dirs: + for candidate in directory.glob("*.pem"): + if pem_fingerprint(candidate) == expected: + matches.append(candidate) + + unique_matches = sorted({match.resolve(): match for match in matches}.values()) + if len(unique_matches) == 1: + return str(unique_matches[0]) + return None + + +def config_profile_value(config: configparser.RawConfigParser, profile: str | None, key: str) -> str: + profile_name = profile or "DEFAULT" + if profile_name == "DEFAULT": + return config.defaults().get(key, "") + return config.get(profile_name, key, fallback="") + + +def prepare_cli_config(config_file: str | None, profile: str | None) -> str | None: + if not config_file or sys.platform.startswith("win"): + return config_file + + source = Path(config_file).expanduser() + if not source.exists(): + return config_file + + original = source.read_text(encoding="utf-8") + + def replace_path(match: re.Match[str]) -> str: + key = match.group("key") + value = match.group("value").strip() + converted = windows_path_to_wsl(value) + return f"{key}{converted}" + + converted = re.sub( + r"^(?P\s*(?:key_file|security_token_file)\s*=\s*)(?P[a-zA-Z]:[^\r\n]+)$", + replace_path, + original, + flags=re.MULTILINE, + ) + parser = configparser.RawConfigParser() + parser.read_string(converted) + key_file = config_profile_value(parser, profile, "key_file") + fingerprint = config_profile_value(parser, profile, "fingerprint") + matching_key_file = find_matching_key_file(source, key_file, fingerprint) + if matching_key_file and matching_key_file != key_file: + converted = re.sub( + r"^(?P\s*key_file\s*=\s*)(?P[^\r\n]+)$", + lambda match: f"{match.group('key')}{matching_key_file}", + converted, + count=1, + flags=re.MULTILINE, + ) + if converted == original: + return config_file + + handle = tempfile.NamedTemporaryFile("w", encoding="utf-8", prefix="oci_config_wsl_", delete=False) + with handle: + handle.write(converted) + return handle.name + + +def build_search_query(args: argparse.Namespace) -> str: + subjects = [] + app_scope = getattr(args, "app_scope", "tia") + if app_scope in {"tia", "all"}: + if args.include_agent: + subjects.append(contains_wildcard("subject", TIA_AGENT_SUBJECT)) + if args.include_bridge: + subjects.append(contains_wildcard("subject", TIA_BRIDGE_SUBJECT)) + if app_scope in {"contas", "all"}: + subjects.append(contains_wildcard("logContent", CONTAS_NAMESPACE)) + if not subjects: + raise ValueError("Selecione pelo menos uma aplicacao ou componente para consultar.") + + where_parts = ["(" + " or ".join(subjects) + ")"] + if not args.show_health: + where_parts.append("not (" + contains_wildcard("data.message", "GET /health") + ")") + + if args.event_preset == "vad-stt": + where_parts.append(contains_any("data.message", VAD_STT_PATTERNS)) + + id_filter_parts: list[str] = [] + for arg_name, field_name in ( + ("session_id", "data.message"), + ("message_id", "data.message"), + ): + value = clean(getattr(args, arg_name)) + if not value: + continue + values = [value] + compact = hyphenless(value) + if compact: + values.append(compact) + id_filter_parts.append(contains_any(field_name, values)) + + for arg_name in ("room", "protocol", "call_id_ged"): + value = clean(getattr(args, arg_name)) + if value: + id_filter_parts.append(contains_wildcard("data.message", value)) + + if id_filter_parts: + id_filter = "(" + " and ".join(id_filter_parts) + ")" + if args.event_preset == "vad-stt" and not args.strict_id_filter: + raw_vad_filter = contains_any( + "data.message", + [ + "step=vad_activity", + "step=vad_speech_start", + "step=vad_speech_end", + "step=vad_user_pause", + "step=vad_interrupt_check", + "USER_STATE", + ], + ) + where_parts.append(f"({id_filter} or {raw_vad_filter})") + else: + where_parts.append(id_filter) + + for text in args.text or []: + if clean(text): + where_parts.append(contains_wildcard("data.message", text)) + + simple_group = simple_filters_group(list(getattr(args, "simple_filters", []) or [])) + if simple_group: + where_parts.append(simple_group) + + lines = [f'search "{clean(args.log_scope)}"'] + lines.extend(f"| where {part}" for part in where_parts) + lines.append("| select datetime, subject, data.message as message") + lines.append(f"| sort by datetime {args.sort}") + return "\n".join(lines) + + +def next_page_token(payload: dict[str, Any]) -> str | None: + for key in ("opc-next-page", "opcNextPage", "nextPage"): + value = payload.get(key) + if value: + return str(value) + headers = payload.get("headers") + if isinstance(headers, dict): + for key in ("opc-next-page", "opcNextPage", "nextPage"): + value = headers.get(key) + if value: + return str(value) + data = payload.get("data") + if isinstance(data, dict): + for key in ("opc-next-page", "opcNextPage", "nextPage"): + value = data.get(key) + if value: + return str(value) + return None + + +def extract_results(payload: dict[str, Any]) -> list[Any]: + data = payload.get("data", payload) + if isinstance(data, dict): + results = data.get("results") + if isinstance(results, list): + return results + if isinstance(data, list): + return data + return [] + + +def extract_summary(payload: dict[str, Any]) -> dict[str, Any]: + data = payload.get("data", payload) + if isinstance(data, dict) and isinstance(data.get("summary"), dict): + return data["summary"] + return {} + + +def run_oci_page(args: argparse.Namespace, query: str, time_start: str, time_end: str, page: str | None) -> dict[str, Any]: + python_dir = Path(sys.executable).parent + venv_dir = Path(sys.prefix) / ("Scripts" if sys.platform.startswith("win") else "bin") + oci_candidates = [ + shutil.which("oci"), + shutil.which("oci.exe"), + str(python_dir / "oci"), + str(python_dir / "oci.exe"), + str(python_dir / "oci.cmd"), + str(venv_dir / "oci"), + str(venv_dir / "oci.exe"), + str(venv_dir / "oci.cmd"), + ] + oci_bin = args.oci_bin or next((candidate for candidate in oci_candidates if candidate and Path(candidate).exists()), None) + if not oci_bin: + raise RuntimeError( + "Nao encontrei o binario 'oci'. Instale a OCI CLI, instale 'oci-cli' neste venv, " + "ou passe --oci-bin com o caminho do executavel." + ) + + command = [ + oci_bin, + "logging-search", + "search-logs", + "--region", + args.region, + "--time-start", + time_start, + "--time-end", + time_end, + "--search-query", + query, + "--limit", + str(args.limit), + "--output", + "json", + ] + if args.profile: + command.extend(["--profile", args.profile]) + if args.config_file: + command.extend(["--config-file", args.config_file]) + if page: + command.extend(["--page", page]) + + env = { + **dict(__import__("os").environ), + "SUPPRESS_LABEL_WARNING": "True", + "OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING": "True", + } + completed = subprocess.run(command, check=False, capture_output=True, text=True, encoding="utf-8", env=env) + if completed.returncode != 0: + raise RuntimeError( + "OCI CLI falhou com exit code " + f"{completed.returncode}.\nSTDERR:\n{completed.stderr.strip()}\nSTDOUT:\n{completed.stdout.strip()}" + ) + try: + return json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise RuntimeError(f"OCI CLI nao retornou JSON valido:\n{completed.stdout[:2000]}") from exc + + +def result_data(result: Any) -> dict[str, Any]: + if not isinstance(result, dict): + return {} + data = result.get("data") or result + if not isinstance(data, dict): + return {} + log_content = data.get("logContent") if isinstance(data.get("logContent"), dict) else {} + message = data.get("message") or data.get("data.message") or "" + if not message and isinstance(log_content.get("data"), dict): + message = (log_content.get("data") or {}).get("message", "") + return { + "datetime": data.get("datetime") or log_content.get("time") or "", + "subject": data.get("subject") or log_content.get("subject") or "", + "message": message, + } + + +def field(body: str, key: str) -> str | None: + match = re.search(rf"(?:^|\|)\s*{re.escape(key)}=([^|]+)", body) + if match: + return match.group(1).strip() + return None + + +def parse_message(message: str) -> dict[str, Any]: + parsed: dict[str, Any] = { + "log_time": "", + "body": message or "", + "event": "", + "fields": {}, + } + match = LOG_LINE_RE.match(message or "") + if match: + parsed.update(match.groupdict()) + body = str(parsed.get("body") or "") + fields = {item.group("key"): item.group("value").strip() for item in KV_RE.finditer(body)} + parsed["fields"] = fields + + event = fields.get("step") + if not event and body.startswith("{"): + try: + event = json.loads(body).get("tipo_evento") or "json" + except json.JSONDecodeError: + event = "json" + if not event: + event = body.split("|", 1)[0].strip() + parsed["event"] = event[:100] + return parsed + + +def normalize_row(result: Any) -> dict[str, Any]: + data = result_data(result) + parsed = parse_message(str(data.get("message") or "")) + return { + "datetime": str(data.get("datetime") or ""), + "subject": str(data.get("subject") or ""), + "log_time": parsed["log_time"], + "event": parsed["event"], + "body": parsed["body"], + "fields": parsed["fields"], + "message": data.get("message") or "", + } + + +def compact_row(row: dict[str, Any]) -> str: + fields = row.get("fields") or {} + details = [] + for key in ( + "old", + "new", + "message_id", + "duration_ms", + "original_audio_ms", + "sent_audio_ms", + "probability", + "speaking", + "speech_duration_ms", + "silence_ms", + "raw_speech_ms", + "raw_silence_ms", + "eligible", + "text", + "reason", + "stage", + ): + if key in fields: + details.append(f"{key}={fields[key]}") + if not details and row["body"].startswith("{"): + try: + payload = json.loads(row["body"]) + for key in ("tipo_evento", "message_id", "callid", "session_id", "mensagem"): + if payload.get(key): + details.append(f"{key}={payload[key]}") + except json.JSONDecodeError: + pass + if not details: + details.append(row["body"][:220]) + return " | ".join(details) + + +def print_summary(rows: list[dict[str, Any]]) -> None: + print(f"linhas={len(rows)}") + if rows: + print(f"primeira={rows[0].get('log_time') or rows[0].get('datetime')}") + print(f"ultima={rows[-1].get('log_time') or rows[-1].get('datetime')}") + + vad_rows = [row for row in rows if row.get("event") == "vad_activity"] + if vad_rows: + def probability(row: dict[str, Any]) -> float: + try: + return float((row.get("fields") or {}).get("probability") or 0) + except ValueError: + return 0.0 + + max_row = max(vad_rows, key=probability) + print("") + print("vad_activity") + print(f"count={len(vad_rows)}") + print(f"max_prob={probability(max_row):.3f} at={max_row.get('log_time') or max_row.get('datetime')}") + for threshold in (0.30, 0.20, 0.10): + print(f"above_{threshold:.2f}={sum(1 for row in vad_rows if probability(row) >= threshold)}") + print(f"speaking_true={sum(1 for row in vad_rows if (row.get('fields') or {}).get('speaking') == 'True')}") + + print("") + print("timeline") + for row in rows: + event = str(row.get("event") or "") + if event in IMPORTANT_EVENTS or "interrupt" in event: + print(f"{row.get('log_time') or row.get('datetime')} | {event} | {compact_row(row)}") + + +def write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def write_csv(path: Path, rows: list[dict[str, Any]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=["datetime", "log_time", "event", "subject", "body"]) + writer.writeheader() + for row in rows: + writer.writerow({key: row.get(key, "") for key in writer.fieldnames or []}) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Busca logs da OCI via OCI CLI e resume eventos VAD/STT.") + parser.add_argument("--time-start", "--start", required=True, help="Inicio da janela. Use UTC ou informe --timezone.") + parser.add_argument("--time-end", "--end", required=True, help="Fim da janela. Use UTC ou informe --timezone.") + parser.add_argument("--timezone", default="UTC", help="Timezone usado quando os horarios vierem sem offset. Ex: America/Sao_Paulo.") + parser.add_argument("--region", default=DEFAULT_REGION) + parser.add_argument("--log-scope", default=DEFAULT_LOG_SCOPE) + parser.add_argument("--profile", default=None) + parser.add_argument("--config-file", default=None) + parser.add_argument("--oci-bin", default=None) + parser.add_argument("--limit", type=int, default=1000) + parser.add_argument("--max-pages", type=int, default=20) + parser.add_argument("--max-results", type=int, default=10000) + parser.add_argument("--sort", choices=["asc", "desc"], default="asc") + parser.add_argument("--include-agent", action=argparse.BooleanOptionalAction, default=True) + parser.add_argument("--include-bridge", action=argparse.BooleanOptionalAction, default=False) + parser.add_argument("--show-health", action="store_true") + parser.add_argument("--event-preset", choices=["vad-stt", "all"], default="vad-stt") + parser.add_argument( + "--strict-id-filter", + action="store_true", + help="Aplica session/call/message id de forma estrita. Por padrao, VAD cru tambem entra na janela.", + ) + parser.add_argument("--session-id") + parser.add_argument("--message-id") + parser.add_argument("--room") + parser.add_argument("--protocol") + parser.add_argument("--call-id-ged") + parser.add_argument("--text", action="append", help="Filtro livre em data.message. Pode repetir.") + parser.add_argument("--output", default=".tmp/oci_logs_cli_search.json") + parser.add_argument("--csv", dest="csv_path", default=None) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--no-summary", action="store_true") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + try: + time_start = normalize_time(args.time_start, args.timezone) + time_end = normalize_time(args.time_end, args.timezone) + query = build_search_query(args) + args.config_file = prepare_cli_config(args.config_file, args.profile) + except Exception as exc: + print(f"Erro nos argumentos: {exc}", file=sys.stderr) + return 2 + + if args.dry_run: + print("time_start=" + time_start) + print("time_end=" + time_end) + print(query) + return 0 + + all_results: list[Any] = [] + summaries: list[dict[str, Any]] = [] + page = None + try: + for page_number in range(1, args.max_pages + 1): + payload = run_oci_page(args, query, time_start, time_end, page) + results = extract_results(payload) + summaries.append(extract_summary(payload)) + all_results.extend(results) + print(f"pagina={page_number} resultados={len(results)} total={len(all_results)}") + page = next_page_token(payload) + if not page or len(all_results) >= args.max_results: + break + except Exception as exc: + print(str(exc), file=sys.stderr) + return 1 + + rows = [normalize_row(result) for result in all_results] + output_payload = { + "query": query, + "time_start": time_start, + "time_end": time_end, + "result_count": len(rows), + "summaries": summaries, + "rows": rows, + } + output_path = Path(args.output) + write_json(output_path, output_payload) + print(f"json={output_path}") + if args.csv_path: + csv_path = Path(args.csv_path) + write_csv(csv_path, rows) + print(f"csv={csv_path}") + if not args.no_summary: + print("") + print_summary(rows) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/app/tools/oci_logs_viewer.html b/src/app/tools/oci_logs_viewer.html new file mode 100644 index 0000000..90d4dc3 --- /dev/null +++ b/src/app/tools/oci_logs_viewer.html @@ -0,0 +1,1282 @@ + + + + + + OCI TIA Logs + + + +

+

OCI TIA Logs

+ Pronto +
+ + + +
+ + +
+
+
+ Nenhuma busca executada + +
+
+ + + Pagina 0/0 + + + + +
+
+ +
+ + + + + + + + + + +
TempoContainerEventoMensagem
+
+ +
+
Selecione uma linha para ver a mensagem completa.
+
+
+
+ + + + diff --git a/src/app/tools/oci_logs_viewer.py b/src/app/tools/oci_logs_viewer.py new file mode 100644 index 0000000..d5c2620 --- /dev/null +++ b/src/app/tools/oci_logs_viewer.py @@ -0,0 +1,508 @@ +from __future__ import annotations + +import argparse +import json +import re +import os +import shutil +import subprocess +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Literal + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field + +from app.tools.oci_logs_cli_search import ( + DEFAULT_LOG_SCOPE, + DEFAULT_REGION, + CONTAS_NAMESPACE, + TIA_AGENT_SUBJECT, + TIA_BRIDGE_SUBJECT, + build_search_query as build_cli_search_query, + extract_results, + extract_summary, + next_page_token, + normalize_row, + prepare_cli_config, + run_oci_page, + windows_path_to_wsl, +) + + +LOG_LINE_RE = re.compile(r"^(?P\S+)\s+(?Pstdout|stderr)\s+(?P[FP])\s(?P.*)$") +KV_RE = re.compile(r"(?P[a-zA-Z_][a-zA-Z0-9_]*)=(?P[^|]+)") +UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + + +class CliAuth(BaseModel): + mode: Literal["cli"] = "cli" + config_file: str = "~/.oci/config" + profile: str = "DEFAULT" + region: str = DEFAULT_REGION + oci_bin: str | None = None + + +class SimpleFilter(BaseModel): + connector: Literal["and", "or"] = "and" + field: Literal[ + "message", + "subject", + "container", + "event", + "session_id", + "message_id", + "call_id_ged", + "room", + "protocol", + ] = "message" + operator: Literal["contains", "not_contains", "equals", "starts_with"] = "contains" + value: str = "" + + +class QueryPreviewRequest(BaseModel): + log_scope: str = DEFAULT_LOG_SCOPE + app_scope: Literal["tia", "contas", "all"] = "tia" + sort: Literal["asc", "desc"] = "asc" + include_agent: bool = True + include_bridge: bool = True + hide_health: bool = True + event_preset: Literal["all", "vad-stt"] = "all" + strict_id_filter: bool = False + session_id: str | None = None + message_id: str | None = None + room: str | None = None + protocol: str | None = None + call_id_ged: str | None = None + text: str | None = None + simple_filters: list[SimpleFilter] = Field(default_factory=list) + + +class LogSearchRequest(QueryPreviewRequest): + auth: CliAuth = Field(default_factory=CliAuth) + time_start: str + time_end: str + limit: int = Field(default=500, ge=1, le=1000) + page: str | None = None + auto_page: bool = True + max_pages: int = Field(default=20, ge=1, le=50) + + +class CodexAnalyzeRequest(BaseModel): + prompt: str = Field(min_length=1, max_length=2_000_000) + codex_bin: str | None = None + model: str | None = None + timeout_seconds: int = Field(default=180, ge=30, le=900) + + +def _clean(value: str | None) -> str: + return (value or "").strip() + + +def _api_error(title: str, message: str, hint: str | None = None, raw: str | None = None) -> dict[str, str | None]: + return { + "title": title, + "message": message, + "hint": hint, + "raw": raw, + } + + +def _parse_datetime(value: str, field_name: str) -> datetime: + raw = value.strip() + if not raw: + raise HTTPException(status_code=400, detail=f"{field_name} vazio.") + if raw.endswith("Z"): + raw = raw[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(raw) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"{field_name} invalido: {value}") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _rfc3339(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def _container_from_subject(subject: str) -> str: + match = re.search(r"/([^/]+)/0\.log$", subject or "") + return match.group(1) if match else "" + + +def _hyphenless(value: str) -> str | None: + clean = value.strip() + if UUID_RE.match(clean): + return clean.replace("-", "") + return None + + +def _query_args(filters: QueryPreviewRequest, auth: CliAuth | None = None, *, limit: int = 500) -> argparse.Namespace: + text = _clean(filters.text) + return argparse.Namespace( + log_scope=_clean(filters.log_scope) or DEFAULT_LOG_SCOPE, + app_scope=filters.app_scope, + sort=filters.sort, + include_agent=filters.include_agent, + include_bridge=filters.include_bridge, + show_health=not filters.hide_health, + event_preset=filters.event_preset, + strict_id_filter=filters.strict_id_filter, + session_id=_clean(filters.session_id) or None, + message_id=_clean(filters.message_id) or None, + room=_clean(filters.room) or None, + protocol=_clean(filters.protocol) or None, + call_id_ged=_clean(filters.call_id_ged) or None, + text=[text] if text else [], + simple_filters=[item.model_dump() for item in filters.simple_filters], + region=(auth.region if auth else DEFAULT_REGION), + profile=(auth.profile if auth else None), + config_file=(auth.config_file if auth else None), + oci_bin=(auth.oci_bin if auth else None), + limit=limit, + ) + + +def build_search_query(filters: QueryPreviewRequest) -> str: + return build_cli_search_query(_query_args(filters)) + + +def _ids_from_body(body: str) -> dict[str, str]: + ids: dict[str, str] = {} + for item in KV_RE.finditer(body): + key = item.group("key") + value = item.group("value").strip().strip('"').strip("'") + if key in { + "session_id", + "message_id", + "room", + "protocol", + "call_id_ged", + "phone_number", + "bridge", + "reason", + "ws_close_reason", + "ws_client_disconnected", + }: + ids[key] = value + + if body.startswith("{"): + try: + payload = json.loads(body) + except json.JSONDecodeError: + payload = {} + for key, payload_key in ( + ("session_id", "session_id"), + ("message_id", "message_id"), + ("call_id_ged", "callid"), + ): + value = payload.get(payload_key) + if value and key not in ids: + ids[key] = str(value) + + return ids + + +def _normalize_viewer_row(result: Any) -> dict[str, Any]: + row = normalize_row(result) + body = str(row.get("body") or "") + fields = row.get("fields") or {} + ids = _ids_from_body(body) + for key in ("session_id", "message_id", "room", "protocol", "call_id_ged", "phone_number", "reason"): + if fields.get(key) and key not in ids: + ids[key] = str(fields[key]) + return { + **row, + "container": _container_from_subject(str(row.get("subject") or "")), + "ids": ids, + "region": "", + } + + +def _summary_with_count(summary: dict[str, Any], count: int) -> dict[str, Any]: + return {**summary, "resultCount": count} + + +def _default_config_file() -> str: + home_config = Path("~/.oci/config").expanduser() + if home_config.exists(): + return str(home_config) + windows_configs = sorted(Path("/mnt/c/Users").glob("*/.oci/config")) if Path("/mnt/c/Users").exists() else [] + if len(windows_configs) == 1: + return str(windows_configs[0]) + return "~/.oci/config" + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[3] + + +def _candidate_codex_bins(explicit: str | None) -> list[str]: + candidates: list[str] = [] + if _clean(explicit): + raw = _clean(explicit) + candidates.append(raw) + if Path("/mnt/c").exists(): + candidates.append(windows_path_to_wsl(raw)) + + for name in ("codex", "codex.exe"): + found = shutil.which(name) + if found: + candidates.append(found) + + extension_roots = [Path.home() / ".vscode" / "extensions"] + windows_users = Path("/mnt/c/Users") + if windows_users.exists(): + extension_roots.extend(path / ".vscode" / "extensions" for path in windows_users.glob("*")) + + for root in extension_roots: + try: + candidates.extend( + str(path) + for path in sorted( + root.glob("openai.chatgpt-*/bin/windows-x86_64/codex.exe"), + reverse=True, + ) + ) + except OSError: + continue + + unique: list[str] = [] + seen: set[str] = set() + for candidate in candidates: + if candidate and candidate not in seen: + unique.append(candidate) + seen.add(candidate) + return unique + + +def _find_codex_bin(explicit: str | None) -> str | None: + for candidate in _candidate_codex_bins(explicit): + path = Path(candidate).expanduser() + if path.exists(): + return str(path) + found = shutil.which(candidate) + if found: + return found + return None + + +def _windows_path_from_wsl(value: str) -> str: + match = re.match(r"^/mnt/([a-zA-Z])/(.*)$", value) + if not match: + return value + drive = match.group(1).upper() + rest = match.group(2).replace("/", "\\") + return f"{drive}:\\{rest}" + + +def _codex_workdir_arg(codex_bin: str) -> str: + root = str(_repo_root()) + if codex_bin.lower().endswith(".exe"): + return _windows_path_from_wsl(root) + return root + + +def _codex_command(request: CodexAnalyzeRequest, codex_bin: str) -> list[str]: + command = [ + codex_bin, + "exec", + "--sandbox", + "read-only", + "--color", + "never", + "--skip-git-repo-check", + "--ephemeral", + "-C", + _codex_workdir_arg(codex_bin), + ] + model = _clean(request.model) + if model: + command.extend(["--model", model]) + command.append("-") + return command + + +app = FastAPI(title="OCI TIA Logs Viewer", version="2.0-cli") + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(Path(__file__).with_name("oci_logs_viewer.html")) + + +@app.get("/api/defaults") +def defaults() -> dict[str, Any]: + return { + "log_scope": DEFAULT_LOG_SCOPE, + "region": DEFAULT_REGION, + "agent_subject": TIA_AGENT_SUBJECT, + "bridge_subject": TIA_BRIDGE_SUBJECT, + "contas_namespace": CONTAS_NAMESPACE, + "auth_mode": "cli", + "config_file": _default_config_file(), + } + + +@app.post("/api/query") +def preview_query(request: QueryPreviewRequest) -> dict[str, str]: + try: + query = build_search_query(request) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"query": query} + + +@app.post("/api/search") +def search_logs(request: LogSearchRequest) -> dict[str, Any]: + time_start = _parse_datetime(request.time_start, "time_start") + time_end = _parse_datetime(request.time_end, "time_end") + if time_end <= time_start: + raise HTTPException(status_code=400, detail="time_end precisa ser maior que time_start.") + + query_request = QueryPreviewRequest( + **request.model_dump(exclude={"auth", "time_start", "time_end", "limit", "page", "auto_page", "max_pages"}) + ) + try: + query = build_search_query(query_request) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + cli_args = _query_args(query_request, request.auth, limit=request.limit) + try: + cli_args.config_file = prepare_cli_config(cli_args.config_file, cli_args.profile) + results: list[Any] = [] + summaries: list[dict[str, Any]] = [] + next_page = request.page + pages_fetched = 0 + while True: + payload = run_oci_page( + cli_args, + query, + _rfc3339(time_start), + _rfc3339(time_end), + next_page, + ) + results.extend(extract_results(payload)) + summaries.append(extract_summary(payload)) + pages_fetched += 1 + next_page = next_page_token(payload) + if request.page or not request.auto_page or not next_page or pages_fetched >= request.max_pages: + break + except RuntimeError as exc: + raise HTTPException( + status_code=502, + detail=_api_error( + "OCI CLI falhou", + str(exc), + "Confira o binario oci, profile/config, permissao IAM, regiao, escopo do log e sintaxe da query.", + ), + ) from exc + except Exception as exc: + raise HTTPException( + status_code=500, + detail=_api_error( + "Erro local ao consultar a OCI via CLI", + str(exc), + "Confira caminhos de config/PEM, WSL, rede/VPN e tente novamente.", + ), + ) from exc + + rows = [_normalize_viewer_row(item) for item in results] + summary = _summary_with_count(summaries[-1] if summaries else {}, len(rows)) + summary["pagesFetched"] = pages_fetched + summary["rowsFetched"] = len(rows) + return { + "query": query, + "time_start": _rfc3339(time_start), + "time_end": _rfc3339(time_end), + "rows": rows, + "summary": summary, + "headers": { + "opc_request_id": None, + "next_page": next_page, + }, + } + + +@app.post("/api/analyze") +def analyze_with_codex(request: CodexAnalyzeRequest) -> dict[str, Any]: + codex_bin = _find_codex_bin(request.codex_bin) + if not codex_bin: + raise HTTPException( + status_code=400, + detail=_api_error( + "Codex CLI nao encontrado", + "Nao encontrei o binario codex/codex.exe.", + "Preencha o caminho do Codex CLI na tela ou confira se ele esta no PATH.", + ), + ) + + command = _codex_command(request, codex_bin) + env = {**os.environ, "NO_COLOR": "1"} + try: + completed = subprocess.run( + command, + input=request.prompt, + text=True, + capture_output=True, + timeout=request.timeout_seconds, + cwd=_repo_root(), + env=env, + check=False, + ) + except subprocess.TimeoutExpired as exc: + raise HTTPException( + status_code=504, + detail=_api_error( + "Codex CLI excedeu o timeout", + f"A analise passou de {request.timeout_seconds}s.", + "Aumente o timeout ou reduza o numero de logs/char por log do pacote.", + ), + ) from exc + except OSError as exc: + raise HTTPException( + status_code=502, + detail=_api_error( + "Falha ao iniciar Codex CLI", + str(exc), + "Confira o caminho do binario Codex CLI e permissoes do ambiente.", + ), + ) from exc + + stdout = completed.stdout.strip() + stderr = completed.stderr.strip() + if completed.returncode != 0: + raise HTTPException( + status_code=502, + detail=_api_error( + "Codex CLI falhou", + stderr or stdout or f"Processo retornou codigo {completed.returncode}.", + "Confira login do Codex, rede, profile/modelo e tente novamente.", + raw="\n".join(part for part in (stdout, stderr) if part), + ), + ) + + return { + "analysis": stdout, + "stderr": stderr, + "codex_bin": codex_bin, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Local OCI Logging/Search viewer for TIA logs using OCI CLI.") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", default=8765, type=int) + args = parser.parse_args() + + import uvicorn + + uvicorn.run("app.tools.oci_logs_viewer:app", host=args.host, port=args.port, reload=False) + + +if __name__ == "__main__": + main() diff --git a/src/app/tools/sofya_transcribe.py b/src/app/tools/sofya_transcribe.py new file mode 100644 index 0000000..22108e9 --- /dev/null +++ b/src/app/tools/sofya_transcribe.py @@ -0,0 +1,1617 @@ +from __future__ import annotations + +import argparse +import asyncio +import copy +import json +import os +import time +import wave +from dataclasses import asdict +from pathlib import Path +from typing import Any + +import httpx +from dotenv import load_dotenv + +from app.common.call_config import resolve_vad_logging_overrides, resolve_vad_overrides +from app.providers.stt_config import override_config +from app.providers.stt_internal_livekit import ( + InternalHTTPSTT, + InternalSTTConfig, + extract_text_from_transcript, +) +from app.tools.local_stresstest.audio import ( + AudioSample, + audio_metrics, + pcm_to_wav_bytes, + vad_proxy_metrics, + write_wav, +) + + +# Ajuste estes valores direto no arquivo se quiser rodar sem flags. +DEFAULT_ENV_FILE = ".env.kube.fqa" +DEFAULT_AUDIO_PATH = "" +DEFAULT_OUTPUT_JSON = "" +DEFAULT_SENT_WAV_PATH = ".run/sofya-transcribe/sent_to_sofya.wav" +DEFAULT_SEGMENTS_DIR = ".run/sofya-transcribe/livekit_vad_segments" +DEFAULT_TRANSCRIPTS_OUTPUT = ".run/sofya-transcribe/silero_segments_transcripts.md" +DEFAULT_INPUT_CHANNEL = "cliente" +DEFAULT_CLIENT_CHANNEL = 1 +DEFAULT_AGENT_CHANNEL = 2 +DEFAULT_USE_LIVEKIT_VAD = True +DEFAULT_VAD_ONLY = False +DEFAULT_VAD_FRAME_MS = 20 +DEFAULT_VAD_TAIL_SILENCE_MS = 1600 + +DEFAULT_CALL_CONFIG = { + "callConfig": { + "vad": { + "minSpeechDuration": 0.15, + "activationThreshold": 0.30, + "deactivationThreshold": 0.15, + "minSilenceDuration": 1.0, + "prefixPaddingDuration": 1.0, + }, + "vadLogging": { + "logDecisions": True, + "logActivity": True, + "activityMinProbability": 0.03, + }, + "ws": { + "outputGain": 2.0, + }, + } +} + +# VAD do Sofya enviado dentro de override_config.processor.config.extra_params. +# Use None para herdar do env STT_VAD_* ou do override_config padrao. +DEFAULT_SOFYA_VAD_FILTER: bool | None = None +DEFAULT_SOFYA_VAD_THRESHOLD: float | None = None +DEFAULT_SOFYA_VAD_MIN_SILENCE_MS: int | None = None +DEFAULT_SOFYA_VAD_SPEECH_PAD_MS: int | None = None + +# Diagnostico local por RMS. Nao substitui o VAD do Sofya, so ajuda a enxergar +# onde o inicio de fala pode estar sendo comido. +DEFAULT_VAD_PROXY_THRESHOLD_DBFS = -45.0 +DEFAULT_VAD_PROXY_PREFIX_PADDING_MS: int | None = None +DEFAULT_VAD_PROXY_MIN_SPEECH_MS = 100 + +# Padding extra antes do WAV enviado ao Sofya, para simular STT_INPUT_PREFIX_PADDING_MS. +DEFAULT_STT_INPUT_PREFIX_PADDING_MS: int | None = None +DEFAULT_STT_INPUT_SUFFIX_PADDING_MS: int | None = None +DEFAULT_STT_PLAYBACK_SPEED = 0.85 +DEFAULT_STT_VOLUME_GAIN_DB = 6.0 +AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".ogg", ".m4a"} + + +def _env_str(name: str, default: str = "") -> str: + value = os.getenv(name) + return str(default if value is None else value).strip() + + +def _env_float(name: str, default: float | None = None) -> float | None: + value = _env_str(name) + if not value: + return default + try: + return float(value) + except ValueError: + return default + + +def _env_int(name: str, default: int | None = None) -> int | None: + value = _env_str(name) + if not value: + return default + try: + return int(value) + except ValueError: + return default + + +def _env_bool(name: str, default: bool | None = None) -> bool | None: + value = _env_str(name) + if not value: + return default + return value.lower() in {"1", "true", "yes", "y", "on", "sim"} + + +def _coalesce(*values: Any) -> Any: + for value in values: + if value is not None and value != "": + return value + return None + + +def _json_dumps(payload: Any) -> str: + return json.dumps(payload, ensure_ascii=False, indent=2) + + +def _float_override( + value: Any, + default: float, + *, + min_value: float | None = None, + max_value: float | None = None, +) -> float: + if value in (None, ""): + return default + try: + resolved = float(value) + except (TypeError, ValueError): + return default + if min_value is not None and resolved < min_value: + return default + if max_value is not None and resolved > max_value: + return default + return resolved + + +def _bool_override(value: Any, default: bool) -> bool: + if value in (None, ""): + return default + raw = str(value).strip().lower() + if raw in {"1", "true", "yes", "on", "sim"}: + return True + if raw in {"0", "false", "no", "off", "nao", "não"}: + return False + return default + + +def _default_livekit_vad_config() -> dict[str, float]: + activation_threshold = float(_env_float("VAD_ACTIVATION_THRESHOLD", 0.35) or 0.35) + prefix_padding_duration = float(_env_float("VAD_PREFIX_PADDING_DURATION", 1.5) or 1.5) + prefix_padding_min_duration = float(_env_float("VAD_PREFIX_PADDING_MIN_DURATION", 1.5) or 1.5) + return { + "min_speech_duration": float(_env_float("VAD_MIN_SPEECH_DURATION", 0.15) or 0.15), + "min_silence_duration": float( + _coalesce( + _env_float("VAD_MIN_SILENCE_DURATION"), + _env_float("LIVEKIT_VAD_MIN_SILENCE_DURATION_S"), + 0.35, + ) + ), + "activation_threshold": activation_threshold, + "deactivation_threshold": float( + _env_float( + "VAD_DEACTIVATION_THRESHOLD", + max(activation_threshold - 0.15, 0.01), + ) + or max(activation_threshold - 0.15, 0.01) + ), + "prefix_padding_duration": max(prefix_padding_duration, prefix_padding_min_duration), + } + + +def _default_livekit_vad_logging_config() -> dict[str, Any]: + return { + "log_decisions": _env_bool("FLOW_LOG_VAD_DECISIONS", False), + "log_activity": _env_bool("FLOW_LOG_VAD_ACTIVITY", False), + "activity_min_probability": float(_env_float("FLOW_LOG_VAD_ACTIVITY_MIN_PROB", 0.03) or 0.03), + } + + +def _call_config_payload(args: argparse.Namespace) -> dict[str, Any]: + if args.call_config_json: + payload = json.loads(args.call_config_json) + elif args.call_config_file: + payload = json.loads(Path(args.call_config_file).expanduser().read_text(encoding="utf-8")) + else: + payload = copy.deepcopy(DEFAULT_CALL_CONFIG) + if not isinstance(payload, dict): + raise ValueError("callConfig precisa ser um objeto JSON") + call_config = payload.get("callConfig", payload) + if not isinstance(call_config, dict): + raise ValueError("callConfig precisa ser um objeto JSON") + call_config = copy.deepcopy(call_config) + vad = call_config.setdefault("vad", {}) + if args.lk_min_speech_duration is not None: + vad["minSpeechDuration"] = args.lk_min_speech_duration + if args.lk_activation_threshold is not None: + vad["activationThreshold"] = args.lk_activation_threshold + if args.lk_deactivation_threshold is not None: + vad["deactivationThreshold"] = args.lk_deactivation_threshold + if args.lk_min_silence_duration is not None: + vad["minSilenceDuration"] = args.lk_min_silence_duration + if args.lk_prefix_padding_duration is not None: + vad["prefixPaddingDuration"] = args.lk_prefix_padding_duration + + vad_logging = call_config.setdefault("vadLogging", {}) + if args.lk_log_activity is not None: + vad_logging["logActivity"] = bool(args.lk_log_activity) + if args.lk_activity_min_probability is not None: + vad_logging["activityMinProbability"] = args.lk_activity_min_probability + return call_config + + +def _resolve_livekit_vad_config(args: argparse.Namespace) -> tuple[dict[str, float], dict[str, Any], dict[str, Any]]: + call_config = _call_config_payload(args) + defaults = _default_livekit_vad_config() + overrides = resolve_vad_overrides(call_config) + prefix_padding_min_duration = float(_env_float("VAD_PREFIX_PADDING_MIN_DURATION", 0.0) or 0.0) + vad_config = { + "min_speech_duration": _float_override( + overrides.get("min_speech_duration"), + defaults["min_speech_duration"], + min_value=0.0, + ), + "min_silence_duration": _float_override( + overrides.get("min_silence_duration"), + defaults["min_silence_duration"], + min_value=0.0, + ), + "activation_threshold": _float_override( + overrides.get("activation_threshold"), + defaults["activation_threshold"], + min_value=0.0, + max_value=1.0, + ), + "deactivation_threshold": _float_override( + overrides.get("deactivation_threshold"), + defaults["deactivation_threshold"], + min_value=0.0, + max_value=1.0, + ), + "prefix_padding_duration": _float_override( + overrides.get("prefix_padding_duration"), + defaults["prefix_padding_duration"], + min_value=prefix_padding_min_duration, + ), + } + + logging_defaults = _default_livekit_vad_logging_config() + logging_overrides = resolve_vad_logging_overrides(call_config) + logging_config = { + "log_decisions": _bool_override( + logging_overrides.get("log_decisions"), + bool(logging_defaults["log_decisions"]), + ), + "log_activity": _bool_override( + logging_overrides.get("log_activity"), + bool(logging_defaults["log_activity"]), + ), + "activity_min_probability": _float_override( + logging_overrides.get("activity_min_probability"), + float(logging_defaults["activity_min_probability"]), + min_value=0.0, + max_value=1.0, + ), + } + return vad_config, logging_config, call_config + + +def _build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Transcreve um arquivo de audio usando o STT Sofya HTTP do projeto.", + ) + parser.add_argument( + "--env-file", + default=DEFAULT_ENV_FILE, + help=f"Arquivo .env para carregar antes de chamar o Sofya. Default: {DEFAULT_ENV_FILE}", + ) + parser.add_argument( + "--override-env", + action="store_true", + help="Faz o .env sobrescrever variaveis ja existentes no processo.", + ) + parser.add_argument( + "--audio", + default=DEFAULT_AUDIO_PATH, + help="Caminho do arquivo ou diretorio de audios. Tambem pode usar SOFYA_AUDIO_FILE.", + ) + parser.add_argument( + "--audio-glob", + default="*.wav", + help="Padrao usado quando --audio aponta para diretorio. Ex.: '*.wav' ou '*.*'.", + ) + parser.add_argument( + "--input-channel", + default=DEFAULT_INPUT_CHANNEL, + help=( + "Canal do WAV usado antes do VAD/STT: mix, cliente/client/left/1 ou " + "agente/agent/right/2. Default: cliente." + ), + ) + mode_group = parser.add_mutually_exclusive_group() + mode_group.add_argument( + "--livekit-vad", + dest="use_livekit_vad", + action="store_true", + default=DEFAULT_USE_LIVEKIT_VAD, + help="Roda o audio pelo Silero VAD do LiveKit antes de chamar o Sofya. Default.", + ) + mode_group.add_argument( + "--direct-sofya", + dest="use_livekit_vad", + action="store_false", + help="Chama o Sofya direto com o audio inteiro, sem Silero VAD do LiveKit.", + ) + parser.add_argument( + "--vad-only", + action="store_true", + default=DEFAULT_VAD_ONLY, + help="Roda apenas o Silero VAD e salva os segmentos, sem chamar o Sofya.", + ) + parser.add_argument( + "--call-config-json", + default="", + help="JSON com callConfig para resolver os parametros de VAD do LiveKit.", + ) + parser.add_argument( + "--call-config-file", + default="", + help="Arquivo JSON com callConfig para resolver os parametros de VAD do LiveKit.", + ) + parser.add_argument("--url", default="", help="Override de STT_URL.") + parser.add_argument("--api-key", default="", help="Override de STT_KEY/STT_API_KEY.") + parser.add_argument("--language", default="", help="Override de STT_LANG.") + parser.add_argument("--timeout-s", type=float, default=None, help="Timeout HTTP do Sofya.") + parser.add_argument( + "--output-mode", + choices=["threshold_text", "api_text", "raw_json"], + default="", + help="Modo de saida do provider. Default vem de STT_OUTPUT_MODE ou threshold_text.", + ) + parser.add_argument( + "--min-prob-single-word", + type=float, + default=None, + help="Filtro local para palavra unica no modo threshold_text.", + ) + parser.add_argument( + "--initial-prompt", + default="", + help="Initial prompt enviado no override_config do Sofya.", + ) + + livekit_vad_group = parser.add_argument_group("Silero VAD do LiveKit") + livekit_vad_group.add_argument( + "--lk-min-speech-duration", + type=float, + default=None, + help="callConfig.vad.minSpeechDuration, em segundos.", + ) + livekit_vad_group.add_argument( + "--lk-activation-threshold", + type=float, + default=None, + help="callConfig.vad.activationThreshold.", + ) + livekit_vad_group.add_argument( + "--lk-deactivation-threshold", + type=float, + default=None, + help="callConfig.vad.deactivationThreshold.", + ) + livekit_vad_group.add_argument( + "--lk-min-silence-duration", + type=float, + default=None, + help="callConfig.vad.minSilenceDuration, em segundos.", + ) + livekit_vad_group.add_argument( + "--lk-prefix-padding-duration", + type=float, + default=None, + help="callConfig.vad.prefixPaddingDuration, em segundos.", + ) + livekit_vad_logging = livekit_vad_group.add_mutually_exclusive_group() + livekit_vad_logging.add_argument( + "--lk-log-activity", + dest="lk_log_activity", + action="store_true", + default=None, + help="Habilita logs/metricas de atividade do VAD no resumo.", + ) + livekit_vad_logging.add_argument( + "--no-lk-log-activity", + dest="lk_log_activity", + action="store_false", + help="Desabilita logs/metricas de atividade do VAD no resumo.", + ) + livekit_vad_group.add_argument( + "--lk-activity-min-probability", + type=float, + default=None, + help="callConfig.vadLogging.activityMinProbability.", + ) + livekit_vad_group.add_argument( + "--vad-frame-ms", + type=int, + default=DEFAULT_VAD_FRAME_MS, + help="Tamanho dos frames empurrados para o LiveKit VAD.", + ) + livekit_vad_group.add_argument( + "--vad-tail-silence-ms", + type=int, + default=DEFAULT_VAD_TAIL_SILENCE_MS, + help="Silencio ao final para forcar END_OF_SPEECH em audio sem cauda.", + ) + livekit_vad_group.add_argument( + "--segments-dir", + default=DEFAULT_SEGMENTS_DIR, + help="Diretorio onde os trechos cortados pelo LiveKit VAD serao salvos.", + ) + livekit_vad_group.add_argument( + "--transcripts-output", + default=DEFAULT_TRANSCRIPTS_OUTPUT, + help="Markdown com segmento Silero, WAV enviado ao Sofya e transcricao por segmento.", + ) + + vad_group = parser.add_argument_group("VAD interno do Sofya, nao e o Silero do LiveKit") + vad_filter = vad_group.add_mutually_exclusive_group() + vad_filter.add_argument( + "--vad-filter", + dest="vad_filter", + action="store_true", + default=DEFAULT_SOFYA_VAD_FILTER, + help="Envia vad_filter=true no extra_params do Sofya.", + ) + vad_filter.add_argument( + "--no-vad-filter", + dest="vad_filter", + action="store_false", + help="Envia vad_filter=false no extra_params do Sofya.", + ) + vad_group.add_argument( + "--vad-threshold", + type=float, + default=DEFAULT_SOFYA_VAD_THRESHOLD, + help="threshold em vad_parameters do Sofya. Ex.: 0.72", + ) + vad_group.add_argument( + "--vad-min-silence-ms", + type=int, + default=DEFAULT_SOFYA_VAD_MIN_SILENCE_MS, + help="min_silence_duration_ms em vad_parameters do Sofya.", + ) + vad_group.add_argument( + "--vad-speech-pad-ms", + type=int, + default=DEFAULT_SOFYA_VAD_SPEECH_PAD_MS, + help="speech_pad_ms em vad_parameters do Sofya.", + ) + vad_group.add_argument( + "--stt-input-prefix-padding-ms", + type=int, + default=DEFAULT_STT_INPUT_PREFIX_PADDING_MS, + help="Silencio adicionado antes do WAV enviado ao Sofya.", + ) + vad_group.add_argument( + "--stt-input-suffix-padding-ms", + type=int, + default=DEFAULT_STT_INPUT_SUFFIX_PADDING_MS, + help="Silencio adicionado no fim do WAV enviado ao Sofya.", + ) + vad_group.add_argument( + "--stt-playback-speed", + type=float, + default=None, + help=( + "Velocidade do audio enviado ao Sofya. 1.0 mantem original; " + "0.85 envia mais lento; 1.15 envia mais rapido." + ), + ) + vad_group.add_argument( + "--stt-volume-gain-db", + type=float, + default=None, + help=( + "Ganho em dB aplicado no audio enviado ao Sofya. " + "0 mantem original; 6 dobra aproximadamente a amplitude." + ), + ) + + proxy_group = parser.add_argument_group("Diagnostico VAD proxy local") + proxy_group.add_argument( + "--vad-proxy-threshold-dbfs", + type=float, + default=DEFAULT_VAD_PROXY_THRESHOLD_DBFS, + help="Limiar RMS local para diagnostico. Default: -45", + ) + proxy_group.add_argument( + "--vad-proxy-prefix-padding-ms", + type=int, + default=DEFAULT_VAD_PROXY_PREFIX_PADDING_MS, + help="Padding local do diagnostico. Default vem de VAD_PREFIX_PADDING_DURATION.", + ) + proxy_group.add_argument( + "--vad-proxy-min-speech-ms", + type=int, + default=DEFAULT_VAD_PROXY_MIN_SPEECH_MS, + help="Fala minima usada no diagnostico local.", + ) + + parser.add_argument( + "--save-sent-wav", + default=DEFAULT_SENT_WAV_PATH, + help="Salva o WAV normalizado que foi enviado ao Sofya. Use vazio para desativar.", + ) + parser.add_argument( + "--json-output", + default=DEFAULT_OUTPUT_JSON, + help="Opcional: salva um resumo JSON da execucao.", + ) + parser.add_argument( + "--print-override-config", + action="store_true", + help="Imprime o override_config final enviado ao Sofya.", + ) + return parser + + +def _audio_path_from_args(args: argparse.Namespace) -> Path: + raw_path = _coalesce(args.audio, _env_str("SOFYA_AUDIO_FILE"), _env_str("STRESS_AUDIO")) + if not raw_path: + raise RuntimeError( + "Informe o audio com --audio caminho.wav, SOFYA_AUDIO_FILE ou editando DEFAULT_AUDIO_PATH." + ) + return Path(str(raw_path)).expanduser() + + +def _resolve_audio_paths(args: argparse.Namespace) -> list[Path]: + root = _audio_path_from_args(args) + if root.is_dir(): + paths = [ + path + for path in sorted(root.glob(str(args.audio_glob or "*.wav"))) + if path.is_file() and path.suffix.lower() in AUDIO_EXTENSIONS + ] + if not paths: + raise RuntimeError(f"Nenhum audio encontrado em {root} com padrao {args.audio_glob!r}.") + return paths + return [root] + + +def _safe_stem(path: Path) -> str: + return "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in path.stem).strip("_") or "audio" + + +def _channel_spec_from_args(args: argparse.Namespace) -> str: + return str(_coalesce(args.input_channel, _env_str("SOFYA_INPUT_CHANNEL"), DEFAULT_INPUT_CHANNEL)).strip() + + +def _resolve_channel_index(channel_spec: str, channels: int) -> int | None: + spec = (channel_spec or DEFAULT_INPUT_CHANNEL).strip().lower() + if spec in {"mix", "mixed", "mono", "ambos", "todos", "all"}: + return None + if spec in {"cliente", "client", "customer", "left", "l"}: + channel_number = DEFAULT_CLIENT_CHANNEL if spec in {"cliente", "client", "customer"} else 1 + elif spec in {"agente", "agent", "right", "r"}: + channel_number = DEFAULT_AGENT_CHANNEL if spec in {"agente", "agent"} else 2 + else: + try: + channel_number = int(spec) + except ValueError as exc: + raise ValueError( + f"Canal invalido: {channel_spec}. Use mix, cliente, agente, left, right, 1 ou 2." + ) from exc + + if channel_number < 1 or channel_number > channels: + raise ValueError(f"Canal {channel_number} indisponivel: o audio tem {channels} canal(is).") + return channel_number - 1 + + +def _extract_pcm_channel(pcm: bytes, *, channels: int, channel_index: int) -> bytes: + if channels == 1: + return pcm + sample_width = 2 + frame_width = sample_width * channels + offset = channel_index * sample_width + out = bytearray() + for pos in range(0, len(pcm) - frame_width + 1, frame_width): + out.extend(pcm[pos + offset : pos + offset + sample_width]) + return bytes(out) + + +def _mix_pcm_channels(pcm: bytes, *, channels: int) -> bytes: + import audioop + + if channels == 1: + return pcm + if channels == 2: + return audioop.tomono(pcm, 2, 0.5, 0.5) + + sample_width = 2 + frame_width = sample_width * channels + out = bytearray() + for pos in range(0, len(pcm) - frame_width + 1, frame_width): + total = 0 + for channel_index in range(channels): + start = pos + (channel_index * sample_width) + total += int.from_bytes(pcm[start : start + sample_width], byteorder="little", signed=True) + average = max(-32768, min(32767, round(total / channels))) + out.extend(int(average).to_bytes(sample_width, byteorder="little", signed=True)) + return bytes(out) + + +def _resample_mono16(pcm: bytes, *, sample_rate: int, target_sample_rate: int = 16000) -> bytes: + import audioop + + if sample_rate == target_sample_rate: + return pcm + converted, _ = audioop.ratecv(pcm, 2, 1, sample_rate, target_sample_rate, None) + return converted + + +def _load_audio(path: Path, *, input_channel: str) -> AudioSample: + if not path.exists(): + raise FileNotFoundError(f"Audio nao encontrado: {path}") + try: + return _load_wav_audio(path, input_channel=input_channel) + except Exception as wav_error: + try: + return _load_with_soundfile(path, input_channel=input_channel) + except Exception as soundfile_error: + raise ValueError( + f"Nao consegui ler {path}. Esperado WAV PCM16, ou formato aceito pelo soundfile. " + f"wave={wav_error}; soundfile={soundfile_error}" + ) from soundfile_error + + +def _load_wav_audio(path: Path, *, input_channel: str) -> AudioSample: + with wave.open(str(path), "rb") as handle: + channels = handle.getnchannels() + sample_width = handle.getsampwidth() + sample_rate = handle.getframerate() + pcm = handle.readframes(handle.getnframes()) + + if sample_width != 2: + raise ValueError(f"{path} precisa ser PCM16 para leitura via wave, sample_width={sample_width}") + + channel_index = _resolve_channel_index(input_channel, channels) + if channel_index is None: + mono_pcm = _mix_pcm_channels(pcm, channels=channels) + channel_name = "mix" + else: + mono_pcm = _extract_pcm_channel(pcm, channels=channels, channel_index=channel_index) + channel_name = f"ch{channel_index + 1}" + mono_pcm = _resample_mono16(mono_pcm, sample_rate=sample_rate) + return AudioSample(name=f"{path.stem}_{channel_name}", pcm=mono_pcm, sample_rate=16000, channels=1) + + +def _load_with_soundfile(path: Path, *, input_channel: str) -> AudioSample: + import audioop + + import soundfile as sf + + data, sample_rate = sf.read(str(path), dtype="int16", always_2d=True) + if data.size <= 0: + raise ValueError(f"Audio vazio: {path}") + channels = int(data.shape[1]) + channel_index = _resolve_channel_index(input_channel, channels) + if channel_index is None: + data = data.mean(axis=1).astype("int16").reshape(-1, 1) + channel_name = "mix" + else: + data = data[:, channel_index].astype("int16").reshape(-1, 1) + channel_name = f"ch{channel_index + 1}" + pcm = data.tobytes() + if int(sample_rate) != 16000: + pcm, _ = audioop.ratecv(pcm, 2, 1, int(sample_rate), 16000, None) + sample_rate = 16000 + return AudioSample(name=f"{path.stem}_{channel_name}", pcm=pcm, sample_rate=int(sample_rate), channels=1) + + +def build_sofya_override_config(args: argparse.Namespace) -> dict[str, Any]: + cfg = copy.deepcopy(override_config) + processor = cfg.setdefault("processor", {}).setdefault("config", {}) + extra_params = processor.setdefault("extra_params", {}) + vad_params = extra_params.setdefault("vad_parameters", {}) + + vad_filter = _coalesce(args.vad_filter, _env_bool("STT_VAD_FILTER")) + if vad_filter is not None: + extra_params["vad_filter"] = bool(vad_filter) + + vad_threshold = _coalesce(args.vad_threshold, _env_float("STT_VAD_THRESHOLD")) + if vad_threshold is not None: + vad_params["threshold"] = float(vad_threshold) + + vad_min_silence_ms = _coalesce( + args.vad_min_silence_ms, + _env_int("STT_VAD_MIN_SILENCE_MS"), + ) + if vad_min_silence_ms is not None: + vad_params["min_silence_duration_ms"] = max(0, int(vad_min_silence_ms)) + + vad_speech_pad_ms = _coalesce( + args.vad_speech_pad_ms, + _env_int("STT_VAD_SPEECH_PAD_MS"), + ) + if vad_speech_pad_ms is not None: + vad_params["speech_pad_ms"] = max(0, int(vad_speech_pad_ms)) + + prompt = _coalesce( + args.initial_prompt, + _env_str("STT_FORCE_INITIAL_PROMPT"), + _env_str("STT_INITIAL_PROMPT"), + ) + if prompt: + extra_params["initial_prompt"] = str(prompt) + + return cfg + + +def _stt_config(args: argparse.Namespace, config_override: dict[str, Any]) -> InternalSTTConfig: + url = _coalesce(args.url, _env_str("STT_URL")) + if not url: + raise RuntimeError("STT_URL nao configurado. Use --url ou carregue um --env-file com STT_URL.") + + api_key = _coalesce(args.api_key, _env_str("STT_KEY"), _env_str("STT_API_KEY"), "unknow") + timeout_s = _coalesce(args.timeout_s, _env_float("STT_TIMEOUT_S"), 30.0) + min_prob_single_word = _coalesce( + args.min_prob_single_word, + _env_float("STT_MIN_PROB_SINGLE_WORD"), + 0.10, + ) + output_mode = _coalesce(args.output_mode, _env_str("STT_OUTPUT_MODE"), "threshold_text") + return InternalSTTConfig( + url=str(url), + api_key=str(api_key), + language=str(_coalesce(args.language, _env_str("STT_LANG"), "portuguese")), + timeout_s=float(timeout_s), + min_prob_single_word=float(min_prob_single_word), + config_override=json.dumps(config_override, ensure_ascii=False), + output_mode=str(output_mode), + ) + + +def _apply_prefix_padding(sample: AudioSample, padding_ms: int) -> tuple[AudioSample, int]: + padding_ms = max(0, int(padding_ms)) + if padding_ms <= 0: + return sample, 0 + frames = round(sample.sample_rate * padding_ms / 1000) + silence = b"\x00" * frames * sample.channels * 2 + if not silence: + return sample, 0 + actual_ms = round((frames / sample.sample_rate) * 1000) + return ( + AudioSample( + name=f"{sample.name}_stt_padded", + pcm=silence + sample.pcm, + sample_rate=sample.sample_rate, + channels=sample.channels, + ), + actual_ms, + ) + + +def _apply_suffix_padding(sample: AudioSample, padding_ms: int) -> tuple[AudioSample, int]: + padding_ms = max(0, int(padding_ms)) + if padding_ms <= 0: + return sample, 0 + frames = round(sample.sample_rate * padding_ms / 1000) + silence = b"\x00" * frames * sample.channels * 2 + if not silence: + return sample, 0 + actual_ms = round((frames / sample.sample_rate) * 1000) + return ( + AudioSample( + name=f"{sample.name}_stt_tail_padded", + pcm=sample.pcm + silence, + sample_rate=sample.sample_rate, + channels=sample.channels, + ), + actual_ms, + ) + + +def _apply_playback_speed(sample: AudioSample, speed: float) -> AudioSample: + speed = float(speed) + if speed <= 0: + raise ValueError("--stt-playback-speed precisa ser maior que zero") + if abs(speed - 1.0) < 0.0001: + return sample + + import audioop + + # Resample para outro numero de amostras e mantem o sample_rate original. + # Ex.: speed=0.85 gera mais amostras e o WAV toca mais lento. + resample_rate = max(1, round(sample.sample_rate / speed)) + pcm, _ = audioop.ratecv( + sample.pcm, + 2, + sample.channels, + sample.sample_rate, + resample_rate, + None, + ) + speed_name = f"{speed:.3f}".rstrip("0").rstrip(".").replace(".", "p") + return AudioSample( + name=f"{sample.name}_speed_{speed_name}x", + pcm=pcm, + sample_rate=sample.sample_rate, + channels=sample.channels, + ) + + +def _apply_volume_gain_db(sample: AudioSample, gain_db: float) -> AudioSample: + gain_db = float(gain_db) + if abs(gain_db) < 0.0001: + return sample + + import audioop + + factor = 10 ** (gain_db / 20.0) + pcm = audioop.mul(sample.pcm, 2, factor) + gain_name = ( + f"{gain_db:+.1f}db" + .replace("+", "plus") + .replace("-", "minus") + .replace(".", "p") + ) + return AudioSample( + name=f"{sample.name}_gain_{gain_name}", + pcm=pcm, + sample_rate=sample.sample_rate, + channels=sample.channels, + ) + + +def _pcm_duration_ms(sample: AudioSample) -> int: + if not sample.pcm: + return 0 + frames = len(sample.pcm) // (2 * max(1, sample.channels)) + return round((frames / max(1, sample.sample_rate)) * 1000) + + +def _tail_silence(sample: AudioSample, duration_ms: int) -> bytes: + frame_count = round(sample.sample_rate * max(0, duration_ms) / 1000) + return b"\x00" * frame_count * max(1, sample.channels) * 2 + + +def _iter_rtc_frames(sample: AudioSample, *, frame_ms: int, tail_silence_ms: int): + from livekit import rtc + + frame_ms = max(1, int(frame_ms)) + samples_per_frame = max(1, round(sample.sample_rate * frame_ms / 1000)) + bytes_per_frame = samples_per_frame * max(1, sample.channels) * 2 + data = sample.pcm + _tail_silence(sample, tail_silence_ms) + for offset in range(0, len(data), bytes_per_frame): + chunk = data[offset : offset + bytes_per_frame] + if not chunk: + continue + if len(chunk) < bytes_per_frame: + chunk += b"\x00" * (bytes_per_frame - len(chunk)) + yield rtc.AudioFrame( + data=chunk, + sample_rate=sample.sample_rate, + num_channels=sample.channels, + samples_per_channel=len(chunk) // (2 * max(1, sample.channels)), + ) + + +async def _run_livekit_vad( + sample: AudioSample, + *, + vad_config: dict[str, float], + logging_config: dict[str, Any], + frame_ms: int, + tail_silence_ms: int, + segments_dir: Path, +) -> tuple[list[dict[str, Any]], dict[str, Any]]: + from livekit.agents import vad as agents_vad + from livekit.plugins import silero + + segments_dir.mkdir(parents=True, exist_ok=True) + vad = silero.VAD.load( + min_speech_duration=vad_config["min_speech_duration"], + min_silence_duration=vad_config["min_silence_duration"], + activation_threshold=vad_config["activation_threshold"], + deactivation_threshold=vad_config["deactivation_threshold"], + prefix_padding_duration=vad_config["prefix_padding_duration"], + sample_rate=16000, + ) + stream = vad.stream() + segments: list[dict[str, Any]] = [] + diagnostics: dict[str, Any] = { + "inference_count": 0, + "max_probability": 0.0, + "activity_events": [], + "decision_events": [], + } + + async def _consume_events() -> None: + async for ev in stream: + if ev.type == agents_vad.VADEventType.INFERENCE_DONE: + probability = float(getattr(ev, "probability", 0.0) or 0.0) + diagnostics["inference_count"] += 1 + diagnostics["max_probability"] = max(float(diagnostics["max_probability"]), probability) + if ( + logging_config["log_activity"] + and probability >= logging_config["activity_min_probability"] + ): + diagnostics["activity_events"].append( + { + "timestamp_ms": round(float(ev.timestamp or 0.0) * 1000), + "probability": round(probability, 4), + "speaking": bool(getattr(ev, "speaking", False)), + "raw_speech_ms": round(float(getattr(ev, "raw_accumulated_speech", 0.0) or 0.0) * 1000), + "raw_silence_ms": round(float(getattr(ev, "raw_accumulated_silence", 0.0) or 0.0) * 1000), + } + ) + continue + + if ev.type == agents_vad.VADEventType.START_OF_SPEECH: + if logging_config["log_decisions"]: + diagnostics["decision_events"].append( + { + "type": "start_of_speech", + "timestamp_ms": round(float(ev.timestamp or 0.0) * 1000), + "speech_duration_ms": round(float(ev.speech_duration or 0.0) * 1000), + } + ) + continue + + if ev.type != agents_vad.VADEventType.END_OF_SPEECH: + continue + + pcm = b"".join(bytes(frame.data) for frame in ev.frames) + segment = AudioSample( + name=f"{sample.name}_segment_{len(segments) + 1:02d}", + pcm=pcm, + sample_rate=sample.sample_rate, + channels=sample.channels, + ) + duration_ms = _pcm_duration_ms(segment) + end_ms = round(float(ev.timestamp or 0.0) * 1000) + start_ms = max(0, end_ms - duration_ms) + segment_path = write_wav( + segments_dir / f"{len(segments) + 1:02d}_{segment.name}.wav", + segment, + ) + metrics = audio_metrics(segment.pcm, sample_rate=segment.sample_rate, channels=segment.channels) + row = { + "index": len(segments) + 1, + "path": str(segment_path), + "start_ms": start_ms, + "end_ms": end_ms, + "duration_ms": duration_ms, + "speech_duration_ms": round(float(ev.speech_duration or 0.0) * 1000), + "silence_duration_ms": round(float(ev.silence_duration or 0.0) * 1000), + "rms_dbfs": metrics.rms_dbfs, + "peak_dbfs": metrics.peak_dbfs, + "pcm": segment.pcm, + } + segments.append(row) + if logging_config["log_decisions"]: + diagnostics["decision_events"].append( + { + "type": "end_of_speech", + "timestamp_ms": end_ms, + "speech_duration_ms": row["speech_duration_ms"], + "silence_duration_ms": row["silence_duration_ms"], + "segment_duration_ms": duration_ms, + } + ) + + consumer = asyncio.create_task(_consume_events()) + try: + for frame in _iter_rtc_frames( + sample, + frame_ms=frame_ms, + tail_silence_ms=tail_silence_ms, + ): + stream.push_frame(frame) + await asyncio.sleep(0) + stream.end_input() + await consumer + finally: + if not consumer.done(): + consumer.cancel() + await asyncio.gather(consumer, return_exceptions=True) + try: + await stream.aclose() + except Exception: + pass + + return segments, diagnostics + + +def _resolve_vad_proxy_prefix_padding_ms(args: argparse.Namespace) -> int: + explicit = _coalesce( + args.vad_proxy_prefix_padding_ms, + _env_int("STRESS_VAD_PREFIX_PADDING_MS"), + ) + if explicit is not None: + return max(0, int(explicit)) + return max(0, round(float(_env_float("VAD_PREFIX_PADDING_DURATION", 1.0) or 1.0) * 1000)) + + +def _resolve_stt_input_padding_ms(args: argparse.Namespace) -> int: + value = _coalesce( + args.stt_input_prefix_padding_ms, + _env_int("STT_INPUT_PREFIX_PADDING_MS"), + 250, + ) + return max(0, int(value)) + + +def _resolve_stt_input_suffix_padding_ms(args: argparse.Namespace) -> int: + value = _coalesce( + args.stt_input_suffix_padding_ms, + _env_int("STT_INPUT_SUFFIX_PADDING_MS"), + 0, + ) + return max(0, int(value)) + + +def _resolve_stt_playback_speed(args: argparse.Namespace) -> float: + value = _coalesce( + args.stt_playback_speed, + _env_float("STT_PLAYBACK_SPEED"), + DEFAULT_STT_PLAYBACK_SPEED, + ) + speed = float(value) + if speed <= 0: + raise ValueError("--stt-playback-speed precisa ser maior que zero") + return speed + + +def _resolve_stt_volume_gain_db(args: argparse.Namespace) -> float: + value = _coalesce( + args.stt_volume_gain_db, + _env_float("STT_VOLUME_GAIN_DB"), + DEFAULT_STT_VOLUME_GAIN_DB, + ) + return float(value) + + +def _result_payload( + *, + audio_path: Path, + input_channel: str, + sent_wav_path: Path | None, + cfg: InternalSTTConfig, + override: dict[str, Any], + source_sample: AudioSample, + sent_sample: AudioSample, + applied_padding_ms: int, + applied_suffix_padding_ms: int, + stt_playback_speed: float, + stt_volume_gain_db: float, + vad_proxy: Any, + transcript_raw: str, + latency_ms: int, +) -> dict[str, Any]: + return { + "audio_path": str(audio_path), + "input_channel": input_channel, + "sent_wav_path": str(sent_wav_path) if sent_wav_path is not None else "", + "stt_url": cfg.url, + "language": cfg.language, + "output_mode": cfg.output_mode, + "latency_ms": latency_ms, + "source_audio": asdict(audio_metrics(source_sample.pcm, sample_rate=source_sample.sample_rate)), + "sent_audio": asdict(audio_metrics(sent_sample.pcm, sample_rate=sent_sample.sample_rate)), + "applied_stt_input_prefix_padding_ms": applied_padding_ms, + "applied_stt_input_suffix_padding_ms": applied_suffix_padding_ms, + "stt_playback_speed": stt_playback_speed, + "stt_volume_gain_db": stt_volume_gain_db, + "vad_proxy": asdict(vad_proxy), + "sofya_vad": override["processor"]["config"]["extra_params"].get("vad_parameters", {}), + "sofya_vad_filter": override["processor"]["config"]["extra_params"].get("vad_filter"), + "transcript": extract_text_from_transcript(transcript_raw), + "transcript_raw": transcript_raw, + } + + +async def _post_sample_to_sofya( + stt: InternalHTTPSTT, + *, + sample: AudioSample, + req_id: str, + language: str, +) -> dict[str, Any]: + metrics = audio_metrics(sample.pcm, sample_rate=sample.sample_rate, channels=sample.channels) + started = time.perf_counter() + event = await stt._post_internal_http( + sample.pcm, + pcm_to_wav_bytes(sample), + req_id=req_id, + started_ns=time.time_ns(), + language=language, + audio_duration_ms=metrics.duration_ms, + level_dbfs=metrics.rms_dbfs, + ) + latency_ms = round((time.perf_counter() - started) * 1000) + transcript_raw = event.alternatives[0].text if event.alternatives else "" + return { + "latency_ms": latency_ms, + "transcript_raw": transcript_raw, + "transcript": extract_text_from_transcript(transcript_raw), + } + + +def _print_livekit_vad_summary( + *, + vad_config: dict[str, float], + logging_config: dict[str, Any], + segments: list[dict[str, Any]], + diagnostics: dict[str, Any], +) -> None: + print("[sofya-transcribe] livekit_vad_config:") + print(_json_dumps(vad_config)) + print("[sofya-transcribe] livekit_vad_logging:") + print(_json_dumps(logging_config)) + print( + "[sofya-transcribe] livekit_vad " + f"segments={len(segments)} " + f"inference_count={diagnostics['inference_count']} " + f"max_probability={float(diagnostics['max_probability']):.4f}" + ) + for row in segments: + print( + "[sofya-transcribe] segment " + f"#{row['index']} start_ms={row['start_ms']} end_ms={row['end_ms']} " + f"duration_ms={row['duration_ms']} speech_ms={row['speech_duration_ms']} " + f"silence_ms={row['silence_duration_ms']} rms_dbfs={row['rms_dbfs']} " + f"path={row['path']}" + ) + + +def _format_ms(value: Any) -> str: + try: + total_ms = max(0, int(round(float(value)))) + except (TypeError, ValueError): + total_ms = 0 + minutes, rest = divmod(total_ms, 60_000) + seconds, ms = divmod(rest, 1000) + return f"{minutes:02d}:{seconds:02d}.{ms:03d}" + + +def _write_segment_transcripts( + path: Path, + *, + audio_path: Path, + input_channel: str, + vad_config: dict[str, float], + segments: list[dict[str, Any]], + transcript: str, +) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + lines = [ + "# Silero VAD -> Sofya STT", + "", + f"- Audio original: `{audio_path}`", + f"- Canal usado: `{input_channel}`", + "- Observacao: Silero nao transcreve texto; ele corta audio. Os textos abaixo sao retornos do Sofya para cada segmento cortado pelo Silero.", + "- VAD LiveKit:", + f" - min_speech_duration: `{vad_config['min_speech_duration']}`", + f" - activation_threshold: `{vad_config['activation_threshold']}`", + f" - deactivation_threshold: `{vad_config['deactivation_threshold']}`", + f" - min_silence_duration: `{vad_config['min_silence_duration']}`", + f" - prefix_padding_duration: `{vad_config['prefix_padding_duration']}`", + "", + "## Transcricao Final", + "", + transcript or "_Sem texto transcrito._", + "", + "## Segmentos", + "", + ] + for row in segments: + text = str(row.get("transcript") or "").strip() + lines.extend( + [ + f"### Segmento {int(row['index']):02d}", + "", + f"- Intervalo original: `{_format_ms(row.get('start_ms'))}` -> `{_format_ms(row.get('end_ms'))}`", + f"- Duracao enviada: `{row.get('duration_ms', '')} ms`", + f"- Fala estimada pelo VAD: `{row.get('speech_duration_ms', '')} ms`", + f"- Silencio de fechamento: `{row.get('silence_duration_ms', '')} ms`", + f"- RMS: `{row.get('rms_dbfs', '')} dBFS`", + f"- WAV cortado pelo Silero: `{row.get('path', '')}`", + f"- WAV enviado ao Sofya: `{row.get('sent_to_sofya_path', '')}`", + f"- Padding inicio enviado ao Sofya: `{row.get('applied_stt_input_prefix_padding_ms', '')} ms`", + f"- Padding fim enviado ao Sofya: `{row.get('applied_stt_input_suffix_padding_ms', '')} ms`", + f"- Velocidade enviada ao Sofya: `{row.get('stt_playback_speed', '')}`", + f"- Ganho enviado ao Sofya: `{row.get('stt_volume_gain_db', '')} dB`", + "", + "Transcricao:", + "", + text or "_Sem texto._", + "", + ] + ) + path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") + return path + + +def _write_batch_transcripts(path: Path, results: list[dict[str, Any]]) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + lines = [ + "# Batch Silero VAD -> Sofya STT", + "", + f"- Audios processados: `{len(results)}`", + "- Observacao: Silero corta audio; os textos sao retornos do Sofya para cada segmento enviado.", + "", + "## Resumo", + "", + "| audio | canal | segmentos | transcricao |", + "| --- | --- | ---: | --- |", + ] + for result in results: + transcript = str(result.get("transcript") or "").replace("\n", " ").strip() + if len(transcript) > 160: + transcript = transcript[:157].rstrip() + "..." + lines.append( + f"| `{result.get('audio_path', '')}` | `{result.get('input_channel', '')}` | " + f"{len(result.get('segments') or [])} | {transcript or '_Sem texto_'} |" + ) + + for audio_idx, result in enumerate(results, start=1): + lines.extend( + [ + "", + f"## Audio {audio_idx:02d}: `{result.get('audio_path', '')}`", + "", + f"- Canal usado: `{result.get('input_channel', '')}`", + f"- Segmentos: `{len(result.get('segments') or [])}`", + f"- Markdown individual: `{result.get('transcripts_output', '')}`", + "", + "Transcricao final:", + "", + str(result.get("transcript") or "").strip() or "_Sem texto transcrito._", + "", + "### Segmentos", + "", + ] + ) + for row in result.get("segments") or []: + text = str(row.get("transcript") or "").strip() + lines.extend( + [ + f"#### Segmento {int(row['index']):02d}", + "", + f"- Intervalo original: `{_format_ms(row.get('start_ms'))}` -> `{_format_ms(row.get('end_ms'))}`", + f"- Duracao enviada: `{row.get('duration_ms', '')} ms`", + f"- Fala estimada pelo VAD: `{row.get('speech_duration_ms', '')} ms`", + f"- Silencio de fechamento: `{row.get('silence_duration_ms', '')} ms`", + f"- RMS: `{row.get('rms_dbfs', '')} dBFS`", + f"- WAV cortado pelo Silero: `{row.get('path', '')}`", + f"- WAV enviado ao Sofya: `{row.get('sent_to_sofya_path', '')}`", + f"- Padding inicio enviado ao Sofya: `{row.get('applied_stt_input_prefix_padding_ms', '')} ms`", + f"- Padding fim enviado ao Sofya: `{row.get('applied_stt_input_suffix_padding_ms', '')} ms`", + f"- Velocidade enviada ao Sofya: `{row.get('stt_playback_speed', '')}`", + f"- Ganho enviado ao Sofya: `{row.get('stt_volume_gain_db', '')} dB`", + "", + "Transcricao:", + "", + text or "_Sem texto._", + "", + ] + ) + path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8") + return path + + +async def _run_one_audio( + args: argparse.Namespace, + *, + audio_path: Path, + input_channel: str, + segments_dir: Path | None = None, + transcripts_output: Path | None = None, + json_output: Path | None = None, +) -> dict[str, Any]: + source_sample = _load_audio(audio_path, input_channel=input_channel) + source_metrics = audio_metrics( + source_sample.pcm, + sample_rate=source_sample.sample_rate, + channels=source_sample.channels, + ) + vad_proxy = vad_proxy_metrics( + source_sample, + threshold_dbfs=float(args.vad_proxy_threshold_dbfs), + prefix_padding_ms=_resolve_vad_proxy_prefix_padding_ms(args), + min_speech_ms=max(0, int(args.vad_proxy_min_speech_ms)), + ) + + print( + "[sofya-transcribe] audio=" + f"{audio_path} input_channel={input_channel} normalized_sample={source_sample.name} " + f"duration_ms={source_metrics.duration_ms} rms_dbfs={source_metrics.rms_dbfs}" + ) + print( + "[sofya-transcribe] vad_proxy " + f"first_voice_ms={vad_proxy.first_voice_ms} " + f"speech_ms={vad_proxy.speech_ms} " + f"low_start_risk={vad_proxy.low_start_risk}" + ) + stt_playback_speed = _resolve_stt_playback_speed(args) + stt_volume_gain_db = _resolve_stt_volume_gain_db(args) + print(f"[sofya-transcribe] stt_playback_speed={stt_playback_speed}") + print(f"[sofya-transcribe] stt_volume_gain_db={stt_volume_gain_db}") + + if args.use_livekit_vad: + vad_config, vad_logging_config, call_config = _resolve_livekit_vad_config(args) + segments_dir = segments_dir or Path(args.segments_dir).expanduser() + segments, diagnostics = await _run_livekit_vad( + source_sample, + vad_config=vad_config, + logging_config=vad_logging_config, + frame_ms=max(1, int(args.vad_frame_ms)), + tail_silence_ms=max(0, int(args.vad_tail_silence_ms)), + segments_dir=segments_dir, + ) + _print_livekit_vad_summary( + vad_config=vad_config, + logging_config=vad_logging_config, + segments=segments, + diagnostics=diagnostics, + ) + + if args.vad_only: + result = { + "mode": "livekit_vad_only", + "audio_path": str(audio_path), + "input_channel": input_channel, + "source_audio": asdict(source_metrics), + "vad_proxy": asdict(vad_proxy), + "call_config": call_config, + "livekit_vad": vad_config, + "livekit_vad_logging": vad_logging_config, + "livekit_vad_diagnostics": diagnostics, + "segments": [{key: value for key, value in row.items() if key != "pcm"} for row in segments], + } + if json_output is not None: + json_path = json_output + json_path.parent.mkdir(parents=True, exist_ok=True) + json_path.write_text(_json_dumps(result) + "\n", encoding="utf-8") + print(f"[sofya-transcribe] json_output={json_path}") + print("[sofya-transcribe] vad_only=true; Sofya nao foi chamado.") + return result + + config_override = build_sofya_override_config(args) + cfg = _stt_config(args, config_override) + os.environ.pop("STT_FORCE_OVERRIDE_CONFIG", None) + if args.print_override_config: + print("[sofya-transcribe] override_config enviado ao Sofya:") + print(_json_dumps(config_override)) + print( + "[sofya-transcribe] sofya_vad " + f"filter={config_override['processor']['config']['extra_params'].get('vad_filter')} " + f"params={config_override['processor']['config']['extra_params'].get('vad_parameters')}" + ) + + stt_input_padding_ms = _resolve_stt_input_padding_ms(args) + stt_input_suffix_padding_ms = _resolve_stt_input_suffix_padding_ms(args) + async with httpx.AsyncClient() as client: + stt = InternalHTTPSTT(cfg, client=client) + for row in segments: + segment_sample = AudioSample( + name=f"{source_sample.name}_segment_{row['index']:02d}", + pcm=row["pcm"], + sample_rate=source_sample.sample_rate, + channels=source_sample.channels, + ) + speed_adjusted_segment = _apply_playback_speed(segment_sample, stt_playback_speed) + gain_adjusted_segment = _apply_volume_gain_db( + speed_adjusted_segment, + stt_volume_gain_db, + ) + sent_segment, applied_padding_ms = _apply_prefix_padding( + gain_adjusted_segment, + stt_input_padding_ms, + ) + sent_segment, applied_suffix_padding_ms = _apply_suffix_padding( + sent_segment, + stt_input_suffix_padding_ms, + ) + sent_path = write_wav( + segments_dir / f"{row['index']:02d}_{sent_segment.name}_sent_to_sofya.wav", + sent_segment, + ) + stt_result = await _post_sample_to_sofya( + stt, + sample=sent_segment, + req_id=f"sofya-file-{audio_path.stem}-seg-{row['index']:02d}", + language=cfg.language, + ) + row.update( + { + "sent_to_sofya_path": str(sent_path), + "applied_stt_input_prefix_padding_ms": applied_padding_ms, + "applied_stt_input_suffix_padding_ms": applied_suffix_padding_ms, + "stt_playback_speed": stt_playback_speed, + "stt_volume_gain_db": stt_volume_gain_db, + "sofya_latency_ms": stt_result["latency_ms"], + "transcript": stt_result["transcript"], + "transcript_raw": stt_result["transcript_raw"], + } + ) + print( + "[sofya-transcribe] transcript " + f"segment=#{row['index']} latency_ms={row['sofya_latency_ms']} " + f"text={row['transcript']}" + ) + + transcript = " ".join(str(row.get("transcript") or "").strip() for row in segments).strip() + transcripts_output = transcripts_output if transcripts_output is not None else ( + Path(args.transcripts_output).expanduser() if args.transcripts_output else None + ) + if transcripts_output is not None: + transcripts_path = _write_segment_transcripts( + transcripts_output, + audio_path=audio_path, + input_channel=input_channel, + vad_config=vad_config, + segments=segments, + transcript=transcript, + ) + print(f"[sofya-transcribe] transcripts_output={transcripts_path}") + result = { + "mode": "livekit_vad_to_sofya", + "audio_path": str(audio_path), + "input_channel": input_channel, + "source_audio": asdict(source_metrics), + "vad_proxy": asdict(vad_proxy), + "call_config": call_config, + "livekit_vad": vad_config, + "livekit_vad_logging": vad_logging_config, + "livekit_vad_diagnostics": diagnostics, + "sofya_vad": config_override["processor"]["config"]["extra_params"].get("vad_parameters", {}), + "sofya_vad_filter": config_override["processor"]["config"]["extra_params"].get("vad_filter"), + "stt_input_prefix_padding_ms": stt_input_padding_ms, + "stt_input_suffix_padding_ms": stt_input_suffix_padding_ms, + "stt_playback_speed": stt_playback_speed, + "stt_volume_gain_db": stt_volume_gain_db, + "transcript": transcript, + "transcripts_output": str(transcripts_output) if transcripts_output is not None else "", + "segments": [{key: value for key, value in row.items() if key != "pcm"} for row in segments], + } + if json_output is not None: + json_path = json_output + json_path.parent.mkdir(parents=True, exist_ok=True) + json_path.write_text(_json_dumps(result) + "\n", encoding="utf-8") + print(f"[sofya-transcribe] json_output={json_path}") + print("[sofya-transcribe] transcript_final:") + print(transcript) + return result + + config_override = build_sofya_override_config(args) + cfg = _stt_config(args, config_override) + + # Garante que os parametros montados por este script ganhem de um + # STT_FORCE_OVERRIDE_CONFIG herdado do shell. + os.environ.pop("STT_FORCE_OVERRIDE_CONFIG", None) + + speed_adjusted_sample = _apply_playback_speed(source_sample, stt_playback_speed) + gain_adjusted_sample = _apply_volume_gain_db(speed_adjusted_sample, stt_volume_gain_db) + sent_sample, applied_padding_ms = _apply_prefix_padding( + gain_adjusted_sample, + _resolve_stt_input_padding_ms(args), + ) + sent_sample, applied_suffix_padding_ms = _apply_suffix_padding( + sent_sample, + _resolve_stt_input_suffix_padding_ms(args), + ) + sent_wav_path = Path(args.save_sent_wav).expanduser() if args.save_sent_wav else None + if sent_wav_path is not None: + write_wav(sent_wav_path, sent_sample) + + if args.print_override_config: + print("[sofya-transcribe] override_config enviado ao Sofya:") + print(_json_dumps(config_override)) + + print( + "[sofya-transcribe] sofya_vad " + f"filter={config_override['processor']['config']['extra_params'].get('vad_filter')} " + f"params={config_override['processor']['config']['extra_params'].get('vad_parameters')}" + ) + + async with httpx.AsyncClient() as client: + stt = InternalHTTPSTT(cfg, client=client) + stt_result = await _post_sample_to_sofya( + stt, + sample=sent_sample, + req_id=f"sofya-file-{audio_path.stem}", + language=cfg.language, + ) + latency_ms = stt_result["latency_ms"] + transcript_raw = stt_result["transcript_raw"] + transcript = extract_text_from_transcript(transcript_raw) + + result = _result_payload( + audio_path=audio_path, + input_channel=input_channel, + sent_wav_path=sent_wav_path, + cfg=cfg, + override=config_override, + source_sample=source_sample, + sent_sample=sent_sample, + applied_padding_ms=applied_padding_ms, + applied_suffix_padding_ms=applied_suffix_padding_ms, + stt_playback_speed=stt_playback_speed, + stt_volume_gain_db=stt_volume_gain_db, + vad_proxy=vad_proxy, + transcript_raw=transcript_raw, + latency_ms=latency_ms, + ) + if json_output is not None: + json_path = json_output + json_path.parent.mkdir(parents=True, exist_ok=True) + json_path.write_text(_json_dumps(result) + "\n", encoding="utf-8") + print(f"[sofya-transcribe] json_output={json_path}") + + print(f"[sofya-transcribe] latency_ms={latency_ms}") + print("[sofya-transcribe] transcript:") + print(transcript) + if cfg.output_mode == "raw_json": + print("[sofya-transcribe] transcript_raw:") + print(transcript_raw) + return result + + +async def run(args: argparse.Namespace) -> int: + env_file = Path(args.env_file).expanduser() if args.env_file else None + if env_file and env_file.exists(): + load_dotenv(env_file, override=bool(args.override_env)) + + input_channel = _channel_spec_from_args(args) + audio_paths = _resolve_audio_paths(args) + base_segments_dir = Path(args.segments_dir).expanduser() + base_transcripts_output = Path(args.transcripts_output).expanduser() if args.transcripts_output else None + base_json_output = Path(args.json_output).expanduser() if args.json_output else None + + if len(audio_paths) == 1: + await _run_one_audio( + args, + audio_path=audio_paths[0], + input_channel=input_channel, + segments_dir=base_segments_dir, + transcripts_output=base_transcripts_output, + json_output=base_json_output, + ) + return 0 + + print(f"[sofya-transcribe] batch audios={len(audio_paths)}") + results: list[dict[str, Any]] = [] + individual_dir = ( + base_transcripts_output.parent / f"{base_transcripts_output.stem}_files" + if base_transcripts_output is not None + else base_segments_dir / "reports" + ) + for idx, audio_path in enumerate(audio_paths, start=1): + safe_name = f"{idx:02d}_{_safe_stem(audio_path)}" + print(f"[sofya-transcribe] batch_item {idx}/{len(audio_paths)} audio={audio_path}") + result = await _run_one_audio( + args, + audio_path=audio_path, + input_channel=input_channel, + segments_dir=base_segments_dir / safe_name, + transcripts_output=individual_dir / f"{safe_name}_segments_transcripts.md", + json_output=None, + ) + results.append(result) + + if base_transcripts_output is not None: + batch_path = _write_batch_transcripts(base_transcripts_output, results) + print(f"[sofya-transcribe] batch_transcripts_output={batch_path}") + + if base_json_output is not None: + payload = { + "mode": "batch", + "input_channel": input_channel, + "audio_count": len(audio_paths), + "transcripts_output": str(base_transcripts_output) if base_transcripts_output is not None else "", + "results": results, + } + base_json_output.parent.mkdir(parents=True, exist_ok=True) + base_json_output.write_text(_json_dumps(payload) + "\n", encoding="utf-8") + print(f"[sofya-transcribe] batch_json_output={base_json_output}") + + return 0 + + +def main() -> None: + parser = _build_arg_parser() + args = parser.parse_args() + raise SystemExit(asyncio.run(run(args))) + + +if __name__ == "__main__": + main() diff --git a/src/app/tools/xai_tts_load.py b/src/app/tools/xai_tts_load.py new file mode 100644 index 0000000..8db450d --- /dev/null +++ b/src/app/tools/xai_tts_load.py @@ -0,0 +1,697 @@ +from __future__ import annotations + +import argparse +import asyncio +import base64 +import binascii +import csv +import json +import os +import time +import uuid +import wave +from collections import Counter +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import urlencode + +import aiohttp +from dotenv import load_dotenv + + +DEFAULT_TEXT = ( + "Ola! Sou especialista em contas e estou a disposicao. " + "Como posso te ajudar com a sua fatura hoje?" +) +DEFAULT_OUTPUT_JSONL = Path(".run/xai-tts-load/results.jsonl") +DEFAULT_SUMMARY_CSV = Path(".run/xai-tts-load/summary.csv") +SAMPLE_RATE = 24_000 +NUM_CHANNELS = 1 +SAMPLE_WIDTH_BYTES = 2 + + +def _now_utc() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _elapsed_ms(started_at: float) -> int: + return max(0, round((time.perf_counter() - started_at) * 1000)) + + +def _env_str(name: str, default: str = "") -> str: + return str(os.getenv(name, default) or default).strip() + + +def _bool_query(value: bool) -> str: + return "true" if value else "false" + + +def _percentile(values: list[int], percentile: float) -> int | None: + if not values: + return None + ordered = sorted(values) + index = round((len(ordered) - 1) * percentile) + return ordered[index] + + +def _write_wav(path: Path, pcm: bytes) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + with wave.open(str(path), "wb") as handle: + handle.setnchannels(NUM_CHANNELS) + handle.setsampwidth(SAMPLE_WIDTH_BYTES) + handle.setframerate(SAMPLE_RATE) + handle.writeframes(pcm) + return path + + +@dataclass(frozen=True, slots=True) +class Config: + env_file: Path + api_key: str + websocket_url: str + voice: str + language: str + text_normalization: bool + optimize_streaming_latency: int + iterations: int + concurrency: int + reuse_connection: bool + reset_on_failure: bool + prewarm_idle_s: float + idle_between_requests_s: float + request_timeout_s: float + connect_timeout_s: float + gap_timeout_s: float + output_jsonl: Path + summary_csv: Path | None + audio_dir: Path | None + texts: tuple[str, ...] + + +@dataclass(slots=True) +class XAIConnection: + connection_id: str + ws: aiohttp.ClientWebSocketResponse + opened_at: float + opened_connect_ms: int + turns_completed: int = 0 + last_outcome: str = "" + last_audio_bytes: int = 0 + last_audio_chunks: int = 0 + + @property + def closed(self) -> bool: + return self.ws.closed + + @property + def age_ms(self) -> int: + return _elapsed_ms(self.opened_at) + + +@dataclass(slots=True) +class Result: + run_id: str + request_id: str + worker_id: int + iteration: int + started_at: str + connection_id: str + connection_age_ms: int + connection_turn_index: int + reused_connection: bool + opened_for_request: bool + idle_before_turn_ms: int + previous_outcome: str + previous_audio_bytes: int + previous_audio_chunks: int + text_len: int + connect_ms: int + send_ms: int + first_audio_ms: int | None + request_to_first_audio_ms: int | None + total_to_first_audio_ms: int | None + elapsed_ms: int + audio_chunks: int + audio_bytes: int + empty_audio_deltas: int + max_gap_ms: int + gap_timeout_ms: int + outcome: str + trace_id: str + error: str + audio_path: str + + +def _build_url(config: Config) -> str: + params = { + "voice": config.voice, + "language": config.language, + "codec": "pcm", + "sample_rate": str(SAMPLE_RATE), + "optimize_streaming_latency": str(config.optimize_streaming_latency), + "text_normalization": _bool_query(config.text_normalization), + } + return f"{config.websocket_url}?{urlencode(params)}" + + +async def _open_connection( + session: aiohttp.ClientSession, + config: Config, + *, + worker_id: int, +) -> XAIConnection: + connection_id = f"direct-{worker_id}-{uuid.uuid4().hex[:8]}" + started_at = time.perf_counter() + ws = await asyncio.wait_for( + session.ws_connect( + _build_url(config), + headers={"Authorization": f"Bearer {config.api_key}"}, + heartbeat=20, + ), + timeout=config.connect_timeout_s, + ) + opened_at = time.perf_counter() + return XAIConnection( + connection_id=connection_id, + ws=ws, + opened_at=opened_at, + opened_connect_ms=max(0, round((opened_at - started_at) * 1000)), + ) + + +async def _close_connection(conn: XAIConnection | None, *, reason: str) -> None: + if conn is None or conn.closed: + return + await conn.ws.close(message=reason.encode("utf-8")) + + +async def _receive_xai_turn( + *, + config: Config, + conn: XAIConnection, + text: str, + run_id: str, + worker_id: int, + iteration: int, + job_started_at: float, + opened_for_request: bool, + idle_before_turn_ms: int, + output_audio_dir: Path | None, +) -> Result: + request_id = uuid.uuid4().hex[:12] + send_started_at = time.perf_counter() + await conn.ws.send_str(json.dumps({"type": "text.delta", "delta": text}, ensure_ascii=False)) + await conn.ws.send_str(json.dumps({"type": "text.done"})) + send_ms = _elapsed_ms(send_started_at) + + turn_started_at = time.perf_counter() + first_audio_ms: int | None = None + request_to_first_audio_ms: int | None = None + total_to_first_audio_ms: int | None = None + last_audio_at = 0.0 + audio_chunks = 0 + audio_bytes = 0 + empty_audio_deltas = 0 + max_gap_ms = 0 + trace_id = "" + error = "" + outcome = "unknown" + audio = bytearray() + previous_outcome = conn.last_outcome + previous_audio_bytes = conn.last_audio_bytes + previous_audio_chunks = conn.last_audio_chunks + turn_index = conn.turns_completed + 1 + connection_age_ms = conn.age_ms + gap_timeout_ms = round(config.gap_timeout_s * 1000) + + while True: + receive_timeout_s = config.request_timeout_s + if audio_chunks > 0 and config.gap_timeout_s > 0: + receive_timeout_s = min(receive_timeout_s, config.gap_timeout_s) + + try: + msg = await asyncio.wait_for(conn.ws.receive(), timeout=receive_timeout_s) + except asyncio.TimeoutError: + if audio_chunks > 0 and config.gap_timeout_s > 0: + gap_ms = max(0, round((time.perf_counter() - last_audio_at) * 1000)) + max_gap_ms = max(max_gap_ms, gap_ms) + outcome = "gap_timeout" + error = f"no audio.delta for {gap_ms}ms" + else: + outcome = "no_audio_timeout" + error = f"no audio.delta before {round(config.request_timeout_s * 1000)}ms" + break + + now = time.perf_counter() + if msg.type in ( + aiohttp.WSMsgType.CLOSED, + aiohttp.WSMsgType.CLOSE, + aiohttp.WSMsgType.CLOSING, + aiohttp.WSMsgType.ERROR, + ): + outcome = "ws_closed_after_audio" if audio_chunks else "ws_closed_no_audio" + error = f"websocket message type {msg.type.name}" + break + + if msg.type != aiohttp.WSMsgType.TEXT: + continue + + try: + data = json.loads(str(msg.data)) + except json.JSONDecodeError as exc: + outcome = "invalid_json" + error = str(exc) + break + + msg_type = data.get("type") + if msg_type == "audio.delta": + try: + chunk = base64.b64decode(str(data.get("delta") or "")) + except (binascii.Error, ValueError) as exc: + outcome = "invalid_audio_delta" + error = str(exc) + break + + if not chunk: + empty_audio_deltas += 1 + continue + + if audio_chunks == 0: + first_audio_ms = max(0, round((now - turn_started_at) * 1000)) + request_to_first_audio_ms = max(0, round((now - send_started_at) * 1000)) + total_to_first_audio_ms = max(0, round((now - job_started_at) * 1000)) + else: + gap_ms = max(0, round((now - last_audio_at) * 1000)) + max_gap_ms = max(max_gap_ms, gap_ms) + + audio_chunks += 1 + audio_bytes += len(chunk) + last_audio_at = now + if output_audio_dir is not None: + audio.extend(chunk) + elif msg_type == "audio.done": + trace_id = str(data.get("trace_id") or "") + outcome = "success" if audio_chunks else "no_audio_done" + break + elif msg_type == "audio.clear": + outcome = "audio_clear" + break + elif msg_type == "error": + trace_id = str(data.get("trace_id") or "") + outcome = "api_error" + error = str(data.get("message") or data) + break + + elapsed_ms = _elapsed_ms(turn_started_at) + audio_path = "" + if output_audio_dir is not None and audio: + audio_path = str(_write_wav(output_audio_dir / f"{iteration:04d}_{request_id}.wav", bytes(audio))) + + result = Result( + run_id=run_id, + request_id=request_id, + worker_id=worker_id, + iteration=iteration, + started_at=_now_utc(), + connection_id=conn.connection_id, + connection_age_ms=connection_age_ms, + connection_turn_index=turn_index, + reused_connection=(not opened_for_request), + opened_for_request=opened_for_request, + idle_before_turn_ms=idle_before_turn_ms, + previous_outcome=previous_outcome, + previous_audio_bytes=previous_audio_bytes, + previous_audio_chunks=previous_audio_chunks, + text_len=len(text), + connect_ms=conn.opened_connect_ms if opened_for_request else 0, + send_ms=send_ms, + first_audio_ms=first_audio_ms, + request_to_first_audio_ms=request_to_first_audio_ms, + total_to_first_audio_ms=total_to_first_audio_ms, + elapsed_ms=elapsed_ms, + audio_chunks=audio_chunks, + audio_bytes=audio_bytes, + empty_audio_deltas=empty_audio_deltas, + max_gap_ms=max_gap_ms, + gap_timeout_ms=gap_timeout_ms, + outcome=outcome, + trace_id=trace_id, + error=error, + audio_path=audio_path, + ) + conn.turns_completed += 1 + conn.last_outcome = outcome + conn.last_audio_bytes = audio_bytes + conn.last_audio_chunks = audio_chunks + return result + + +def _compact_result_line(result: Result) -> str: + return ( + "[xai-tts-load] " + f"i={result.iteration} worker={result.worker_id} conn={result.connection_id} " + f"turn={result.connection_turn_index} outcome={result.outcome} " + f"age_ms={result.connection_age_ms} idle_ms={result.idle_before_turn_ms} " + f"connect_ms={result.connect_ms} first_audio_ms={result.first_audio_ms} " + f"request_to_first_audio_ms={result.request_to_first_audio_ms} " + f"total_to_first_audio_ms={result.total_to_first_audio_ms} " + f"max_gap_ms={result.max_gap_ms} chunks={result.audio_chunks} bytes={result.audio_bytes} " + f"trace_id={result.trace_id}" + ) + + +async def _run_worker( + *, + config: Config, + run_id: str, + worker_id: int, + iterations: list[int], + jsonl_handle: Any, + write_lock: asyncio.Lock, +) -> list[Result]: + results: list[Result] = [] + conn: XAIConnection | None = None + async with aiohttp.ClientSession() as session: + for iteration in iterations: + text = config.texts[(iteration - 1) % len(config.texts)] + job_started_at = time.perf_counter() + opened_for_request = False + idle_before_turn_ms = 0 + if conn is None or conn.closed: + conn = await _open_connection(session, config, worker_id=worker_id) + opened_for_request = True + if config.prewarm_idle_s > 0: + idle_started_at = time.perf_counter() + await asyncio.sleep(config.prewarm_idle_s) + idle_before_turn_ms = _elapsed_ms(idle_started_at) + elif config.idle_between_requests_s > 0: + idle_started_at = time.perf_counter() + await asyncio.sleep(config.idle_between_requests_s) + idle_before_turn_ms = _elapsed_ms(idle_started_at) + + try: + result = await _receive_xai_turn( + config=config, + conn=conn, + text=text, + run_id=run_id, + worker_id=worker_id, + iteration=iteration, + job_started_at=job_started_at, + opened_for_request=opened_for_request, + idle_before_turn_ms=idle_before_turn_ms, + output_audio_dir=config.audio_dir, + ) + except Exception as exc: + result = Result( + run_id=run_id, + request_id=uuid.uuid4().hex[:12], + worker_id=worker_id, + iteration=iteration, + started_at=_now_utc(), + connection_id=conn.connection_id if conn is not None else "", + connection_age_ms=conn.age_ms if conn is not None else 0, + connection_turn_index=(conn.turns_completed + 1) if conn is not None else 0, + reused_connection=not opened_for_request, + opened_for_request=opened_for_request, + idle_before_turn_ms=idle_before_turn_ms, + previous_outcome=conn.last_outcome if conn is not None else "", + previous_audio_bytes=conn.last_audio_bytes if conn is not None else 0, + previous_audio_chunks=conn.last_audio_chunks if conn is not None else 0, + text_len=len(text), + connect_ms=conn.opened_connect_ms if conn is not None and opened_for_request else 0, + send_ms=0, + first_audio_ms=None, + request_to_first_audio_ms=None, + total_to_first_audio_ms=None, + elapsed_ms=_elapsed_ms(job_started_at), + audio_chunks=0, + audio_bytes=0, + empty_audio_deltas=0, + max_gap_ms=0, + gap_timeout_ms=round(config.gap_timeout_s * 1000), + outcome="exception", + trace_id="", + error=f"{type(exc).__name__}: {exc}", + audio_path="", + ) + + results.append(result) + async with write_lock: + jsonl_handle.write(json.dumps(asdict(result), ensure_ascii=False) + "\n") + jsonl_handle.flush() + print(_compact_result_line(result), flush=True) + + should_close = not config.reuse_connection + if result.outcome != "success" and config.reset_on_failure: + should_close = True + if should_close: + await _close_connection(conn, reason=result.outcome) + conn = None + + await _close_connection(conn, reason="worker_done") + return results + + +def _partition_iterations(iterations: int, concurrency: int) -> list[list[int]]: + buckets: list[list[int]] = [[] for _ in range(concurrency)] + for iteration in range(1, iterations + 1): + buckets[(iteration - 1) % concurrency].append(iteration) + return buckets + + +def _write_summary_csv(path: Path, results: list[Result]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fields = list(asdict(results[0]).keys()) if results else list(Result.__dataclass_fields__.keys()) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + for result in sorted(results, key=lambda item: item.iteration): + writer.writerow(asdict(result)) + + +def _metric_values(results: list[Result], field: str) -> list[int]: + values: list[int] = [] + for result in results: + value = getattr(result, field) + if isinstance(value, int): + values.append(value) + return values + + +def _print_summary(config: Config, results: list[Result], started_at: float) -> None: + outcomes = Counter(result.outcome for result in results) + first_audio_values = _metric_values(results, "first_audio_ms") + request_to_first_values = _metric_values(results, "request_to_first_audio_ms") + total_to_first_values = _metric_values(results, "total_to_first_audio_ms") + connect_values = [result.connect_ms for result in results if result.opened_for_request] + connection_age_values = _metric_values(results, "connection_age_ms") + gap_values = _metric_values(results, "max_gap_ms") + no_audio = sum(1 for result in results if result.audio_bytes == 0) + gap_timeouts = sum(1 for result in results if result.outcome == "gap_timeout") + total_near_3s = sum( + 1 + for result in results + if result.total_to_first_audio_ms is not None and result.total_to_first_audio_ms >= 2800 + ) + request_near_3s = sum( + 1 + for result in results + if result.request_to_first_audio_ms is not None and result.request_to_first_audio_ms >= 2800 + ) + + print("", flush=True) + print("[xai-tts-load] resumo", flush=True) + print(f" total={len(results)} duration_ms={_elapsed_ms(started_at)}", flush=True) + print( + f" reuse_connection={int(config.reuse_connection)} concurrency={config.concurrency} " + f"prewarm_idle_ms={round(config.prewarm_idle_s * 1000)} " + f"idle_between_requests_ms={round(config.idle_between_requests_s * 1000)}", + flush=True, + ) + print(f" outcomes={dict(outcomes)}", flush=True) + print( + f" no_audio={no_audio} gap_timeouts={gap_timeouts} " + f"request_to_first_audio_ge_2800ms={request_near_3s} " + f"total_to_first_audio_ge_2800ms={total_near_3s}", + flush=True, + ) + print( + " connection_age_ms " + f"p50={_percentile(connection_age_values, 0.50)} p95={_percentile(connection_age_values, 0.95)} " + f"max={max(connection_age_values) if connection_age_values else None}", + flush=True, + ) + print( + " connect_ms " + f"p50={_percentile(connect_values, 0.50)} p95={_percentile(connect_values, 0.95)} " + f"max={max(connect_values) if connect_values else None}", + flush=True, + ) + print( + " first_audio_ms " + f"p50={_percentile(first_audio_values, 0.50)} p95={_percentile(first_audio_values, 0.95)} " + f"max={max(first_audio_values) if first_audio_values else None}", + flush=True, + ) + print( + " request_to_first_audio_ms " + f"p50={_percentile(request_to_first_values, 0.50)} p95={_percentile(request_to_first_values, 0.95)} " + f"max={max(request_to_first_values) if request_to_first_values else None}", + flush=True, + ) + print( + " total_to_first_audio_ms " + f"p50={_percentile(total_to_first_values, 0.50)} p95={_percentile(total_to_first_values, 0.95)} " + f"max={max(total_to_first_values) if total_to_first_values else None}", + flush=True, + ) + print( + " max_gap_ms " + f"p50={_percentile(gap_values, 0.50)} p95={_percentile(gap_values, 0.95)} " + f"max={max(gap_values) if gap_values else None}", + flush=True, + ) + + +def _load_texts(args: argparse.Namespace) -> tuple[str, ...]: + texts = [item for item in (args.text or []) if item] + if args.texts_file: + for line in Path(args.texts_file).read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if stripped and not stripped.startswith("#"): + texts.append(stripped) + if not texts: + texts.append(DEFAULT_TEXT) + return tuple(texts) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Carga direta no WebSocket de TTS xAI, sem LiveKit/TIA.", + ) + parser.add_argument("--env-file", default=".env.dev") + parser.add_argument("--websocket-url", default="") + parser.add_argument("--api-key", default="") + parser.add_argument("--voice", default="") + parser.add_argument("--language", default="") + parser.add_argument("--iterations", type=int, default=20) + parser.add_argument("--concurrency", type=int, default=1) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--reuse-connection", dest="reuse_connection", action="store_true", default=True) + mode.add_argument("--new-connection-per-request", dest="reuse_connection", action="store_false") + parser.add_argument("--keep-connection-after-failure", action="store_true") + parser.add_argument( + "--prewarm-idle-ms", + type=int, + default=0, + help="Abre o WebSocket e espera este tempo antes do primeiro texto da conexao.", + ) + parser.add_argument( + "--idle-between-requests-ms", + type=int, + default=0, + help="Espera este tempo entre turnos quando a mesma conexao e reutilizada.", + ) + parser.add_argument("--request-timeout-ms", type=int, default=10_000) + parser.add_argument("--connect-timeout-ms", type=int, default=10_000) + parser.add_argument("--gap-timeout-ms", type=int, default=3_000) + parser.add_argument("--optimize-streaming-latency", type=int, default=1) + parser.add_argument("--no-text-normalization", action="store_true") + parser.add_argument("--text", action="append", help="Texto a sintetizar. Pode repetir.") + parser.add_argument("--texts-file", default="", help="Arquivo com um texto por linha.") + parser.add_argument("--output-jsonl", default=str(DEFAULT_OUTPUT_JSONL)) + parser.add_argument("--summary-csv", default=str(DEFAULT_SUMMARY_CSV)) + parser.add_argument("--no-summary-csv", action="store_true") + parser.add_argument("--write-audio-dir", default="") + return parser.parse_args(argv) + + +def load_config(args: argparse.Namespace) -> Config: + env_file = Path(args.env_file) + if env_file.exists(): + load_dotenv(env_file, override=False) + + api_key = str(args.api_key or _env_str("XAI_API_KEY")).strip() + websocket_url = str(args.websocket_url or _env_str("XAI_WEBSOCKET_URL")).strip() + if not api_key: + raise RuntimeError("Missing XAI_API_KEY. Defina no .env.dev ou passe --api-key.") + if not websocket_url: + raise RuntimeError("Missing XAI_WEBSOCKET_URL. Defina no .env.dev ou passe --websocket-url.") + + return Config( + env_file=env_file, + api_key=api_key, + websocket_url=websocket_url, + voice=str(args.voice or _env_str("XAI_TTS_VOICE", "ara")).strip() or "ara", + language=str(args.language or _env_str("XAI_TTS_LANGUAGE", "pt-BR")).strip() or "pt-BR", + text_normalization=not bool(args.no_text_normalization), + optimize_streaming_latency=max(0, int(args.optimize_streaming_latency)), + iterations=max(1, int(args.iterations)), + concurrency=max(1, int(args.concurrency)), + reuse_connection=bool(args.reuse_connection), + reset_on_failure=not bool(args.keep_connection_after_failure), + prewarm_idle_s=max(0.0, int(args.prewarm_idle_ms) / 1000), + idle_between_requests_s=max(0.0, int(args.idle_between_requests_ms) / 1000), + request_timeout_s=max(0.1, int(args.request_timeout_ms) / 1000), + connect_timeout_s=max(0.1, int(args.connect_timeout_ms) / 1000), + gap_timeout_s=max(0.0, int(args.gap_timeout_ms) / 1000), + output_jsonl=Path(args.output_jsonl), + summary_csv=None if args.no_summary_csv else Path(args.summary_csv), + audio_dir=Path(args.write_audio_dir) if args.write_audio_dir else None, + texts=_load_texts(args), + ) + + +async def run(argv: list[str] | None = None) -> int: + config = load_config(parse_args(argv)) + config.output_jsonl.parent.mkdir(parents=True, exist_ok=True) + if config.audio_dir is not None: + config.audio_dir.mkdir(parents=True, exist_ok=True) + + run_id = uuid.uuid4().hex[:12] + started_at = time.perf_counter() + print( + "[xai-tts-load] iniciando " + f"run_id={run_id} iterations={config.iterations} concurrency={config.concurrency} " + f"reuse_connection={int(config.reuse_connection)} voice={config.voice} " + f"language={config.language} prewarm_idle_ms={round(config.prewarm_idle_s * 1000)} " + f"idle_between_requests_ms={round(config.idle_between_requests_s * 1000)} " + f"output={config.output_jsonl}", + flush=True, + ) + + write_lock = asyncio.Lock() + partitions = _partition_iterations(config.iterations, config.concurrency) + with config.output_jsonl.open("w", encoding="utf-8") as jsonl_handle: + tasks = [ + _run_worker( + config=config, + run_id=run_id, + worker_id=index + 1, + iterations=iterations, + jsonl_handle=jsonl_handle, + write_lock=write_lock, + ) + for index, iterations in enumerate(partitions) + if iterations + ] + nested_results = await asyncio.gather(*tasks) + + results = [result for batch in nested_results for result in batch] + results.sort(key=lambda item: item.iteration) + if config.summary_csv is not None: + _write_summary_csv(config.summary_csv, results) + print(f"[xai-tts-load] csv={config.summary_csv}", flush=True) + _print_summary(config, results, started_at) + return 1 if any(result.outcome != "success" for result in results) else 0 + + +def main() -> None: + raise SystemExit(asyncio.run(run())) + + +if __name__ == "__main__": + main() diff --git a/src/app/tools/xai_tts_ws.py b/src/app/tools/xai_tts_ws.py new file mode 100644 index 0000000..3e0f92a --- /dev/null +++ b/src/app/tools/xai_tts_ws.py @@ -0,0 +1,676 @@ +"""Captura crua dos eventos do WebSocket xAI TTS em JSONL. + +Este coletor nao usa LiveKit, AudioEmitter, buffer, retry nem timeout de gap. +Cada linha do JSONL e um evento do transporte/provedor, identificado por call_id. +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import binascii +from collections import Counter +import hashlib +import json +import os +import time +import uuid +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import urlencode + +import aiohttp +from dotenv import load_dotenv + +SAMPLE_RATE = 24_000 +PCM_BYTES_PER_SAMPLE = 2 +DEFAULT_TEXT = "Ola, esta e uma captura bruta de eventos do websocket de TTS." + + +@dataclass(frozen=True, slots=True) +class Config: + api_key: str + websocket_url: str + voice: str + language: str + text: str + calls: int + concurrency: int + prewarm: bool + event_timeout_s: float | None + output: Path + summary_output: Path + include_delta_base64: bool + initial_playout_buffer_ms: float + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds") + + +def elapsed_ms(started_at: float) -> int: + return max(0, round((time.perf_counter() - started_at) * 1000)) + + +def bool_query(value: bool) -> str: + return "true" if value else "false" + + +def build_url(config: Config) -> str: + query = { + "voice": config.voice, + "language": config.language, + "codec": "pcm", + "sample_rate": str(SAMPLE_RATE), + "optimize_streaming_latency": "1", + "text_normalization": bool_query(True), + } + return f"{config.websocket_url}?{urlencode(query)}" + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser( + description="Captura eventos brutos do WebSocket xAI TTS em JSONL, sem LiveKit." + ) + result.add_argument("--env-file", default=".env.dev") + result.add_argument("--api-key", default="") + result.add_argument("--websocket-url", default="") + result.add_argument("--voice", default="") + result.add_argument("--language", default="") + result.add_argument("--text", default=DEFAULT_TEXT) + result.add_argument("--calls", type=int, default=1) + result.add_argument("--concurrency", type=int, default=1) + result.add_argument( + "--prewarm", + action="store_true", + help="Abre todas as conexoes antes da janela medida. Requer --calls igual a --concurrency.", + ) + result.add_argument( + "--event-timeout-ms", + type=int, + default=65_000, + help="Limite de seguranca uniforme para cada ws.receive; 0 desativa. Nao e timeout de gap.", + ) + result.add_argument("--output", default=".run/xai-tts-ws-capture/events.jsonl") + result.add_argument( + "--summary-output", + default="", + help="JSON summary output. Default: .summary.json.", + ) + result.add_argument( + "--initial-playout-buffer-ms", + type=float, + default=200.0, + help="Initial buffer used only for the underflow simulation in the summary (default: 200).", + ) + result.add_argument( + "--include-delta-base64", + action="store_true", + help="Inclui o campo delta integral em cada audio.delta; pode gerar arquivos muito grandes.", + ) + return result + + +def load_config(args: argparse.Namespace) -> Config: + env_file = Path(args.env_file) + if env_file.exists(): + load_dotenv(env_file, override=False) + api_key = str(args.api_key or os.getenv("XAI_API_KEY", "")).strip() + websocket_url = str(args.websocket_url or os.getenv("XAI_WEBSOCKET_URL", "")).strip() + if not api_key: + raise RuntimeError("XAI_API_KEY ausente; informe --api-key ou --env-file.") + if not websocket_url: + raise RuntimeError("XAI_WEBSOCKET_URL ausente; informe --websocket-url ou --env-file.") + timeout_ms = max(0, int(args.event_timeout_ms)) + output = Path(args.output) + summary_output = Path(args.summary_output) if args.summary_output else output.with_suffix(".summary.json") + return Config( + api_key=api_key, + websocket_url=websocket_url, + voice=str(args.voice or os.getenv("XAI_TTS_VOICE", "ara")).strip() or "ara", + language=str(args.language or os.getenv("XAI_TTS_LANGUAGE", "pt-BR")).strip() or "pt-BR", + text=str(args.text), + calls=max(1, int(args.calls)), + concurrency=max(1, int(args.concurrency)), + prewarm=bool(args.prewarm), + event_timeout_s=(timeout_ms / 1000) if timeout_ms else None, + output=output, + summary_output=summary_output, + include_delta_base64=bool(args.include_delta_base64), + initial_playout_buffer_ms=max(0.0, float(args.initial_playout_buffer_ms)), + ) + + +class JsonlWriter: + def __init__(self, output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + self._handle = output.open("w", encoding="utf-8") + self._lock = asyncio.Lock() + + async def write(self, record: dict[str, Any]) -> None: + async with self._lock: + self._handle.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n") + self._handle.flush() + + def close(self) -> None: + self._handle.close() + + +async def receive_message(ws: aiohttp.ClientWebSocketResponse, timeout_s: float | None) -> aiohttp.WSMessage: + if timeout_s is None: + return await ws.receive() + return await asyncio.wait_for(ws.receive(), timeout=timeout_s) + + +def event_base(*, run_id: str, call_id: str, call_number: int, sequence: int, started_at: float) -> dict[str, Any]: + return { + "run_id": run_id, + "call_id": call_id, + "call_number": call_number, + "sequence": sequence, + "received_at_utc": utc_now(), + "received_after_call_start_ms": elapsed_ms(started_at), + } + + +async def capture_call( + *, + session: aiohttp.ClientSession, + config: Config, + writer: JsonlWriter, + run_id: str, + call_number: int, + call_id: str, + limiter: asyncio.Semaphore, + prewarmed_ws: aiohttp.ClientWebSocketResponse | None = None, +) -> str: + async with limiter: + started_at = time.perf_counter() + sequence = 0 + status = "unknown" + ws = prewarmed_ws + + async def write(kind: str, **fields: Any) -> None: + nonlocal sequence + sequence += 1 + record = event_base( + run_id=run_id, + call_id=call_id, + call_number=call_number, + sequence=sequence, + started_at=started_at, + ) + record["event"] = kind + record.update(fields) + await writer.write(record) + + try: + if ws is None: + await write("client.connecting", websocket_url=config.websocket_url) + connect_started_at = time.perf_counter() + ws = await session.ws_connect( + build_url(config), + headers={"Authorization": f"Bearer {config.api_key}"}, + heartbeat=20, + ) + await write("client.connected", connect_ms=elapsed_ms(connect_started_at)) + else: + await write("client.using_prewarmed_connection") + + await ws.send_json({"type": "text.clear"}) + await write("client.sent", message_type="text.clear") + clear_acknowledged = False + while not clear_acknowledged: + message = await receive_message(ws, config.event_timeout_s) + now = time.perf_counter() + if message.type != aiohttp.WSMsgType.TEXT: + await write("transport.message", ws_type=message.type.name, data=str(message.data)) + raise RuntimeError(f"esperava audio.clear, recebeu WebSocket {message.type.name}") + try: + payload = json.loads(str(message.data)) + except json.JSONDecodeError: + await write("provider.invalid_json", raw_text=str(message.data)) + raise + await write( + "provider.event", + message_type=str(payload.get("type") or ""), + provider_message=payload, + received_monotonic_ms=round(now * 1000), + ) + clear_acknowledged = payload.get("type") == "audio.clear" + if payload.get("type") == "error": + raise RuntimeError(str(payload.get("message") or payload)) + + await ws.send_json({"type": "text.delta", "delta": config.text}) + await write("client.sent", message_type="text.delta", text_chars=len(config.text)) + await ws.send_json({"type": "text.done"}) + await write("client.sent", message_type="text.done") + + while True: + try: + message = await receive_message(ws, config.event_timeout_s) + except asyncio.TimeoutError: + await write("client.receive_timeout", timeout_ms=None if config.event_timeout_s is None else round(config.event_timeout_s * 1000)) + status = "receive_timeout" + break + + now = time.perf_counter() + if message.type != aiohttp.WSMsgType.TEXT: + await write("transport.message", ws_type=message.type.name, data=str(message.data)) + status = f"transport_{message.type.name.lower()}" + break + + try: + payload = json.loads(str(message.data)) + except json.JSONDecodeError: + await write("provider.invalid_json", raw_text=str(message.data)) + status = "invalid_json" + break + + message_type = str(payload.get("type") or "") + if message_type == "audio.delta": + encoded = str(payload.get("delta") or "") + try: + pcm = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError) as exc: + await write( + "provider.audio_delta_invalid", + message_type=message_type, + base64_characters=len(encoded), + error=str(exc), + provider_message={key: value for key, value in payload.items() if key != "delta"}, + ) + status = "invalid_audio_delta" + break + record: dict[str, Any] = { + "message_type": message_type, + "base64_characters": len(encoded), + "audio_bytes": len(pcm), + "audio_ms": round(len(pcm) * 1000 / (SAMPLE_RATE * PCM_BYTES_PER_SAMPLE), 3), + "empty_audio": not bool(pcm), + "audio_sha256": hashlib.sha256(pcm).hexdigest(), + "provider_message": {key: value for key, value in payload.items() if key != "delta"}, + "received_monotonic_ms": round(now * 1000), + } + if config.include_delta_base64: + record["delta_base64"] = encoded + await write("provider.audio_delta", **record) + continue + + await write( + "provider.event", + message_type=message_type, + provider_message=payload, + received_monotonic_ms=round(now * 1000), + ) + if message_type == "audio.done": + status = "audio_done" + break + if message_type == "error": + status = "provider_error" + break + except Exception as exc: + if status == "unknown": + status = "exception" + await write("client.exception", error_type=type(exc).__name__, error=str(exc)) + finally: + if ws is not None and not ws.closed: + await ws.close() + await write("client.closed") + await write("client.finished", status=status, elapsed_ms=elapsed_ms(started_at)) + return status + + +async def prewarm_connection( + *, + session: aiohttp.ClientSession, + config: Config, + writer: JsonlWriter, + run_id: str, + call_number: int, + call_id: str, +) -> aiohttp.ClientWebSocketResponse: + started_at = time.perf_counter() + await writer.write({ + **event_base(run_id=run_id, call_id=call_id, call_number=call_number, sequence=-1, started_at=started_at), + "event": "client.prewarm_connecting", + "websocket_url": config.websocket_url, + }) + try: + ws = await session.ws_connect( + build_url(config), + headers={"Authorization": f"Bearer {config.api_key}"}, + heartbeat=20, + ) + except Exception as exc: + await writer.write({ + **event_base(run_id=run_id, call_id=call_id, call_number=call_number, sequence=0, started_at=started_at), + "event": "client.prewarm_failed", + "error_type": type(exc).__name__, + "error": str(exc), + }) + raise + await writer.write({ + **event_base(run_id=run_id, call_id=call_id, call_number=call_number, sequence=0, started_at=started_at), + "event": "client.prewarm_ready", + "connect_ms": elapsed_ms(started_at), + }) + return ws + + +def percentile(values: list[float], percentile_value: float) -> float | None: + """Return the nearest-rank percentile without introducing a dependency.""" + if not values: + return None + ordered = sorted(values) + index = round((len(ordered) - 1) * percentile_value) + return ordered[max(0, min(index, len(ordered) - 1))] + + +def round_ms(value: float | None) -> float | None: + return None if value is None else round(value, 3) + + +def replay_underflow( + audio_frames: list[tuple[float, float]], initial_buffer_ms: float +) -> tuple[int, float, float]: + """Replay raw arrivals against real-time playout after the initial buffer fills.""" + buffered_ms = 0.0 + playout_started = False + previous_arrival_ms: float | None = None + events = 0 + total_underflow_ms = 0.0 + largest_underflow_ms = 0.0 + + for arrived_at_ms, audio_ms in audio_frames: + if playout_started: + assert previous_arrival_ms is not None + elapsed_ms = arrived_at_ms - previous_arrival_ms + missing_audio_ms = max(0.0, elapsed_ms - buffered_ms) + if missing_audio_ms: + events += 1 + total_underflow_ms += missing_audio_ms + largest_underflow_ms = max(largest_underflow_ms, missing_audio_ms) + buffered_ms = max(0.0, buffered_ms - elapsed_ms) + audio_ms + previous_arrival_ms = arrived_at_ms + continue + + buffered_ms += audio_ms + if buffered_ms >= initial_buffer_ms: + playout_started = True + previous_arrival_ms = arrived_at_ms + + return events, total_underflow_ms, largest_underflow_ms + + +def metric_distribution(values: list[float]) -> dict[str, float | int | None]: + return { + "count": len(values), + "p50": round_ms(percentile(values, 0.50)), + "p95": round_ms(percentile(values, 0.95)), + "max": round_ms(max(values)) if values else None, + } + + +def summarize_capture(config: Config, run_id: str) -> dict[str, Any]: + """Build a provider-facing summary exclusively from the raw JSONL capture.""" + calls: dict[str, list[dict[str, Any]]] = {} + run_events: list[dict[str, Any]] = [] + with config.output.open(encoding="utf-8") as handle: + for line in handle: + record = json.loads(line) + call_id = record.get("call_id") + if call_id: + calls.setdefault(str(call_id), []).append(record) + else: + run_events.append(record) + + status_counts: Counter[str] = Counter() + error_counts: Counter[str] = Counter() + ttfb_ms: list[float] = [] + rtf: list[float] = [] + audio_duration_ms: list[float] = [] + frame_duration_ms: list[float] = [] + frame_gaps_ms: list[float] = [] + per_call_max_gaps_ms: list[float] = [] + frames_per_call: list[float] = [] + calls_with_audio = 0 + calls_with_underflow = 0 + underflow_events = 0 + total_underflow_ms = 0.0 + largest_underflow_ms = 0.0 + empty_audio_deltas = 0 + + for records in calls.values(): + records.sort(key=lambda record: int(record.get("sequence", -2))) + for record in records: + event = str(record.get("event") or "") + message_type = str(record.get("message_type") or "") + if event == "client.finished": + status_counts[str(record.get("status") or "unknown")] += 1 + if event == "client.prewarm_failed": + error_counts["prewarm_failed"] += 1 + elif event == "client.exception": + error_counts["client_exception"] += 1 + elif event == "client.receive_timeout": + error_counts["receive_timeout"] += 1 + elif event == "provider.audio_delta_invalid": + error_counts["invalid_audio_delta"] += 1 + elif event == "provider.invalid_json": + error_counts["invalid_json"] += 1 + elif event == "transport.message": + error_counts[f"transport_{str(record.get('ws_type') or 'unknown').lower()}"] += 1 + elif event == "provider.event" and message_type == "error": + error_counts["provider_error"] += 1 + elif event == "provider.audio_delta" and record.get("empty_audio"): + empty_audio_deltas += 1 + + text_done_at_ms = next( + ( + float(record["received_after_call_start_ms"]) + for record in records + if record.get("event") == "client.sent" and record.get("message_type") == "text.done" + ), + None, + ) + audio_frames = [ + (float(record["received_after_call_start_ms"]), float(record["audio_ms"])) + for record in records + if record.get("event") == "provider.audio_delta" and float(record.get("audio_bytes") or 0) > 0 + ] + if text_done_at_ms is None or not audio_frames: + continue + + calls_with_audio += 1 + frames_per_call.append(float(len(audio_frames))) + duration_ms = sum(frame[1] for frame in audio_frames) + audio_duration_ms.append(duration_ms) + frame_duration_ms.extend(frame[1] for frame in audio_frames) + ttfb_ms.append(audio_frames[0][0] - text_done_at_ms) + if duration_ms: + rtf.append((audio_frames[-1][0] - text_done_at_ms) / duration_ms) + gaps = [audio_frames[index][0] - audio_frames[index - 1][0] for index in range(1, len(audio_frames))] + frame_gaps_ms.extend(gaps) + if gaps: + per_call_max_gaps_ms.append(max(gaps)) + events, total_ms, largest_ms = replay_underflow(audio_frames, config.initial_playout_buffer_ms) + if events: + calls_with_underflow += 1 + underflow_events += events + total_underflow_ms += total_ms + largest_underflow_ms = max(largest_underflow_ms, largest_ms) + + run_failed = next((record for record in run_events if record.get("event") == "run.failed"), None) + completed_calls = status_counts.get("audio_done", 0) + observed_errors = sum(error_counts.values()) + return { + "summary_version": 1, + "language": "en", + "source_events_jsonl": str(config.output), + "run_id": run_id, + "configuration": { + "requested_calls": config.calls, + "concurrency": config.concurrency, + "prewarm": config.prewarm, + "initial_playout_buffer_ms": config.initial_playout_buffer_ms, + "event_timeout_ms": None if config.event_timeout_s is None else round(config.event_timeout_s * 1000), + "pcm_format": f"{SAMPLE_RATE} Hz, mono, signed 16-bit PCM", + }, + "completion": { + "calls_observed": len(calls), + "calls_with_audio": calls_with_audio, + "audio_done": completed_calls, + "audio_done_rate": round(completed_calls / config.calls, 4) if config.calls else None, + "finished_statuses": dict(sorted(status_counts.items())), + "run_failed": bool(run_failed), + "run_failure": None if run_failed is None else run_failed.get("error"), + }, + "latency": { + "ttfb_ms": metric_distribution(ttfb_ms), + "rtf": metric_distribution(rtf), + "generated_audio_ms_per_call": metric_distribution(audio_duration_ms), + }, + "frame_delivery": { + "nonempty_audio_frames_per_call": metric_distribution(frames_per_call), + "audio_ms_per_frame": metric_distribution(frame_duration_ms), + "interframe_gap_ms": metric_distribution(frame_gaps_ms), + "per_call_max_interframe_gap_ms": metric_distribution(per_call_max_gaps_ms), + "empty_audio_delta_count": empty_audio_deltas, + }, + "simulated_playout": { + "definition": "Raw audio.delta arrivals replayed at real-time PCM consumption after the initial buffer is accumulated.", + "initial_buffer_ms": config.initial_playout_buffer_ms, + "calls_with_underflow": calls_with_underflow, + "calls_with_underflow_rate": round(calls_with_underflow / config.calls, 4) if config.calls else None, + "underflow_events": underflow_events, + "total_underflow_ms": round_ms(total_underflow_ms), + "largest_underflow_ms": round_ms(largest_underflow_ms), + }, + "errors": { + "total_observed": observed_errors, + "by_type": dict(sorted(error_counts.items())), + "empty_audio_delta_count": empty_audio_deltas, + }, + } + + +def format_summary(summary: dict[str, Any]) -> str: + """Create a concise, copy-pasteable English summary for terminal output.""" + configuration = summary["configuration"] + completion = summary["completion"] + latency = summary["latency"] + frames = summary["frame_delivery"] + playout = summary["simulated_playout"] + errors = summary["errors"] + ttfb = latency["ttfb_ms"] + rtf = latency["rtf"] + gaps = frames["interframe_gap_ms"] + return "\n".join( + [ + "TTS WebSocket Capture Summary", + f"Load: {configuration['requested_calls']} calls at C={configuration['concurrency']} (prewarm: {'on' if configuration['prewarm'] else 'off'})", + f"Completion: {completion['audio_done']}/{configuration['requested_calls']} audio.done ({completion['audio_done_rate']:.1%})", + f"TTFB (text.done -> first non-empty audio): p50 {ttfb['p50']} ms | p95 {ttfb['p95']} ms | max {ttfb['max']} ms", + f"RTF (last audio arrival / generated audio): p50 {rtf['p50']} | p95 {rtf['p95']} | max {rtf['max']}", + f"Inter-frame gap: p50 {gaps['p50']} ms | p95 {gaps['p95']} ms | max {gaps['max']} ms", + f"Simulated underflow ({playout['initial_buffer_ms']} ms initial buffer): {playout['calls_with_underflow']}/{configuration['requested_calls']} calls | {playout['underflow_events']} events | largest {playout['largest_underflow_ms']} ms | total {playout['total_underflow_ms']} ms", + f"Errors: {errors['total_observed']} observed | empty audio.delta frames: {errors['empty_audio_delta_count']} | by type: {errors['by_type'] or 'none'}", + ] + ) + + +async def run(argv: list[str] | None = None) -> int: + config = load_config(parser().parse_args(argv)) + run_id = uuid.uuid4().hex + writer = JsonlWriter(config.output) + limiter = asyncio.Semaphore(config.concurrency) + statuses: list[str] = [] + call_ids = {number: f"call-{number:04d}-{uuid.uuid4().hex[:8]}" for number in range(1, config.calls + 1)} + try: + if config.prewarm and config.calls != config.concurrency: + raise ValueError("--prewarm requer --calls igual a --concurrency para manter a mesma janela de carga.") + await writer.write( + { + "event": "run.started", + "run_id": run_id, + "started_at_utc": utc_now(), + "calls": config.calls, + "concurrency": config.concurrency, + "prewarm": config.prewarm, + "event_timeout_ms": None if config.event_timeout_s is None else round(config.event_timeout_s * 1000), + "include_delta_base64": config.include_delta_base64, + "pcm_format": {"sample_rate": SAMPLE_RATE, "channels": 1, "sample_width_bytes": PCM_BYTES_PER_SAMPLE}, + } + ) + timeout = aiohttp.ClientTimeout(total=None) + connector = aiohttp.TCPConnector(limit=config.concurrency) + async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session: + prewarmed: dict[int, aiohttp.ClientWebSocketResponse | None] = {number: None for number in call_ids} + if config.prewarm: + prewarm_started_at = time.perf_counter() + results = await asyncio.gather( + *[ + prewarm_connection(session=session, config=config, writer=writer, run_id=run_id, call_number=number, call_id=call_ids[number]) + for number in call_ids + ], + return_exceptions=True, + ) + failures = [item for item in results if isinstance(item, BaseException)] + if failures: + for item in results: + if isinstance(item, aiohttp.ClientWebSocketResponse) and not item.closed: + await item.close() + raise RuntimeError(f"prewarm falhou em {len(failures)}/{config.calls} conexoes") + prewarmed = {number: results[number - 1] for number in call_ids} + await writer.write({ + "event": "run.prewarm_finished", "run_id": run_id, "finished_at_utc": utc_now(), + "prewarm_duration_ms": elapsed_ms(prewarm_started_at), "connections_ready": len(prewarmed), + }) + statuses = await asyncio.gather( + *[ + capture_call( + session=session, config=config, writer=writer, run_id=run_id, + call_number=call_number, call_id=call_ids[call_number], limiter=limiter, + prewarmed_ws=prewarmed[call_number], + ) + for call_number in call_ids + ] + ) + await writer.write( + { + "event": "run.finished", + "run_id": run_id, + "finished_at_utc": utc_now(), + "statuses": {status: statuses.count(status) for status in sorted(set(statuses))}, + } + ) + except BaseException as exc: + await writer.write( + { + "event": "run.failed", + "run_id": run_id, + "failed_at_utc": utc_now(), + "error_type": type(exc).__name__, + "error": str(exc), + } + ) + raise + finally: + writer.close() + summary = summarize_capture(config, run_id) + config.summary_output.parent.mkdir(parents=True, exist_ok=True) + config.summary_output.write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(format_summary(summary)) + print(f"Summary JSON: {config.summary_output}") + return 0 if statuses and all(status == "audio_done" for status in statuses) else 1 + + +def main() -> None: + raise SystemExit(asyncio.run(run())) + + +if __name__ == "__main__": + main() diff --git a/src/app/tools/xai_tts_ws_capture.py b/src/app/tools/xai_tts_ws_capture.py new file mode 100644 index 0000000..f78751a --- /dev/null +++ b/src/app/tools/xai_tts_ws_capture.py @@ -0,0 +1,949 @@ +"""Capture raw xAI TTS WebSocket events as JSONL. + +This collector records direct client/provider traffic. It does not use an audio +emitter, retries, or a gap timeout; each JSONL line is an event for one call. +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import binascii +import hashlib +import json +import math +import os +import sys +import time +import uuid +from collections import Counter +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.parse import urlencode + +import aiohttp +from dotenv import load_dotenv + +SAMPLE_RATE = 24_000 +PCM_BYTES_PER_SAMPLE = 2 +DEFAULT_TEXT = "This is a raw capture of TTS WebSocket events." + + +@dataclass(frozen=True, slots=True) +class Config: + api_key: str + websocket_url: str + voice: str + language: str + text: str + calls: int + concurrency: int + prewarm: bool + event_timeout_s: float | None + connect_timeout_s: float | None + output: Path + summary_output: Path + include_delta_base64: bool + initial_playout_buffer_ms: float + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat(timespec="milliseconds") + + +def elapsed_ms(started_at: float) -> int: + return max(0, round((time.perf_counter() - started_at) * 1000)) + + +def bool_query(value: bool) -> str: + return "true" if value else "false" + + +def build_url(config: Config) -> str: + query = { + "voice": config.voice, + "language": config.language, + "codec": "pcm", + "sample_rate": str(SAMPLE_RATE), + "optimize_streaming_latency": "1", + "text_normalization": bool_query(True), + } + return f"{config.websocket_url}?{urlencode(query)}" + + +def parser() -> argparse.ArgumentParser: + result = argparse.ArgumentParser( + description="Capture raw xAI TTS WebSocket events as JSONL." + ) + result.add_argument("--env-file", default=".env.dev") + result.add_argument("--api-key", default="") + result.add_argument("--websocket-url", default="") + result.add_argument("--voice", default="") + result.add_argument("--language", default="") + result.add_argument("--text", default=DEFAULT_TEXT) + result.add_argument("--calls", type=int, default=1) + result.add_argument("--concurrency", type=int, default=1) + result.add_argument( + "--prewarm", + action="store_true", + help="Open every WebSocket before the measured window. Requires --calls to equal --concurrency.", + ) + result.add_argument( + "--event-timeout-ms", + type=int, + default=65_000, + help="Safety limit for each ws.receive call; 0 disables it. This is not a gap timeout.", + ) + result.add_argument( + "--connect-timeout-ms", + type=int, + default=15_000, + help="Safety limit for each WebSocket handshake; 0 disables it.", + ) + result.add_argument("--output", default=".run/xai-tts-ws-capture/events.jsonl") + result.add_argument( + "--summary-output", + default="", + help="JSON summary output. Default: .summary.json.", + ) + result.add_argument( + "--initial-playout-buffer-ms", + type=float, + default=0.0, + help="Optional intentional prebuffer for the underflow simulation (default: 0; playout starts at the first non-empty audio.delta).", + ) + result.add_argument( + "--include-delta-base64", + action="store_true", + help="Include the full Base64 delta in each audio.delta event. This can produce very large files.", + ) + return result + + +def load_config(args: argparse.Namespace) -> Config: + env_file = Path(args.env_file) + if env_file.exists(): + load_dotenv(env_file, override=False) + api_key = str(args.api_key or os.getenv("XAI_API_KEY", "")).strip() + websocket_url = str( + args.websocket_url or os.getenv("XAI_WEBSOCKET_URL", "") + ).strip() + if not api_key: + raise RuntimeError("XAI_API_KEY is missing; provide --api-key or --env-file.") + if not websocket_url: + raise RuntimeError( + "XAI_WEBSOCKET_URL is missing; provide --websocket-url or --env-file." + ) + if not str(args.text).strip(): + raise ValueError("--text must not be empty.") + if int(args.calls) < 1: + raise ValueError("--calls must be at least 1.") + if int(args.concurrency) < 1: + raise ValueError("--concurrency must be at least 1.") + timeout_ms = max(0, int(args.event_timeout_ms)) + connect_timeout_ms = max(0, int(args.connect_timeout_ms)) + initial_playout_buffer_ms = float(args.initial_playout_buffer_ms) + if not math.isfinite(initial_playout_buffer_ms) or initial_playout_buffer_ms < 0: + raise ValueError( + "--initial-playout-buffer-ms must be a finite value greater than or equal to 0." + ) + output = Path(args.output) + summary_output = ( + Path(args.summary_output) + if args.summary_output + else output.with_suffix(".summary.json") + ) + if output.absolute() == summary_output.absolute(): + raise ValueError("--output and --summary-output must refer to different files.") + return Config( + api_key=api_key, + websocket_url=websocket_url, + voice=str(args.voice or os.getenv("XAI_TTS_VOICE", "ara")).strip() or "ara", + language=str(args.language or os.getenv("XAI_TTS_LANGUAGE", "pt-BR")).strip() + or "pt-BR", + text=str(args.text), + calls=int(args.calls), + concurrency=int(args.concurrency), + prewarm=bool(args.prewarm), + event_timeout_s=(timeout_ms / 1000) if timeout_ms else None, + connect_timeout_s=(connect_timeout_ms / 1000) if connect_timeout_ms else None, + output=output, + summary_output=summary_output, + include_delta_base64=bool(args.include_delta_base64), + initial_playout_buffer_ms=initial_playout_buffer_ms, + ) + + +class JsonlWriter: + def __init__(self, output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + self._handle = output.open("w", encoding="utf-8") + self._lock = asyncio.Lock() + + async def write(self, record: dict[str, Any]) -> None: + async with self._lock: + self._handle.write( + json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n" + ) + self._handle.flush() + + def close(self) -> None: + self._handle.close() + + +async def receive_message( + ws: aiohttp.ClientWebSocketResponse, timeout_s: float | None +) -> aiohttp.WSMessage: + if timeout_s is None: + return await ws.receive() + return await asyncio.wait_for(ws.receive(), timeout=timeout_s) + + +def event_base( + *, run_id: str, call_id: str, call_number: int, sequence: int, started_at: float +) -> dict[str, Any]: + return { + "run_id": run_id, + "call_id": call_id, + "call_number": call_number, + "sequence": sequence, + "received_at_utc": utc_now(), + "received_after_call_start_ms": elapsed_ms(started_at), + } + + +async def capture_call( + *, + session: aiohttp.ClientSession, + config: Config, + writer: JsonlWriter, + run_id: str, + call_number: int, + call_id: str, + limiter: asyncio.Semaphore, + prewarmed_ws: aiohttp.ClientWebSocketResponse | None = None, +) -> str: + async with limiter: + started_at = time.perf_counter() + sequence = 0 + status = "unknown" + ws = prewarmed_ws + + async def write(kind: str, **fields: Any) -> None: + nonlocal sequence + sequence += 1 + record = event_base( + run_id=run_id, + call_id=call_id, + call_number=call_number, + sequence=sequence, + started_at=started_at, + ) + record["event"] = kind + record.update(fields) + await writer.write(record) + + try: + if ws is None: + await write("client.connecting", websocket_url=config.websocket_url) + connect_started_at = time.perf_counter() + ws = await session.ws_connect( + build_url(config), + headers={"Authorization": f"Bearer {config.api_key}"}, + heartbeat=20, + ) + await write( + "client.connected", connect_ms=elapsed_ms(connect_started_at) + ) + else: + await write("client.using_prewarmed_connection") + + await ws.send_json({"type": "text.clear"}) + await write("client.sent", message_type="text.clear") + clear_acknowledged = False + while not clear_acknowledged: + message = await receive_message(ws, config.event_timeout_s) + now = time.perf_counter() + if message.type != aiohttp.WSMsgType.TEXT: + await write( + "transport.message", + ws_type=message.type.name, + data=str(message.data), + ) + raise RuntimeError( + f"Expected audio.clear, received WebSocket {message.type.name}." + ) + try: + payload = json.loads(str(message.data)) + except json.JSONDecodeError: + await write("provider.invalid_json", raw_text=str(message.data)) + raise + await write( + "provider.event", + message_type=str(payload.get("type") or ""), + provider_message=payload, + received_monotonic_ms=round(now * 1000), + ) + clear_acknowledged = payload.get("type") == "audio.clear" + if payload.get("type") == "error": + raise RuntimeError(str(payload.get("message") or payload)) + + await ws.send_json({"type": "text.delta", "delta": config.text}) + await write( + "client.sent", message_type="text.delta", text_chars=len(config.text) + ) + await ws.send_json({"type": "text.done"}) + await write("client.sent", message_type="text.done") + + while True: + try: + message = await receive_message(ws, config.event_timeout_s) + except asyncio.TimeoutError: + await write( + "client.receive_timeout", + timeout_ms=None + if config.event_timeout_s is None + else round(config.event_timeout_s * 1000), + ) + status = "receive_timeout" + break + + now = time.perf_counter() + if message.type != aiohttp.WSMsgType.TEXT: + await write( + "transport.message", + ws_type=message.type.name, + data=str(message.data), + ) + status = f"transport_{message.type.name.lower()}" + break + + try: + payload = json.loads(str(message.data)) + except json.JSONDecodeError: + await write("provider.invalid_json", raw_text=str(message.data)) + status = "invalid_json" + break + + message_type = str(payload.get("type") or "") + if message_type == "audio.delta": + encoded = str(payload.get("delta") or "") + try: + pcm = base64.b64decode(encoded, validate=True) + except (binascii.Error, ValueError) as exc: + await write( + "provider.audio_delta_invalid", + message_type=message_type, + base64_characters=len(encoded), + error=str(exc), + provider_message={ + key: value + for key, value in payload.items() + if key != "delta" + }, + ) + status = "invalid_audio_delta" + break + record: dict[str, Any] = { + "message_type": message_type, + "base64_characters": len(encoded), + "audio_bytes": len(pcm), + "audio_ms": round( + len(pcm) * 1000 / (SAMPLE_RATE * PCM_BYTES_PER_SAMPLE), 3 + ), + "empty_audio": not bool(pcm), + "audio_sha256": hashlib.sha256(pcm).hexdigest(), + "provider_message": { + key: value + for key, value in payload.items() + if key != "delta" + }, + "received_monotonic_ms": round(now * 1000), + } + if config.include_delta_base64: + record["delta_base64"] = encoded + await write("provider.audio_delta", **record) + continue + + await write( + "provider.event", + message_type=message_type, + provider_message=payload, + received_monotonic_ms=round(now * 1000), + ) + if message_type == "audio.done": + status = "audio_done" + break + if message_type == "error": + status = "provider_error" + break + except Exception as exc: + if status == "unknown": + status = "exception" + await write( + "client.exception", error_type=type(exc).__name__, error=str(exc) + ) + finally: + if ws is not None and not ws.closed: + await ws.close() + await write("client.closed") + await write( + "client.finished", status=status, elapsed_ms=elapsed_ms(started_at) + ) + return status + + +async def prewarm_connection( + *, + session: aiohttp.ClientSession, + config: Config, + writer: JsonlWriter, + run_id: str, + call_number: int, + call_id: str, +) -> aiohttp.ClientWebSocketResponse: + started_at = time.perf_counter() + await writer.write( + { + **event_base( + run_id=run_id, + call_id=call_id, + call_number=call_number, + sequence=-1, + started_at=started_at, + ), + "event": "client.prewarm_connecting", + "websocket_url": config.websocket_url, + } + ) + try: + ws = await session.ws_connect( + build_url(config), + headers={"Authorization": f"Bearer {config.api_key}"}, + heartbeat=20, + ) + except Exception as exc: + await writer.write( + { + **event_base( + run_id=run_id, + call_id=call_id, + call_number=call_number, + sequence=0, + started_at=started_at, + ), + "event": "client.prewarm_failed", + "error_type": type(exc).__name__, + "error": str(exc), + } + ) + raise + await writer.write( + { + **event_base( + run_id=run_id, + call_id=call_id, + call_number=call_number, + sequence=0, + started_at=started_at, + ), + "event": "client.prewarm_ready", + "connect_ms": elapsed_ms(started_at), + } + ) + return ws + + +def percentile(values: list[float], percentile_value: float) -> float | None: + """Return the nearest-rank percentile without introducing a dependency.""" + if not values: + return None + ordered = sorted(values) + index = round((len(ordered) - 1) * percentile_value) + return ordered[max(0, min(index, len(ordered) - 1))] + + +def round_ms(value: float | None) -> float | None: + return None if value is None else round(value, 3) + + +def replay_underflow( + audio_frames: list[tuple[float, float]], initial_buffer_ms: float +) -> tuple[int, float, float]: + """Replay raw arrivals; with a zero prebuffer, playout starts at first audio.delta.""" + buffered_ms = 0.0 + playout_started = False + previous_arrival_ms: float | None = None + events = 0 + total_underflow_ms = 0.0 + largest_underflow_ms = 0.0 + + for arrived_at_ms, audio_ms in audio_frames: + if playout_started: + assert previous_arrival_ms is not None + elapsed_ms = arrived_at_ms - previous_arrival_ms + missing_audio_ms = max(0.0, elapsed_ms - buffered_ms) + if missing_audio_ms: + events += 1 + total_underflow_ms += missing_audio_ms + largest_underflow_ms = max(largest_underflow_ms, missing_audio_ms) + buffered_ms = max(0.0, buffered_ms - elapsed_ms) + audio_ms + previous_arrival_ms = arrived_at_ms + continue + + buffered_ms += audio_ms + if buffered_ms >= initial_buffer_ms: + playout_started = True + previous_arrival_ms = arrived_at_ms + + return events, total_underflow_ms, largest_underflow_ms + + +def metric_distribution(values: list[float]) -> dict[str, float | int | None]: + return { + "count": len(values), + "p50": round_ms(percentile(values, 0.50)), + "p95": round_ms(percentile(values, 0.95)), + "max": round_ms(max(values)) if values else None, + } + + +def read_finite_float(record: dict[str, Any], field: str) -> float | None: + """Read a finite numeric field without allowing a malformed capture to break analysis.""" + try: + value = float(record[field]) + except (KeyError, TypeError, ValueError): + return None + return value if math.isfinite(value) else None + + +def event_sequence(record: dict[str, Any]) -> int: + """Sort malformed or missing sequence values before regular events.""" + try: + return int(record.get("sequence", -2)) + except (TypeError, ValueError): + return -2 + + +def display_metric(value: float | None, unit: str = "") -> str: + return "n/a" if value is None else f"{value}{unit}" + + +def summarize_capture(config: Config, run_id: str) -> dict[str, Any]: + """Build a provider-facing summary exclusively from the raw JSONL capture.""" + calls: dict[str, list[tuple[int, dict[str, Any]]]] = {} + run_events: list[dict[str, Any]] = [] + analysis_warnings: list[str] = [] + with config.output.open(encoding="utf-8") as handle: + for line_number, line in enumerate(handle, start=1): + if not line.strip(): + continue + try: + record = json.loads(line) + except json.JSONDecodeError as exc: + analysis_warnings.append( + f"Skipped invalid JSONL record at line {line_number}: {exc.msg}." + ) + continue + if not isinstance(record, dict): + analysis_warnings.append( + f"Skipped non-object JSONL record at line {line_number}." + ) + continue + call_id = record.get("call_id") + if call_id: + calls.setdefault(str(call_id), []).append((line_number, record)) + else: + run_events.append(record) + + status_counts: Counter[str] = Counter() + error_counts: Counter[str] = Counter() + ttfb_ms: list[float] = [] + rtf: list[float] = [] + audio_duration_ms: list[float] = [] + frame_duration_ms: list[float] = [] + frame_gaps_ms: list[float] = [] + per_call_max_gaps_ms: list[float] = [] + largest_gap_observation: dict[str, Any] | None = None + frames_per_call: list[float] = [] + calls_with_audio = 0 + calls_with_underflow = 0 + underflow_events = 0 + total_underflow_ms = 0.0 + largest_underflow_ms = 0.0 + empty_audio_deltas = 0 + + for call_id, call_records in calls.items(): + call_records.sort(key=lambda item: event_sequence(item[1])) + records = [record for _, record in call_records] + for record in records: + event = str(record.get("event") or "") + message_type = str(record.get("message_type") or "") + if event == "client.finished": + status_counts[str(record.get("status") or "unknown")] += 1 + if event == "client.prewarm_failed": + error_counts["prewarm_failed"] += 1 + elif event == "client.exception": + error_counts["client_exception"] += 1 + elif event == "client.receive_timeout": + error_counts["receive_timeout"] += 1 + elif event == "provider.audio_delta_invalid": + error_counts["invalid_audio_delta"] += 1 + elif event == "provider.invalid_json": + error_counts["invalid_json"] += 1 + elif event == "transport.message": + error_counts[ + f"transport_{str(record.get('ws_type') or 'unknown').lower()}" + ] += 1 + elif event == "provider.event" and message_type == "error": + error_counts["provider_error"] += 1 + elif event == "provider.audio_delta" and record.get("empty_audio"): + empty_audio_deltas += 1 + + text_done_at_ms = None + for line_number, record in call_records: + if ( + record.get("event") != "client.sent" + or record.get("message_type") != "text.done" + ): + continue + text_done_at_ms = read_finite_float(record, "received_after_call_start_ms") + if text_done_at_ms is None: + analysis_warnings.append( + f"Skipped invalid text.done timestamp at line {line_number} for {call_id}." + ) + break + audio_frame_records: list[dict[str, Any]] = [] + for line_number, record in call_records: + if record.get("event") != "provider.audio_delta": + continue + audio_bytes = read_finite_float(record, "audio_bytes") + received_at_ms = read_finite_float(record, "received_after_call_start_ms") + frame_audio_ms = read_finite_float(record, "audio_ms") + if audio_bytes is None or received_at_ms is None or frame_audio_ms is None: + analysis_warnings.append( + f"Skipped malformed audio.delta at line {line_number} for {call_id}." + ) + continue + if audio_bytes > 0 and frame_audio_ms >= 0: + audio_frame_records.append(record) + audio_frames = [ + (float(record["received_after_call_start_ms"]), float(record["audio_ms"])) + for record in audio_frame_records + ] + if text_done_at_ms is None or not audio_frames: + continue + + calls_with_audio += 1 + frames_per_call.append(float(len(audio_frames))) + duration_ms = sum(frame[1] for frame in audio_frames) + audio_duration_ms.append(duration_ms) + frame_duration_ms.extend(frame[1] for frame in audio_frames) + ttfb_ms.append(audio_frames[0][0] - text_done_at_ms) + if duration_ms: + rtf.append((audio_frames[-1][0] - text_done_at_ms) / duration_ms) + gaps = [ + audio_frames[index][0] - audio_frames[index - 1][0] + for index in range(1, len(audio_frames)) + ] + frame_gaps_ms.extend(gaps) + if gaps: + per_call_max_gaps_ms.append(max(gaps)) + largest_gap_index = max( + range(1, len(audio_frames)), + key=lambda index: audio_frames[index][0] - audio_frames[index - 1][0], + ) + largest_gap_ms = ( + audio_frames[largest_gap_index][0] + - audio_frames[largest_gap_index - 1][0] + ) + if ( + largest_gap_observation is None + or largest_gap_ms > largest_gap_observation["gap_ms"] + ): + previous_record = audio_frame_records[largest_gap_index - 1] + current_record = audio_frame_records[largest_gap_index] + largest_gap_observation = { + "gap_ms": round_ms(largest_gap_ms), + "call_id": call_id, + "previous_audio_delta_sequence": previous_record.get("sequence"), + "previous_arrived_after_call_start_ms": audio_frames[ + largest_gap_index - 1 + ][0], + "previous_frame_audio_ms": audio_frames[largest_gap_index - 1][1], + "next_audio_delta_sequence": current_record.get("sequence"), + "next_arrived_after_call_start_ms": audio_frames[largest_gap_index][ + 0 + ], + "next_frame_audio_ms": audio_frames[largest_gap_index][1], + } + events, total_ms, largest_ms = replay_underflow( + audio_frames, config.initial_playout_buffer_ms + ) + if events: + calls_with_underflow += 1 + underflow_events += events + total_underflow_ms += total_ms + largest_underflow_ms = max(largest_underflow_ms, largest_ms) + + run_failed = next( + (record for record in run_events if record.get("event") == "run.failed"), None + ) + completed_calls = status_counts.get("audio_done", 0) + observed_errors = sum(error_counts.values()) + return { + "summary_version": 1, + "language": "en", + "source_events_jsonl": str(config.output), + "run_id": run_id, + "configuration": { + "requested_calls": config.calls, + "concurrency": config.concurrency, + "prewarm": config.prewarm, + "initial_playout_buffer_ms": config.initial_playout_buffer_ms, + "event_timeout_ms": None + if config.event_timeout_s is None + else round(config.event_timeout_s * 1000), + "connect_timeout_ms": None + if config.connect_timeout_s is None + else round(config.connect_timeout_s * 1000), + "pcm_format": f"{SAMPLE_RATE} Hz, mono, signed 16-bit PCM", + }, + "completion": { + "calls_observed": len(calls), + "calls_with_audio": calls_with_audio, + "audio_done": completed_calls, + "audio_done_rate": round(completed_calls / config.calls, 4) + if config.calls + else None, + "finished_statuses": dict(sorted(status_counts.items())), + "run_failed": bool(run_failed), + "run_failure": None if run_failed is None else run_failed.get("error"), + }, + "latency": { + "ttfb_ms": metric_distribution(ttfb_ms), + "rtf": metric_distribution(rtf), + "generated_audio_ms_per_call": metric_distribution(audio_duration_ms), + }, + "frame_delivery": { + "raw_interframe_arrival_gap_definition": "Timestamp difference between consecutive non-empty provider.audio_delta messages for the same call. It excludes TTFB and does not include either frame's audio duration.", + "nonempty_audio_frames_per_call": metric_distribution(frames_per_call), + "audio_ms_per_frame": metric_distribution(frame_duration_ms), + "raw_interframe_arrival_gap_ms": metric_distribution(frame_gaps_ms), + "per_call_max_raw_interframe_arrival_gap_ms": metric_distribution( + per_call_max_gaps_ms + ), + "largest_raw_interframe_arrival_gap": largest_gap_observation, + "empty_audio_delta_count": empty_audio_deltas, + }, + "simulated_playout": { + "definition": "TTFB is excluded. The replay clock starts when the first non-empty audio.delta arrives. If initial_buffer_ms is greater than zero, playout is intentionally delayed until that much PCM has accumulated; otherwise it starts immediately at the first frame.", + "initial_buffer_ms": config.initial_playout_buffer_ms, + "calls_with_underflow": calls_with_underflow, + "calls_with_underflow_rate": round(calls_with_underflow / config.calls, 4) + if config.calls + else None, + "underflow_events": underflow_events, + "total_underflow_ms": round_ms(total_underflow_ms), + "largest_underflow_ms": round_ms(largest_underflow_ms), + }, + "errors": { + "total_observed": observed_errors, + "by_type": dict(sorted(error_counts.items())), + "empty_audio_delta_count": empty_audio_deltas, + }, + "analysis_warnings": analysis_warnings, + } + + +def format_summary(summary: dict[str, Any]) -> str: + """Create a concise, copy-pasteable English summary for terminal output.""" + configuration = summary["configuration"] + completion = summary["completion"] + latency = summary["latency"] + frames = summary["frame_delivery"] + playout = summary["simulated_playout"] + errors = summary["errors"] + warnings = summary["analysis_warnings"] + ttfb = latency["ttfb_ms"] + rtf = latency["rtf"] + gaps = frames["raw_interframe_arrival_gap_ms"] + largest_gap = frames["largest_raw_interframe_arrival_gap"] + largest_gap_context = ( + "n/a" + if largest_gap is None + else ( + f"call {largest_gap['call_id']}, sequences " + f"{largest_gap['previous_audio_delta_sequence']}->{largest_gap['next_audio_delta_sequence']}" + ) + ) + return "\n".join( + [ + "TTS WebSocket Capture Summary", + f"Load: {configuration['requested_calls']} calls at C={configuration['concurrency']} (prewarm: {'on' if configuration['prewarm'] else 'off'})", + f"Completion: {completion['audio_done']}/{configuration['requested_calls']} audio.done ({completion['audio_done_rate']:.1%})", + "TTFB (text.done -> first non-empty audio): " + f"p50 {display_metric(ttfb['p50'], ' ms')} | p95 {display_metric(ttfb['p95'], ' ms')} | max {display_metric(ttfb['max'], ' ms')}", + "RTF (last audio arrival / generated audio): " + f"p50 {display_metric(rtf['p50'])} | p95 {display_metric(rtf['p95'])} | max {display_metric(rtf['max'])}", + "Raw inter-frame arrival gap (consecutive non-empty audio.delta; excludes TTFB and frame duration): " + f"p50 {display_metric(gaps['p50'], ' ms')} | p95 {display_metric(gaps['p95'], ' ms')} | max {display_metric(gaps['max'], ' ms')} ({largest_gap_context})", + "Audio payload duration per frame: " + f"p50 {display_metric(frames['audio_ms_per_frame']['p50'], ' ms')} | p95 {display_metric(frames['audio_ms_per_frame']['p95'], ' ms')} | max {display_metric(frames['audio_ms_per_frame']['max'], ' ms')}", + "Simulated underflow " + f"(TTFB excluded; {playout['initial_buffer_ms']} ms intentional prebuffer): {playout['calls_with_underflow']}/{configuration['requested_calls']} calls | {playout['underflow_events']} events | largest {display_metric(playout['largest_underflow_ms'], ' ms')} | total {display_metric(playout['total_underflow_ms'], ' ms')}", + f"Errors: {errors['total_observed']} observed | empty audio.delta frames: {errors['empty_audio_delta_count']} | by type: {errors['by_type'] or 'none'}", + f"Analysis warnings: {len(warnings)}" + + (f" | {warnings[0]}" if warnings else ""), + ] + ) + + +async def run(argv: list[str] | None = None) -> int: + config = load_config(parser().parse_args(argv)) + run_id = uuid.uuid4().hex + writer = JsonlWriter(config.output) + limiter = asyncio.Semaphore(config.concurrency) + statuses: list[str] = [] + call_ids = { + number: f"call-{number:04d}-{uuid.uuid4().hex[:8]}" + for number in range(1, config.calls + 1) + } + try: + if config.prewarm and config.calls != config.concurrency: + raise ValueError( + "--prewarm requires --calls to equal --concurrency so the measured load window is consistent." + ) + await writer.write( + { + "event": "run.started", + "run_id": run_id, + "started_at_utc": utc_now(), + "calls": config.calls, + "concurrency": config.concurrency, + "prewarm": config.prewarm, + "event_timeout_ms": None + if config.event_timeout_s is None + else round(config.event_timeout_s * 1000), + "connect_timeout_ms": None + if config.connect_timeout_s is None + else round(config.connect_timeout_s * 1000), + "include_delta_base64": config.include_delta_base64, + "pcm_format": { + "sample_rate": SAMPLE_RATE, + "channels": 1, + "sample_width_bytes": PCM_BYTES_PER_SAMPLE, + }, + } + ) + timeout = aiohttp.ClientTimeout( + total=None, sock_connect=config.connect_timeout_s + ) + connector = aiohttp.TCPConnector(limit=config.concurrency) + async with aiohttp.ClientSession( + timeout=timeout, connector=connector + ) as session: + prewarmed: dict[int, aiohttp.ClientWebSocketResponse | None] = { + number: None for number in call_ids + } + if config.prewarm: + prewarm_started_at = time.perf_counter() + results = await asyncio.gather( + *[ + prewarm_connection( + session=session, + config=config, + writer=writer, + run_id=run_id, + call_number=number, + call_id=call_ids[number], + ) + for number in call_ids + ], + return_exceptions=True, + ) + failures = [item for item in results if isinstance(item, BaseException)] + if failures: + for item in results: + if ( + isinstance(item, aiohttp.ClientWebSocketResponse) + and not item.closed + ): + await item.close() + raise RuntimeError( + f"Prewarm failed for {len(failures)}/{config.calls} connections." + ) + prewarmed = {number: results[number - 1] for number in call_ids} + await writer.write( + { + "event": "run.prewarm_finished", + "run_id": run_id, + "finished_at_utc": utc_now(), + "prewarm_duration_ms": elapsed_ms(prewarm_started_at), + "connections_ready": len(prewarmed), + } + ) + statuses = await asyncio.gather( + *[ + capture_call( + session=session, + config=config, + writer=writer, + run_id=run_id, + call_number=call_number, + call_id=call_ids[call_number], + limiter=limiter, + prewarmed_ws=prewarmed[call_number], + ) + for call_number in call_ids + ] + ) + await writer.write( + { + "event": "run.finished", + "run_id": run_id, + "finished_at_utc": utc_now(), + "statuses": { + status: statuses.count(status) for status in sorted(set(statuses)) + }, + } + ) + except BaseException as exc: + await writer.write( + { + "event": "run.failed", + "run_id": run_id, + "failed_at_utc": utc_now(), + "error_type": type(exc).__name__, + "error": str(exc), + } + ) + raise + finally: + writer.close() + try: + summary = summarize_capture(config, run_id) + config.summary_output.parent.mkdir(parents=True, exist_ok=True) + config.summary_output.write_text( + json.dumps(summary, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(format_summary(summary)) + print(f"Summary JSON: {config.summary_output}") + except Exception as exc: + print( + f"Unable to generate summary JSON: {type(exc).__name__}: {exc}", + file=sys.stderr, + ) + return 0 if statuses and all(status == "audio_done" for status in statuses) else 1 + + +def main() -> None: + raise SystemExit(asyncio.run(run())) + + +if __name__ == "__main__": + main() diff --git a/src/app/utils/audio_backlog.py b/src/app/utils/audio_backlog.py new file mode 100644 index 0000000..ade198f --- /dev/null +++ b/src/app/utils/audio_backlog.py @@ -0,0 +1,111 @@ +"""Shed de backlog de audio guiado por energia, compartilhado entre a entrada +(cliente -> LiveKit) e a saida (agente -> cliente) do bridge. + +Ideia central: quando o backlog de uma fila de frames PCM16 passa de um limiar, +em vez de descartar cegamente os frames mais antigos -- o que pode cortar fala -- +preferimos descartar apenas os frames silenciosos, comprimindo pausas sem remover +amostras de voz. O descarte cego fica reservado para o regime de rajada da +entrada, onde o excesso e grande e a prioridade e recuperar latencia. Na saida +(fala do agente ja gerada, esperando ser tocada) o descarte cego nunca deve ser +usado: so silencio pode sair. +""" + +from __future__ import annotations + +import audioop +from dataclasses import dataclass +from typing import Any + +_FULL_SCALE = 32768.0 + + +def rms_threshold_from_dbfs(dbfs: float) -> int: + """Converte um limiar em dBFS para amplitude RMS PCM16 (ex.: -50 -> ~103).""" + try: + return max(0, int(_FULL_SCALE * (10 ** (float(dbfs) / 20.0)))) + except (ValueError, OverflowError): + return 0 + + +def frames_from_ms(value_ms: int, frame_ms: int, *, minimum: int = 1) -> int: + value_ms = max(0, int(value_ms)) + frame_ms = max(1, int(frame_ms)) + return max(minimum, (value_ms + frame_ms - 1) // frame_ms) + + +def frame_rms(frame: bytes, sample_width: int = 2) -> int: + try: + return audioop.rms(frame, sample_width) + except Exception: + return 0 + + +def is_silent_frame(frame: bytes, rms_threshold: int, *, sample_width: int = 2) -> bool: + return frame_rms(frame, sample_width) <= rms_threshold + + +@dataclass(frozen=True) +class ShedResult: + dropped: int = 0 + dropped_silent: int = 0 + dropped_voiced: int = 0 + mode: str = "none" + + +def shed_queue_backlog( + queue: Any, + *, + keep_frames: int, + rms_threshold: int, + blind: bool, + sample_width: int = 2, +) -> ShedResult: + """Reduz ``queue`` (``asyncio.Queue[bytes]``) ate ``keep_frames``. + + - ``blind=True``: descarta os frames mais antigos ate chegar em + ``keep_frames`` (regime de rajada -- recupera latencia mesmo cortando + conteudo). + - ``blind=False`` (guiado por energia): drena a fila e descarta apenas frames + silenciosos (rms <= ``rms_threshold``), preservando a fala. Pode terminar + acima de ``keep_frames`` se nao houver silencio suficiente -- de proposito. + + IMPORTANTE: deve ser chamada sem ``await`` entre os get/put. No event loop + isso e atomico: nenhum produtor/consumidor da fila roda no meio da drenagem. + """ + keep_frames = max(0, int(keep_frames)) + qsize = queue.qsize() + need = qsize - keep_frames + if need <= 0: + return ShedResult() + + if blind: + dropped = 0 + while queue.qsize() > keep_frames: + try: + queue.get_nowait() + except Exception: + break + dropped += 1 + return ShedResult(dropped=dropped, dropped_voiced=dropped, mode="blind") + + frames: list[bytes] = [] + while True: + try: + frames.append(queue.get_nowait()) + except Exception: + break + + to_drop = need + dropped_silent = 0 + kept: list[bytes] = [] + for frame in frames: + if to_drop > 0 and is_silent_frame(frame, rms_threshold, sample_width=sample_width): + to_drop -= 1 + dropped_silent += 1 + continue + kept.append(frame) + + for frame in kept: + queue.put_nowait(frame) + + return ShedResult(dropped=dropped_silent, dropped_silent=dropped_silent, mode="energy") diff --git a/src/app/utils/background.py b/src/app/utils/background.py new file mode 100644 index 0000000..c0aba2b --- /dev/null +++ b/src/app/utils/background.py @@ -0,0 +1,524 @@ +from __future__ import annotations + +import asyncio +import math +import logging +import os +import time +import audioop +from dataclasses import dataclass +from typing import Any, Callable, Mapping, Optional +from fastapi import WebSocket, WebSocketDisconnect + +from app.utils.audio_backlog import ( + frames_from_ms, + rms_threshold_from_dbfs, + shed_queue_backlog, +) +from app.utils.logging import log_flow_event + +logger = logging.getLogger("background") + + +def _out_env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default))) + except (TypeError, ValueError): + return default + + +def _out_env_float(name: str, default: float) -> float: + try: + return float(os.getenv(name, str(default))) + except (TypeError, ValueError): + return default + + +def _out_env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +@dataclass +class AudioActivity: + last_agent_out: float # "última fala com energia" do agente (idle/mock-stop) + + +@dataclass +class BridgeOutputStats: + total_ws_frames: int = 0 + agent_audio_frames: int = 0 + silence_frames: int = 0 + agent_audio_bursts: int = 0 + first_agent_audio_sent: bool = False + last_agent_audio_sent_at: float = 0.0 + + +@dataclass(frozen=True) +class AudioOutputBacklogConfig: + """Instrumentacao e shed do caminho de saida (agente -> cliente). + + O backlog da agent_q e, em boa parte, fala do agente ja gerada esperando ser + tocada em tempo real -- por isso o shed de saida e SEMPRE guiado por energia + (so silencio sai; nunca corta voz). O descarte cego da entrada nao se aplica. + """ + + metrics_enabled: bool = True + latency_alert_ms: int = 1000 + latency_log_interval_s: float = 15.0 + pacing_debt_threshold_ms: int = 200 + shed_enabled: bool = True + shed_threshold_ms: int = 400 + shed_keep_ms: int = 200 + shed_check_interval_frames: int = 5 + silence_dbfs: float = -60.0 + + def shed_threshold_frames(self, frame_ms: int) -> int: + return frames_from_ms(self.shed_threshold_ms, frame_ms) + + def shed_keep_frames(self, frame_ms: int) -> int: + return min( + self.shed_threshold_frames(frame_ms), + frames_from_ms(self.shed_keep_ms, frame_ms), + ) + + @property + def silence_rms_threshold(self) -> int: + return rms_threshold_from_dbfs(self.silence_dbfs) + + +def audio_output_backlog_config_from_env() -> AudioOutputBacklogConfig: + return AudioOutputBacklogConfig( + metrics_enabled=_out_env_bool("AUDIO_OUT_LATENCY_METRICS_ENABLED", True), + latency_alert_ms=max(0, _out_env_int("AUDIO_OUT_LATENCY_ALERT_MS", 1000)), + latency_log_interval_s=max( + 0.1, _out_env_float("AUDIO_OUT_LATENCY_LOG_INTERVAL_S", 15.0) + ), + pacing_debt_threshold_ms=max( + 0, _out_env_int("AUDIO_OUT_PACING_DEBT_THRESHOLD_MS", 200) + ), + shed_enabled=_out_env_bool("AUDIO_OUT_BACKLOG_SHED_ENABLED", True), + shed_threshold_ms=max(0, _out_env_int("AUDIO_OUT_BACKLOG_SHED_THRESHOLD_MS", 400)), + shed_keep_ms=max(0, _out_env_int("AUDIO_OUT_BACKLOG_SHED_KEEP_MS", 200)), + shed_check_interval_frames=max( + 1, _out_env_int("AUDIO_OUT_BACKLOG_SHED_CHECK_INTERVAL_FRAMES", 5) + ), + silence_dbfs=_out_env_float("AUDIO_OUT_BACKLOG_SILENCE_DBFS", -60.0), + ) + + +class AudioOutputLatencyTracker: + """Relogio + metrica + shed do caminho de saida, espelhando a entrada. + + - queue_ms/peak_queue_ms: quanto audio do agente espera na agent_q. + - pacing_debt_dropped_ms: divida de pacing jogada fora quando o loop atrasa + (antes era um resync mudo ``tick = now``, sem contador). + - total_dropped_ms: silencio descartado pelo shed de saida. + """ + + def __init__( + self, + config: AudioOutputBacklogConfig, + *, + frame_ms: int, + flow_logger: Any, + timeline: Any = None, + call_context: Optional[Mapping[str, Any]] = None, + debug_event_publisher: Optional[Callable[[str, Mapping[str, Any]], None]] = None, + ) -> None: + self.config = config + self.frame_ms = max(1, int(frame_ms)) + self.flow_logger = flow_logger + self.timeline = timeline + self.call_context = dict(call_context or {}) + self.debug_event_publisher = debug_event_publisher + self.peak_queue_ms = 0 + self.total_dropped_frames = 0 + self.total_dropped_silent = 0 + self.pacing_debt_events = 0 + self.pacing_debt_dropped_ms = 0 + self.last_log_at: Optional[float] = None + self.alert_active = False + + def _emit(self, step: str, payload: Mapping[str, Any]) -> None: + log_flow_event(self.flow_logger, step, **payload) + if self.timeline is not None: + try: + self.timeline.emit(step, **payload) + except Exception: + pass + + def note_pacing_debt(self, debt_ms: int, *, now: Optional[float] = None) -> None: + debt_ms = max(0, int(debt_ms)) + if debt_ms <= 0: + return + self.pacing_debt_events += 1 + self.pacing_debt_dropped_ms += debt_ms + if self.config.metrics_enabled: + self._emit( + "audio_out_pacing_debt", + { + **self.call_context, + "debt_ms": debt_ms, + "pacing_debt_events": self.pacing_debt_events, + "pacing_debt_dropped_ms": self.pacing_debt_dropped_ms, + }, + ) + + def record_shed( + self, + *, + dropped_frames: int, + dropped_silent: int, + mode: str, + queue_before_frames: int, + queue_after_frames: int, + ) -> None: + if dropped_frames <= 0: + return + self.total_dropped_frames += int(dropped_frames) + self.total_dropped_silent += int(dropped_silent) + if not self.config.metrics_enabled: + return + payload = { + **self.call_context, + "mode": mode, + "content_policy": "silence_only" if mode == "energy" else "oldest_frames", + "sync_action": "compress_silence" if mode == "energy" else "drop_audio_to_catch_up", + "dropped_frames": int(dropped_frames), + "dropped_ms": int(dropped_frames) * self.frame_ms, + "dropped_silent_frames": int(dropped_silent), + "queue_before_ms": int(queue_before_frames) * self.frame_ms, + "queue_after_ms": int(queue_after_frames) * self.frame_ms, + "total_dropped_ms": self.total_dropped_frames * self.frame_ms, + "shed_threshold_ms": self.config.shed_threshold_ms, + "shed_keep_ms": self.config.shed_keep_ms, + } + self._emit("audio_out_latency_shed", payload) + if self.debug_event_publisher is not None: + self.debug_event_publisher("bridge.audio_out.shed", payload) + + def maybe_log( + self, + *, + queue_frames: int, + now: float, + reason: str, + force: bool = False, + ) -> None: + queue_ms = max(0, int(queue_frames)) * self.frame_ms + self.peak_queue_ms = max(self.peak_queue_ms, queue_ms) + if not self.config.metrics_enabled: + return + alert_active = ( + self.config.latency_alert_ms > 0 + and queue_ms >= self.config.latency_alert_ms + ) + alert_started = alert_active and not self.alert_active + self.alert_active = alert_active + if self.last_log_at is None: + self.last_log_at = now + if not force and not alert_started: + return + should_log = ( + force + or alert_started + or (now - self.last_log_at) >= self.config.latency_log_interval_s + ) + if not should_log: + return + self.last_log_at = now + self._emit( + "audio_out_latency", + { + **self.call_context, + "queue_frames": max(0, int(queue_frames)), + "queue_ms": queue_ms, + "peak_queue_ms": self.peak_queue_ms, + "pacing_debt_events": self.pacing_debt_events, + "pacing_debt_dropped_ms": self.pacing_debt_dropped_ms, + "total_dropped_ms": self.total_dropped_frames * self.frame_ms, + "reason": reason, + "alert_ms": self.config.latency_alert_ms, + }, + ) +def _config_value(value: Any) -> str: + if value in (None, ""): + return "" + return str(value).strip() + + +def _gain_from_linear(value: Any) -> Optional[float]: + text = _config_value(value) + if not text: + return None + try: + gain = float(text) + return max(0.0, min(10.0, gain)) if math.isfinite(gain) else None + except Exception: + return None + + +def _get_ws_output_gain( + *, + output_gain: Any = None, +) -> float: + """ + Prioriza callConfig.ws.outputGain. Fallback: WS_OUTPUT_GAIN; default 1.0. + """ + for gain in ( + _gain_from_linear(output_gain), + _gain_from_linear(os.getenv("WS_OUTPUT_GAIN", "")), + ): + if gain is not None: + return gain + return 1.0 + + +def _apply_pcm_gain(frame: bytes, sample_width: int, gain: float) -> bytes: + if gain == 1.0: + return frame + + try: + return audioop.mul(frame, sample_width, gain) + except Exception: + return frame + + + +def _agent_speech_rms_threshold() -> int: + """Energia mínima para eventos de início de fala; não filtra o áudio.""" + try: + dbfs = float(os.getenv("AGENT_OUT_SPEECH_DBFS", "-52")) + except (TypeError, ValueError): + dbfs = -52.0 + if not math.isfinite(dbfs): + dbfs = -52.0 + return int(32768 * (10 ** (max(-90.0, min(0.0, dbfs)) / 20.0))) + + + +async def ws_out_loop( + ws: WebSocket, + agent_q: asyncio.Queue[bytes], + *, + frame_ms: int, + bytes_per_frame: int, + first_agent_audio_sent: Optional[asyncio.Event] = None, + flow_logger: Any = None, + timeline: Any = None, + call_context: Optional[Mapping[str, Any]] = None, + output_stats: Optional[BridgeOutputStats] = None, + output_gain: Any = None, + recording_sink: Any = None, + debug_event_publisher: Optional[Callable[[str, Mapping[str, Any]], None]] = None, +): + frame_interval = frame_ms / 1000.0 + silence = b"\x00" * bytes_per_frame + flow_logger = flow_logger or logger + flow_context = dict(call_context or {}) + output_stats = output_stats or BridgeOutputStats() + + audio_burst_gap_s = float(os.getenv("FLOW_AUDIO_BURST_GAP_S", "0.8")) + ws_output_gain = _get_ws_output_gain( + output_gain=output_gain, + ) + agent_speech_rms_threshold = _agent_speech_rms_threshold() + if ws_output_gain != 1.0: + logger.info("[ws-out] aplicando ganho global nos frames enviados ao WS gain=%.3f", ws_output_gain) + + # relogio + metrica + shed da saida (espelha a instrumentacao da entrada) + out_config = audio_output_backlog_config_from_env() + out_tracker = AudioOutputLatencyTracker( + out_config, + frame_ms=frame_ms, + flow_logger=flow_logger, + timeline=timeline, + call_context=flow_context, + debug_event_publisher=debug_event_publisher, + ) + out_shed_threshold_frames = out_config.shed_threshold_frames(frame_ms) + out_shed_keep_frames = out_config.shed_keep_frames(frame_ms) + out_silence_rms = out_config.silence_rms_threshold + pacing_debt_threshold_s = out_config.pacing_debt_threshold_ms / 1000.0 + if out_config.metrics_enabled: + out_tracker._emit( + "audio_out_backlog_config", + { + **flow_context, + "shed_enabled": out_config.shed_enabled, + "shed_threshold_ms": out_config.shed_threshold_ms, + "shed_keep_ms": out_config.shed_keep_ms, + "silence_dbfs": out_config.silence_dbfs, + "latency_alert_ms": out_config.latency_alert_ms, + "pacing_debt_threshold_ms": out_config.pacing_debt_threshold_ms, + "shed_policy": "silence_only", + }, + ) + + def _emit_flow(step: str, **fields: Any) -> None: + log_flow_event(flow_logger, step, **flow_context, **fields) + + tick = time.monotonic() + n = 0 + + while True: + now = time.monotonic() + + if now < tick: + await asyncio.sleep(tick - now) + now = time.monotonic() + + if now - tick > pacing_debt_threshold_s: + # loop atrasou: em vez de resync mudo, contabiliza a divida de pacing. + out_tracker.note_pacing_debt(round((now - tick) * 1000), now=now) + tick = now + + # 0) shed de backlog da saida: so silencio sai, nunca corta a fala do + # agente. Rate-limited para nao redrenar a fila a cada tick. + if ( + out_config.shed_enabled + and (n % out_config.shed_check_interval_frames) == 0 + ): + out_queue_before = agent_q.qsize() + if out_queue_before > out_shed_threshold_frames: + out_shed = shed_queue_backlog( + agent_q, + keep_frames=out_shed_keep_frames, + rms_threshold=out_silence_rms, + blind=False, + ) + if out_shed.dropped: + out_tracker.record_shed( + dropped_frames=out_shed.dropped, + dropped_silent=out_shed.dropped_silent, + mode=out_shed.mode, + queue_before_frames=out_queue_before, + queue_after_frames=agent_q.qsize(), + ) + + # 1) Consome exatamente um frame por tick. O frame e encaminhado como + # veio; silencio temporal do agente so e removido pelo shed acima. + agent_frame: Optional[bytes] = None + agent_frame_rms = 0 + agent_frame_has_speech = False + frame_source = "silence" + try: + cand = agent_q.get_nowait() + except asyncio.QueueEmpty: + cand = None + + if cand is not None: + if len(cand) != bytes_per_frame: + if len(cand) > bytes_per_frame: + cand = cand[:bytes_per_frame] + else: + cand = cand + (b"\x00" * (bytes_per_frame - len(cand))) + agent_frame = cand + try: + agent_frame_rms = audioop.rms(cand, 2) + except Exception: + agent_frame_rms = 0 + agent_frame_has_speech = agent_frame_rms > agent_speech_rms_threshold + if agent_frame is not None: + frame = agent_frame + frame_source = "agent" + else: + # fila vazia: preenche o pacing de 20ms com silêncio + frame = silence + + frame = _apply_pcm_gain(frame, 2, ws_output_gain) + + try: + await ws.send_bytes(frame) + except WebSocketDisconnect: + return + except RuntimeError as exc: + if "close message has been sent" in str(exc): + return + raise + + if recording_sink is not None: + try: + recording_sink.record_output_frame(frame) + except Exception: + pass + + output_stats.total_ws_frames += 1 + if frame_source == "agent": + output_stats.agent_audio_frames += 1 + new_burst = ( + output_stats.last_agent_audio_sent_at <= 0.0 + or (now - output_stats.last_agent_audio_sent_at) >= audio_burst_gap_s + ) + if agent_frame_has_speech: + output_stats.last_agent_audio_sent_at = now + + if agent_frame_has_speech and not output_stats.first_agent_audio_sent: + output_stats.first_agent_audio_sent = True + _emit_flow( + "audio_to_tia_first_frame", + destination="ws_client", + ws_frame=output_stats.total_ws_frames, + agent_audio_frames=output_stats.agent_audio_frames, + queued_agent_frames=agent_q.qsize(), + rms=agent_frame_rms, + bytes=len(frame), + ) + if timeline is not None: + timeline.emit( + "agent_audio_first_frame_sent_to_tia", + **flow_context, + destination="ws_client", + ws_frame=output_stats.total_ws_frames, + agent_audio_frames=output_stats.agent_audio_frames, + queued_agent_frames=agent_q.qsize(), + rms=agent_frame_rms, + bytes=len(frame), + ) + + if agent_frame_has_speech and new_burst: + output_stats.agent_audio_bursts += 1 + _emit_flow( + "audio_to_tia", + destination="ws_client", + seq=output_stats.agent_audio_bursts, + ws_frame=output_stats.total_ws_frames, + agent_audio_frames=output_stats.agent_audio_frames, + queued_agent_frames=agent_q.qsize(), + rms=agent_frame_rms, + bytes=len(frame), + ) + if timeline is not None: + timeline.emit( + "agent_audio_sent_to_tia", + **flow_context, + destination="ws_client", + seq=output_stats.agent_audio_bursts, + ws_frame=output_stats.total_ws_frames, + agent_audio_frames=output_stats.agent_audio_frames, + queued_agent_frames=agent_q.qsize(), + rms=agent_frame_rms, + bytes=len(frame), + ) + else: + output_stats.silence_frames += 1 + + if ( + agent_frame is not None + and agent_frame_has_speech + and first_agent_audio_sent is not None + and not first_agent_audio_sent.is_set() + ): + first_agent_audio_sent.set() + + out_tracker.maybe_log( + queue_frames=agent_q.qsize(), + now=now, + reason="tick", + ) + + n += 1 + tick += frame_interval diff --git a/src/app/utils/call_timeline.py b/src/app/utils/call_timeline.py new file mode 100644 index 0000000..ce40244 --- /dev/null +++ b/src/app/utils/call_timeline.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +import atexit +import json +import logging +import os +import queue +import threading +import time +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + +try: + import fcntl +except ImportError: # pragma: no cover - indisponível no Windows + fcntl = None + + +def _sanitize_for_filename(value: str) -> str: + raw = (value or "").strip() + if not raw: + return "unknown" + + allowed = [] + for ch in raw: + if ch.isalnum() or ch in {"-", "_", "."}: + allowed.append(ch) + else: + allowed.append("_") + + sanitized = "".join(allowed).strip("._") + return sanitized or "unknown" + + +def _json_safe(value: Any) -> Any: + if value is None: + return None + if isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, Path): + return str(value) + if isinstance(value, Mapping): + return {str(key): _json_safe(item) for key, item in value.items()} + if isinstance(value, (list, tuple, set)): + return [_json_safe(item) for item in value] + return str(value) + + +def _bounded_env_int(name: str, default: int, *, minimum: int, maximum: int) -> int: + try: + value = int(os.getenv(name, str(default)) or str(default)) + except (TypeError, ValueError): + value = default + return max(minimum, min(maximum, value)) + + +class _WriterCommand: + __slots__ = ("kind", "event") + + def __init__(self, kind: str) -> None: + self.kind = kind + self.event = threading.Event() + + +class _TimelineWriter: + """Serializa timelines numa thread dedicada, fora do event loop.""" + + def __init__( + self, + max_queue: int, + *, + warning_interval_s: float = 60.0, + ) -> None: + self._queue: queue.Queue[tuple[Path, str] | _WriterCommand] = queue.Queue( + maxsize=max(1, max_queue) + ) + self._thread: threading.Thread | None = None + self._lifecycle_lock = threading.Lock() + self._stats_lock = threading.Lock() + self._closed = False + self._dropped = 0 + self._write_errors = 0 + self._last_drop_warning_at = 0.0 + self._last_error_warning_at = 0.0 + self._warning_interval_s = max(1.0, float(warning_interval_s)) + self._logger = logging.getLogger(f"{__name__}.writer") + + @property + def dropped(self) -> int: + with self._stats_lock: + return self._dropped + + @property + def write_errors(self) -> int: + with self._stats_lock: + return self._write_errors + + def start(self) -> bool: + with self._lifecycle_lock: + if self._closed: + return False + if self._thread is not None and self._thread.is_alive(): + return True + self._thread = threading.Thread( + target=self._run, + name="call-timeline-writer", + daemon=True, + ) + self._thread.start() + return True + + def submit(self, path: Path, line: str) -> bool: + if not self.start(): + self._record_drop(reason="writer_closed") + return False + reason = "" + with self._lifecycle_lock: + if self._closed: + reason = "writer_closed" + else: + try: + self._queue.put_nowait((path, line)) + return True + except queue.Full: + reason = "queue_full" + self._record_drop(reason=reason) + return False + + def flush(self, timeout: float | None = 2.0) -> bool: + with self._lifecycle_lock: + if self._closed: + return self._queue.empty() + if not self.start(): + return False + command = _WriterCommand("flush") + deadline = None if timeout is None else time.monotonic() + max(0.0, timeout) + with self._lifecycle_lock: + if self._closed: + return self._queue.empty() + if not self._enqueue_command(command, deadline): + return False + remaining = None if deadline is None else max(0.0, deadline - time.monotonic()) + return command.event.wait(remaining) + + def shutdown(self, timeout: float | None = 2.0) -> bool: + with self._lifecycle_lock: + if self._closed: + thread = self._thread + return thread is None or not thread.is_alive() + self._closed = True + thread = self._thread + + if thread is None: + return True + + command = _WriterCommand("shutdown") + deadline = None if timeout is None else time.monotonic() + max(0.0, timeout) + if not self._enqueue_command(command, deadline): + return False + remaining = None if deadline is None else max(0.0, deadline - time.monotonic()) + if not command.event.wait(remaining): + return False + remaining = None if deadline is None else max(0.0, deadline - time.monotonic()) + thread.join(remaining) + return not thread.is_alive() + + def _enqueue_command( + self, + command: _WriterCommand, + deadline: float | None, + ) -> bool: + try: + if deadline is None: + self._queue.put(command) + else: + self._queue.put(command, timeout=max(0.0, deadline - time.monotonic())) + return True + except queue.Full: + return False + + def _run(self) -> None: + while True: + first = self._queue.get() + batch: list[tuple[Path, str]] = [] + commands: list[_WriterCommand] = [] + self._collect(first, batch, commands) + while True: + try: + item = self._queue.get_nowait() + except queue.Empty: + break + self._collect(item, batch, commands) + + if batch: + self._write_batch(batch) + + should_stop = False + for command in commands: + command.event.set() + should_stop = should_stop or command.kind == "shutdown" + if should_stop: + return + + @staticmethod + def _collect( + item: tuple[Path, str] | _WriterCommand, + batch: list[tuple[Path, str]], + commands: list[_WriterCommand], + ) -> None: + if isinstance(item, _WriterCommand): + commands.append(item) + else: + batch.append(item) + + def _write_batch(self, batch: list[tuple[Path, str]]) -> None: + by_path: dict[Path, list[str]] = defaultdict(list) + for path, line in batch: + by_path[path].append(line) + for path, lines in by_path.items(): + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as handle: + if fcntl is not None: + fcntl.flock(handle.fileno(), fcntl.LOCK_EX) + try: + handle.write("".join(f"{line}\n" for line in lines)) + finally: + if fcntl is not None: + fcntl.flock(handle.fileno(), fcntl.LOCK_UN) + except Exception as exc: + self._record_write_error(path=path, exc=exc) + + def _record_drop(self, *, reason: str) -> None: + now = time.monotonic() + with self._stats_lock: + self._dropped += 1 + dropped = self._dropped + should_warn = now - self._last_drop_warning_at >= self._warning_interval_s + if should_warn: + self._last_drop_warning_at = now + if should_warn: + self._logger.warning( + "CALL_TIMELINE_ASYNC_DROP | reason=%s | dropped_total=%s | queue_size=%s", + reason, + dropped, + self._queue.qsize(), + ) + + def _record_write_error(self, *, path: Path, exc: Exception) -> None: + now = time.monotonic() + with self._stats_lock: + self._write_errors += 1 + errors = self._write_errors + should_warn = now - self._last_error_warning_at >= self._warning_interval_s + if should_warn: + self._last_error_warning_at = now + if should_warn: + self._logger.warning( + "CALL_TIMELINE_ASYNC_WRITE_FAIL | path=%s | error=%s: %s | errors_total=%s", + path, + type(exc).__name__, + exc, + errors, + ) + + +_WRITER = _TimelineWriter( + _bounded_env_int( + "CALL_TIMELINE_QUEUE_MAX", + 10_000, + minimum=1, + maximum=1_000_000, + ), + warning_interval_s=_bounded_env_int( + "ASYNC_IO_WARNING_INTERVAL_S", + 60, + minimum=1, + maximum=3_600, + ), +) +atexit.register(_WRITER.shutdown) + + +class CallTimeline: + def __init__( + self, + *, + logger: logging.Logger, + component: str, + timeline_id: str, + protocol: str = "", + room: str = "", + session_id: str = "", + phone_number: str = "", + origin_unix_ms: int | None = None, + ) -> None: + self._logger = logger + self._component = (component or "unknown").strip().lower() + self._timeline_id = _sanitize_for_filename(timeline_id or session_id or protocol or room) + self._protocol = (protocol or "").strip() + self._room = (room or "").strip() + self._session_id = (session_id or "").strip() + self._phone_number = (phone_number or "").strip() + self._origin_unix_ms = int(origin_unix_ms or round(time.time() * 1000)) + self._enabled = os.getenv("CALL_TIMELINE_ENABLED", "1") == "1" + self._console_enabled = os.getenv("CALL_TIMELINE_CONSOLE", "0") == "1" + self._dir = Path(os.getenv("CALL_TIMELINE_DIR", "./timeline")) + self._path = self._dir / f"{self._timeline_id}.jsonl" + if self._enabled: + _WRITER.start() + + @property + def origin_unix_ms(self) -> int: + return self._origin_unix_ms + + @property + def path(self) -> Path: + return self._path + + def emit(self, event: str, **fields: Any) -> None: + if not self._enabled: + return + + now_ms = round(time.time() * 1000) + record = { + "ts": datetime.now(timezone.utc).isoformat(), + "t_rel_ms": max(0, int(now_ms - self._origin_unix_ms)), + "component": self._component, + "event": (event or "unknown").strip(), + "timeline_id": self._timeline_id, + "protocol": self._protocol, + "room": self._room, + "session_id": self._session_id, + "phone_number": self._phone_number, + } + for key, value in fields.items(): + record[str(key)] = _json_safe(value) + + line = json.dumps(record, ensure_ascii=False) + self._append_line(line) + + if self._console_enabled: + self._logger.info("TIMELINE | %s", line) + + def _append_line(self, line: str) -> None: + _WRITER.submit(self._path, line) + + def flush(self, timeout: float | None = 2.0) -> bool: + """Aguarda eventos já enfileirados; não deve ser chamado no loop de áudio.""" + return _WRITER.flush(timeout=timeout) + + @property + def dropped_events(self) -> int: + return _WRITER.dropped diff --git a/src/app/utils/dump.py b/src/app/utils/dump.py new file mode 100644 index 0000000..a6c1289 --- /dev/null +++ b/src/app/utils/dump.py @@ -0,0 +1,76 @@ +import os +import time +import wave +import uuid +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +def _dump_pcm16_wav( + pcm16: bytes, + *, + sample_rate: int = 16000, + channels: int = 1, + dump_dir: str | None = None, + prefix: str = "stt", +) -> str | None: + """ + Salva PCM16LE (raw) em WAV para inspeção. + Retorna path salvo ou None se desabilitado. + """ + dump_dir = dump_dir or os.getenv("STT_DUMP_DIR", "") + if not dump_dir: + return None + + Path(dump_dir).mkdir(parents=True, exist_ok=True) + ts = time.strftime("%Y%m%dT%H%M%S") + fname = f"{prefix}_{ts}_{uuid.uuid4().hex[:8]}.wav" + path = str(Path(dump_dir) / fname) + + with wave.open(path, "wb") as w: + w.setnchannels(channels) + w.setsampwidth(2) # PCM16 => 2 bytes + w.setframerate(sample_rate) + w.writeframes(pcm16) + + return path + + +def _dump_bytes(path: str, b: bytes) -> None: + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_bytes(b) + +def _dump_audio_for_debug( + *, + req_id: str, + pcm: bytes, + wav_bytes: bytes, + sample_rate: int, + channels: int, +) -> dict: + """ + Salva PCM e WAV em disco quando STT_DUMP_DIR estiver setado. + Retorna dict com paths (ou vazio se desabilitado). + """ + dump_dir = os.getenv("STT_DUMP_DIR", "").strip() + if not dump_dir: + return {} + + ts = time.strftime("%Y%m%dT%H%M%S") + base = str(Path(dump_dir) / f"stt_{ts}_{req_id}") + + pcm_path = base + ".pcm" # raw PCM16LE + wav_path = base + ".wav" # WAV pronto (exatamente o que vai pro STT) + + _dump_bytes(pcm_path, pcm) + _dump_bytes(wav_path, wav_bytes) + + return { + "pcm_path": pcm_path, + "wav_path": wav_path, + "bytes_pcm": len(pcm), + "bytes_wav": len(wav_bytes), + "sample_rate": sample_rate, + "channels": channels, + } \ No newline at end of file diff --git a/src/app/utils/export.py b/src/app/utils/export.py new file mode 100644 index 0000000..f20e584 --- /dev/null +++ b/src/app/utils/export.py @@ -0,0 +1,191 @@ +import json +import csv +import os +import re +from datetime import datetime +from typing import Union, List, Dict, Any, Optional + + +def _only_digits(value: Any) -> str: + return re.sub(r"\D+", "", str(value or "")) + + +def _find_first(obj: Any, keys: List[str]) -> Optional[Any]: + """ + Procura recursivamente (dict/list) pelo primeiro campo existente em `keys`. + Retorna o valor encontrado ou None. + """ + if isinstance(obj, dict): + for k in keys: + if k in obj and obj[k] not in (None, ""): + return obj[k] + for v in obj.values(): + found = _find_first(v, keys) + if found not in (None, ""): + return found + elif isinstance(obj, list): + for it in obj: + found = _find_first(it, keys) + if found not in (None, ""): + return found + return None + + +def _parse_date_time(date_str: Any, time_str: Any) -> Optional[datetime]: + date_s = str(date_str).strip() + time_s = str(time_str).strip() + + # Normaliza hora + if re.fullmatch(r"\d{6}", time_s): # HHMMSS + time_s = f"{time_s[0:2]}:{time_s[2:4]}:{time_s[4:6]}" + elif re.fullmatch(r"\d{4}", time_s): # HHMM + time_s = f"{time_s[0:2]}:{time_s[2:4]}:00" + elif re.fullmatch(r"\d{2}:\d{2}$", time_s): # HH:MM + time_s = f"{time_s}:00" + + # Normaliza data (suporta 2026-01-26, 26/01/2026, 20260126) + parsed_date = None + for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%Y%m%d"): + try: + parsed_date = datetime.strptime(date_s, fmt).date() + break + except ValueError: + pass + + if not parsed_date: + return None + + try: + parsed_time = datetime.strptime(time_s, "%H:%M:%S").time() + except ValueError: + return None + + return datetime.combine(parsed_date, parsed_time) + + +def _parse_iso_datetime(value: Any) -> Optional[datetime]: + if value in (None, ""): + return None + s = str(value).strip() + try: + # suporta "Z" no final + dt = datetime.fromisoformat(s.replace("Z", "+00:00")) + # remove timezone para ficar consistente no filename + return dt.replace(tzinfo=None) + except ValueError: + return None + + +def _extract_success_purchase(payload: Any) -> Dict[str, Any]: + """ + Encontra o dict success_purchase (quando existir) no payload. + """ + if isinstance(payload, dict): + sp = payload.get("success_purchase") + return sp if isinstance(sp, dict) else {} + if isinstance(payload, list) and payload and isinstance(payload[0], dict): + sp = payload[0].get("success_purchase") + return sp if isinstance(sp, dict) else {} + return {} + + +def _build_filename(payload: Any, uuid: Any) -> str: + # Telefone: tenta vários campos comuns (success_purchase / formatted_call / etc.) + phone_val = _find_first( + payload, + ["NUM_TELEFONE", "NUM_ACESSO", "telefone", "phone", "phone_number"], + ) + phone = _only_digits(phone_val) or _only_digits(uuid) or "unknown" + + # Data/hora: tenta DATA + HORA primeiro + date_val = _find_first(payload, ["DATA", "data", "date"]) + time_val = _find_first(payload, ["HORA", "hora", "time"]) + + dt = None + if date_val and time_val: + dt = _parse_date_time(date_val, time_val) + + # Fallback: tenta dataISO / timestamp ISO + if not dt: + dt = _parse_iso_datetime( + _find_first(payload, ["dataISO", "DATA_ISO", "timestamp", "datetime"]) + ) + + # Último fallback: agora + if not dt: + dt = datetime.now() + + stamp = dt.strftime("%Y%m%dT%H%M%S") + return f"{phone}_{stamp}.csv" + + +def json_to_csv( + json_input: Union[str, List[Dict[str, Any]], Dict[str, Any]], + uuid, + encoding: str = "utf-8", +) -> None: + # 1) Carrega o JSON (arquivo ou objeto em memória) + if isinstance(json_input, str): + with open(json_input, "r", encoding=encoding) as f: + payload = json.load(f) + else: + payload = json_input + + print("Json de retorno", payload) + + # 2) Valida success_purchase (mantendo sua regra) + success_purchase = _extract_success_purchase(payload) + if not success_purchase: + return + + # 3) Nome do arquivo: telefone_data + fname = _build_filename(payload, uuid) + + # Base da pasta vinda da env, padrão ./recordings + base_dir = os.getenv("EXPORT_DIR", "./recordings") + + # 4) Salva direto em ./recordings (sem subpasta do telefone) + os.makedirs(base_dir, exist_ok=True) + path = os.path.join(base_dir, fname) + + # 5) Normaliza os dados para lista de dicts (para achatar) + data = payload + if isinstance(data, dict): + data = [data] + elif not isinstance(data, list): + raise ValueError("O JSON deve ser uma lista de objetos ou um único objeto.") + + if not data: + raise ValueError("JSON está vazio, não há dados para escrever no CSV.") + + # 6) Achata cada linha + linhas_flat: List[Dict[str, Any]] = [] + fieldnames_set = set() + + for item in data: + if not isinstance(item, dict): + raise ValueError("Cada item da lista JSON deve ser um objeto (dict).") + + flat_row: Dict[str, Any] = {} + + for key, value in item.items(): + if isinstance(value, dict): + for subkey, subvalue in value.items(): + flat_row[subkey] = subvalue + else: + flat_row[key] = value + + linhas_flat.append(flat_row) + fieldnames_set.update(flat_row.keys()) + + # Opcional: ordena as colunas para ficar estável + fieldnames = sorted(fieldnames_set) + + # 7) Escreve CSV + with open(path, "w", encoding=encoding, newline="") as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + for row in linhas_flat: + writer.writerow(row) + + print(f"CSV gerado com sucesso em: {path}") diff --git a/src/app/utils/full_call_recording.py b/src/app/utils/full_call_recording.py new file mode 100644 index 0000000..e8da323 --- /dev/null +++ b/src/app/utils/full_call_recording.py @@ -0,0 +1,903 @@ +from __future__ import annotations + +import asyncio +import hashlib +import logging +import os +import queue +import re +import threading +import time +import uuid +import wave +import weakref +from collections import deque +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Mapping + +from app.utils.logging import log_flow_event +from app.utils.stt_audio_upload import ( + OCIUploadConfig, + _OCI_UPLOAD_LOCK, + _oci_client, + _oci_upload_config_from_env, + _reset_oci_client, +) + +logger = logging.getLogger(__name__) + +_DEFAULT_QUEUE_SIZE = 1024 +_DEFAULT_MAX_CLIENT_BUFFER_FRAMES = 512 +_UPLOAD_QUEUE_SIZE = 8 +_UPLOAD_MAX_ATTEMPTS = 3 +_UPLOAD_RETRY_BASE_DELAY_S = 0.25 +_MAX_PART_CHARS = 64 +_SAFE_PART_RE = re.compile(r"[^A-Za-z0-9_.-]+") +_SENTINEL = object() +_RESERVED_LOG_FIELD_NAMES = frozenset( + { + "audio_duration_ms", + "bytes", + "duration_ms", + "dropped_client_buffer_frames", + "dropped_queue_frames", + "error", + "frame_ms", + "frames", + "object_name", + "path", + "reason", + "sample_rate", + "session_id", + } +) + + +@dataclass(frozen=True, slots=True) +class EntireCallRecordingResult: + object_name: str + path: Path + bytes: int + frames: int + duration_ms: int + dropped_queue_frames: int + dropped_client_buffer_frames: int + upload_enqueued: bool + + +@dataclass(frozen=True, slots=True) +class _RecordingEvent: + source: str + frame: bytes + + +@dataclass(frozen=True, slots=True) +class EntireCallUploadItem: + object_name: str + path: Path + session_id: str + bytes: int + duration_ms: int + dropped_queue_frames: int + dropped_client_buffer_frames: int + metadata: Mapping[str, Any] + logger: Any | None = None + timeline: Any | None = None + + +def _safe_path_part(value: Any, *, fallback: str) -> str: + raw = str(value or "").strip() + if not raw: + raw = fallback + safe = _SAFE_PART_RE.sub("_", raw).strip("._-") or fallback + if len(safe) <= _MAX_PART_CHARS: + return safe + + digest = hashlib.sha1(safe.encode("utf-8")).hexdigest()[:12] + prefix_len = max(1, _MAX_PART_CHARS - len(digest) - 1) + return f"{safe[:prefix_len].rstrip('._-')}-{digest}" + + +def build_entire_call_object_name( + *, + session_id: Any, + now: datetime | None = None, +) -> str: + dt = now or datetime.now() + session_part = _safe_path_part(session_id, fallback="unknown-session") + return f"{dt:%Y-%m-%d}/entire_call/{session_part}.wav" + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _tmp_dir_from_env() -> Path: + return Path(os.getenv("ENTIRE_CALL_RECORDING_TMP_DIR", "./recordings/entire_call_tmp")) + + +def _metadata_fields(metadata: Mapping[str, Any]) -> dict[str, Any]: + return { + str(key): value + for key, value in metadata.items() + if value not in (None, "") and str(key) not in _RESERVED_LOG_FIELD_NAMES + } + + +def _emit_timeline(timeline: Any | None, event: str, **fields: Any) -> None: + if timeline is None: + return + try: + timeline.emit(event, **fields) + except Exception: + pass + + +def _cleanup_path(path: Path, active_logger: Any | None = None) -> None: + try: + path.unlink(missing_ok=True) + except Exception: + (active_logger or logger).debug( + "[entire-call-recording] failed cleaning temp file path=%s", + path, + exc_info=True, + ) + + +def _put_object_file_sync(item: EntireCallUploadItem, config: OCIUploadConfig) -> Any: + with _OCI_UPLOAD_LOCK: + client = _oci_client(config) + with item.path.open("rb") as body: + return client.put_object( + namespace_name=config.namespace, + bucket_name=config.bucket, + object_name=item.object_name, + put_object_body=body, + ) + + +def _upload_max_attempts() -> int: + raw = os.getenv("ENTIRE_CALL_RECORDING_UPLOAD_MAX_ATTEMPTS", str(_UPLOAD_MAX_ATTEMPTS)) + try: + return max(1, int(raw)) + except (TypeError, ValueError): + return _UPLOAD_MAX_ATTEMPTS + + +def _upload_retry_base_delay_s() -> float: + raw = os.getenv( + "ENTIRE_CALL_RECORDING_UPLOAD_RETRY_BASE_DELAY_S", + str(_UPLOAD_RETRY_BASE_DELAY_S), + ) + try: + return max(0.0, float(raw)) + except (TypeError, ValueError): + return _UPLOAD_RETRY_BASE_DELAY_S + + +async def _upload_item(item: EntireCallUploadItem) -> None: + started = time.perf_counter() + config = _oci_upload_config_from_env() + if config is None: + raise RuntimeError("OCI Object Storage upload config is missing") + + max_attempts = _upload_max_attempts() + base_delay_s = _upload_retry_base_delay_s() + response: Any | None = None + for attempt in range(1, max_attempts + 1): + try: + response = await asyncio.to_thread(_put_object_file_sync, item, config) + break + except Exception as exc: + # Do not close/reset the shared HTTP session while an STT upload is + # using it. The same lock serializes reset and put_object calls. + with _OCI_UPLOAD_LOCK: + _reset_oci_client() + if attempt >= max_attempts: + raise + delay_s = base_delay_s * (2 ** (attempt - 1)) + log_flow_event( + item.logger or logger, + "entire_call_recording_upload_retry", + session_id=item.session_id, + object_name=item.object_name, + attempt=attempt, + max_attempts=max_attempts, + delay_ms=round(delay_s * 1000), + error_type=type(exc).__name__, + **_metadata_fields(item.metadata), + ) + if delay_s: + await asyncio.sleep(delay_s) + + if response is None: + raise RuntimeError("OCI Object Storage upload returned no response") + duration_ms = round((time.perf_counter() - started) * 1000) + active_logger = item.logger or logger + log_flow_event( + active_logger, + "entire_call_recording_upload_done", + session_id=item.session_id, + object_name=item.object_name, + bytes=item.bytes, + audio_duration_ms=item.duration_ms, + duration_ms=duration_ms, + bucket=config.bucket, + namespace=config.namespace, + oci_status=getattr(response, "status", None), + dropped_queue_frames=item.dropped_queue_frames, + dropped_client_buffer_frames=item.dropped_client_buffer_frames, + **_metadata_fields(item.metadata), + ) + _emit_timeline( + item.timeline, + "entire_call_recording_upload_done", + session_id=item.session_id, + object_name=item.object_name, + bytes=item.bytes, + audio_duration_ms=item.duration_ms, + duration_ms=duration_ms, + bucket=config.bucket, + namespace=config.namespace, + oci_status=getattr(response, "status", None), + dropped_queue_frames=item.dropped_queue_frames, + dropped_client_buffer_frames=item.dropped_client_buffer_frames, + **_metadata_fields(item.metadata), + ) + + +class _EntireCallUploadWorker: + def __init__(self, loop: asyncio.AbstractEventLoop) -> None: + self._loop = loop + self._queue: asyncio.Queue[EntireCallUploadItem] = asyncio.Queue(maxsize=_UPLOAD_QUEUE_SIZE) + self._task: asyncio.Task[None] | None = None + + def enqueue(self, item: EntireCallUploadItem) -> bool: + if self._task is None or self._task.done(): + self._task = self._loop.create_task( + self._run(), + name="entire-call-recording-upload-worker", + ) + try: + self._queue.put_nowait(item) + return True + except asyncio.QueueFull: + return False + + async def _run(self) -> None: + while True: + item = await self._queue.get() + active_logger = item.logger or logger + try: + await _upload_item(item) + _cleanup_path(item.path, active_logger) + except Exception: + log_flow_event( + active_logger, + "entire_call_recording_upload_failed", + session_id=item.session_id, + object_name=item.object_name, + bytes=item.bytes, + audio_duration_ms=item.duration_ms, + retained_path=str(item.path), + dropped_queue_frames=item.dropped_queue_frames, + dropped_client_buffer_frames=item.dropped_client_buffer_frames, + **_metadata_fields(item.metadata), + ) + _emit_timeline( + item.timeline, + "entire_call_recording_upload_failed", + session_id=item.session_id, + object_name=item.object_name, + bytes=item.bytes, + audio_duration_ms=item.duration_ms, + retained_path=str(item.path), + dropped_queue_frames=item.dropped_queue_frames, + dropped_client_buffer_frames=item.dropped_client_buffer_frames, + **_metadata_fields(item.metadata), + ) + active_logger.exception( + "[entire-call-recording][upload_failed] session_id=%s object_name=%s", + item.session_id, + item.object_name, + ) + finally: + self._queue.task_done() + + +_UPLOAD_WORKERS: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, _EntireCallUploadWorker] = ( + weakref.WeakKeyDictionary() +) +_UPLOAD_WORKERS_LOCK = threading.Lock() + + +def _worker_for_loop(loop: asyncio.AbstractEventLoop) -> _EntireCallUploadWorker: + with _UPLOAD_WORKERS_LOCK: + worker = _UPLOAD_WORKERS.get(loop) + if worker is None: + worker = _EntireCallUploadWorker(loop) + _UPLOAD_WORKERS[loop] = worker + return worker + + +def enqueue_entire_call_upload( + *, + path: Path, + object_name: str, + session_id: str, + bytes: int, + duration_ms: int, + dropped_queue_frames: int = 0, + dropped_client_buffer_frames: int = 0, + metadata: Mapping[str, Any] | None = None, + logger_override: Any | None = None, + timeline: Any | None = None, + cleanup_on_drop: bool = False, +) -> str | None: + active_logger = logger_override or logger + metadata = metadata or {} + if _oci_upload_config_from_env() is None: + log_flow_event( + active_logger, + "entire_call_recording_upload_drop", + session_id=session_id, + object_name=object_name, + reason="missing_oci_config", + bytes=bytes, + audio_duration_ms=duration_ms, + **_metadata_fields(metadata), + ) + _emit_timeline( + timeline, + "entire_call_recording_upload_drop", + session_id=session_id, + object_name=object_name, + reason="missing_oci_config", + bytes=bytes, + audio_duration_ms=duration_ms, + **_metadata_fields(metadata), + ) + if cleanup_on_drop: + _cleanup_path(path, active_logger) + return None + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + log_flow_event( + active_logger, + "entire_call_recording_upload_drop", + session_id=session_id, + object_name=object_name, + reason="no_running_loop", + bytes=bytes, + audio_duration_ms=duration_ms, + **_metadata_fields(metadata), + ) + if cleanup_on_drop: + _cleanup_path(path, active_logger) + return None + + item = EntireCallUploadItem( + object_name=object_name, + path=path, + session_id=session_id, + bytes=bytes, + duration_ms=duration_ms, + dropped_queue_frames=dropped_queue_frames, + dropped_client_buffer_frames=dropped_client_buffer_frames, + metadata=metadata, + logger=active_logger, + timeline=timeline, + ) + queued = _worker_for_loop(loop).enqueue(item) + if not queued: + log_flow_event( + active_logger, + "entire_call_recording_upload_drop", + session_id=session_id, + object_name=object_name, + reason="queue_full", + bytes=bytes, + audio_duration_ms=duration_ms, + **_metadata_fields(metadata), + ) + _emit_timeline( + timeline, + "entire_call_recording_upload_drop", + session_id=session_id, + object_name=object_name, + reason="queue_full", + bytes=bytes, + audio_duration_ms=duration_ms, + **_metadata_fields(metadata), + ) + if cleanup_on_drop: + _cleanup_path(path, active_logger) + return object_name + + log_flow_event( + active_logger, + "entire_call_recording_upload_enqueued", + session_id=session_id, + object_name=object_name, + bytes=bytes, + audio_duration_ms=duration_ms, + dropped_queue_frames=dropped_queue_frames, + dropped_client_buffer_frames=dropped_client_buffer_frames, + **_metadata_fields(metadata), + ) + _emit_timeline( + timeline, + "entire_call_recording_upload_enqueued", + session_id=session_id, + object_name=object_name, + bytes=bytes, + audio_duration_ms=duration_ms, + dropped_queue_frames=dropped_queue_frames, + dropped_client_buffer_frames=dropped_client_buffer_frames, + **_metadata_fields(metadata), + ) + return object_name + + +class EntireCallRecorder: + def __init__( + self, + *, + session_id: str, + sample_rate: int, + channels: int, + sample_width: int, + frame_ms: int, + bytes_per_frame: int, + tmp_dir: Path, + logger_override: Any | None = None, + timeline: Any | None = None, + metadata: Mapping[str, Any] | None = None, + queue_size: int = _DEFAULT_QUEUE_SIZE, + max_client_buffer_frames: int = _DEFAULT_MAX_CLIENT_BUFFER_FRAMES, + object_name: str | None = None, + ) -> None: + self.session_id = str(session_id or "").strip() or "unknown-session" + self.sample_rate = int(sample_rate) + self.source_channels = int(channels) + if self.source_channels != 1: + raise ValueError("entire call recorder requires mono source frames") + self.channels = 2 + self.sample_width = int(sample_width) + self.frame_ms = int(frame_ms) + self.bytes_per_frame = int(bytes_per_frame) + self.object_name = object_name or build_entire_call_object_name(session_id=self.session_id) + self._tmp_dir = tmp_dir + self._logger = logger_override or logger + self._timeline = timeline + self._metadata = dict(metadata or {}) + self._queue: queue.Queue[_RecordingEvent | object] = queue.Queue( + maxsize=max(1, int(queue_size)) + ) + self._max_client_buffer_frames = max(1, int(max_client_buffer_frames)) + self._thread: threading.Thread | None = None + self._state_lock = threading.Lock() + self._started = False + self._closed = False + self._finalized = False + self._thread_error: BaseException | None = None + self._frames_written = 0 + self._dropped_queue_frames = 0 + self._dropped_client_buffer_frames = 0 + self._path = self._tmp_dir / ( + f"{_safe_path_part(self.session_id, fallback='unknown-session')}-{uuid.uuid4().hex}.wav" + ) + + @property + def path(self) -> Path: + return self._path + + @property + def dropped_queue_frames(self) -> int: + return self._dropped_queue_frames + + def start(self) -> None: + with self._state_lock: + if self._started: + return + self._tmp_dir.mkdir(parents=True, exist_ok=True) + self._started = True + self._thread = threading.Thread( + target=self._run, + name=f"entire-call-recorder-{self.session_id[:16]}", + daemon=True, + ) + self._thread.start() + + log_flow_event( + self._logger, + "entire_call_recording_started", + session_id=self.session_id, + object_name=self.object_name, + path=str(self._path), + sample_rate=self.sample_rate, + channels=self.channels, + channel_left="client", + channel_right="agent", + frame_ms=self.frame_ms, + **_metadata_fields(self._metadata), + ) + _emit_timeline( + self._timeline, + "entire_call_recording_started", + session_id=self.session_id, + object_name=self.object_name, + path=str(self._path), + sample_rate=self.sample_rate, + channels=self.channels, + channel_left="client", + channel_right="agent", + frame_ms=self.frame_ms, + **_metadata_fields(self._metadata), + ) + + def record_client_frame(self, frame: bytes) -> None: + self._enqueue("client", frame) + + def record_output_frame(self, frame: bytes) -> None: + self._enqueue("output", frame) + + def _enqueue(self, source: str, frame: bytes) -> None: + try: + with self._state_lock: + if not self._started or self._closed: + return + self._queue.put_nowait(_RecordingEvent(source=source, frame=self._normalize_frame(frame))) + except queue.Full: + self._record_queue_drop(source=source) + except Exception: + self._logger.debug( + "[entire-call-recording] failed enqueueing frame source=%s", + source, + exc_info=True, + ) + + def _record_queue_drop(self, *, source: str) -> None: + with self._state_lock: + self._dropped_queue_frames += 1 + dropped = self._dropped_queue_frames + + if dropped == 1 or dropped % 100 == 0: + log_flow_event( + self._logger, + "entire_call_recording_drop", + session_id=self.session_id, + object_name=self.object_name, + source=source, + reason="queue_full", + dropped_queue_frames=dropped, + queue_size=self._queue.qsize(), + **_metadata_fields(self._metadata), + ) + _emit_timeline( + self._timeline, + "entire_call_recording_drop", + session_id=self.session_id, + object_name=self.object_name, + source=source, + reason="queue_full", + dropped_queue_frames=dropped, + queue_size=self._queue.qsize(), + **_metadata_fields(self._metadata), + ) + + def _normalize_frame(self, frame: bytes) -> bytes: + data = bytes(frame or b"") + if len(data) == self.bytes_per_frame: + return data + if len(data) > self.bytes_per_frame: + return data[: self.bytes_per_frame] + return data + (b"\x00" * (self.bytes_per_frame - len(data))) + + def _interleave_stereo_frames(self, client_frame: bytes, output_frame: bytes) -> bytes: + """Build stereo PCM with the client on the left and the agent on the right.""" + stereo = bytearray(len(client_frame) + len(output_frame)) + write_offset = 0 + for sample_offset in range(0, self.bytes_per_frame, self.sample_width): + next_offset = sample_offset + self.sample_width + stereo[write_offset : write_offset + self.sample_width] = client_frame[ + sample_offset:next_offset + ] + write_offset += self.sample_width + stereo[write_offset : write_offset + self.sample_width] = output_frame[ + sample_offset:next_offset + ] + write_offset += self.sample_width + return bytes(stereo) + + def _write_stereo_frame( + self, + wav_handle: wave.Wave_write, + *, + client_frame: bytes, + output_frame: bytes, + ) -> None: + wav_handle.writeframesraw( + self._interleave_stereo_frames(client_frame, output_frame) + ) + self._frames_written += 1 + + def _run(self) -> None: + silence = b"\x00" * self.bytes_per_frame + client_frames: deque[bytes] = deque() + try: + with wave.open(str(self._path), "wb") as wav_handle: + wav_handle.setnchannels(self.channels) + wav_handle.setsampwidth(self.sample_width) + wav_handle.setframerate(self.sample_rate) + + while True: + event = self._queue.get() + try: + if event is _SENTINEL: + break + + if not isinstance(event, _RecordingEvent): + continue + + if event.source == "client": + if len(client_frames) >= self._max_client_buffer_frames: + client_frames.popleft() + self._dropped_client_buffer_frames += 1 + client_frames.append(event.frame) + continue + + if event.source == "output": + client_frame = client_frames.popleft() if client_frames else silence + self._write_stereo_frame( + wav_handle, + client_frame=client_frame, + output_frame=event.frame, + ) + finally: + self._queue.task_done() + + while client_frames: + self._write_stereo_frame( + wav_handle, + client_frame=client_frames.popleft(), + output_frame=silence, + ) + except BaseException as exc: + self._thread_error = exc + self._logger.exception( + "[entire-call-recording][writer_failed] session_id=%s object_name=%s", + self.session_id, + self.object_name, + ) + + def _close_and_join(self) -> None: + with self._state_lock: + if not self._started or self._closed: + return + self._closed = True + + self._queue.put(_SENTINEL, timeout=5.0) + thread = self._thread + if thread is not None: + thread.join(timeout=5.0) + if thread.is_alive(): + raise TimeoutError("entire call recorder writer did not stop in time") + + async def finalize(self, *, enqueue_upload: bool = True) -> EntireCallRecordingResult | None: + with self._state_lock: + if self._finalized: + return None + self._finalized = True + + try: + await asyncio.to_thread(self._close_and_join) + except Exception: + log_flow_event( + self._logger, + "entire_call_recording_failed", + session_id=self.session_id, + object_name=self.object_name, + reason="writer_finalize_failed", + **_metadata_fields(self._metadata), + ) + _emit_timeline( + self._timeline, + "entire_call_recording_failed", + session_id=self.session_id, + object_name=self.object_name, + reason="writer_finalize_failed", + **_metadata_fields(self._metadata), + ) + self._logger.exception( + "[entire-call-recording][finalize_failed] session_id=%s object_name=%s", + self.session_id, + self.object_name, + ) + _cleanup_path(self._path, self._logger) + return None + + if self._thread_error is not None: + log_flow_event( + self._logger, + "entire_call_recording_failed", + session_id=self.session_id, + object_name=self.object_name, + reason="writer_failed", + error=type(self._thread_error).__name__, + **_metadata_fields(self._metadata), + ) + _emit_timeline( + self._timeline, + "entire_call_recording_failed", + session_id=self.session_id, + object_name=self.object_name, + reason="writer_failed", + error=type(self._thread_error).__name__, + **_metadata_fields(self._metadata), + ) + _cleanup_path(self._path, self._logger) + return None + + file_bytes = self._path.stat().st_size if self._path.exists() else 0 + duration_ms = round(self._frames_written * self.frame_ms) + result = EntireCallRecordingResult( + object_name=self.object_name, + path=self._path, + bytes=file_bytes, + frames=self._frames_written, + duration_ms=duration_ms, + dropped_queue_frames=self._dropped_queue_frames, + dropped_client_buffer_frames=self._dropped_client_buffer_frames, + upload_enqueued=False, + ) + log_flow_event( + self._logger, + "entire_call_recording_finalized", + session_id=self.session_id, + object_name=self.object_name, + path=str(self._path), + bytes=file_bytes, + frames=self._frames_written, + audio_duration_ms=duration_ms, + channels=self.channels, + channel_left="client", + channel_right="agent", + dropped_queue_frames=self._dropped_queue_frames, + dropped_client_buffer_frames=self._dropped_client_buffer_frames, + **_metadata_fields(self._metadata), + ) + _emit_timeline( + self._timeline, + "entire_call_recording_finalized", + session_id=self.session_id, + object_name=self.object_name, + path=str(self._path), + bytes=file_bytes, + frames=self._frames_written, + audio_duration_ms=duration_ms, + channels=self.channels, + channel_left="client", + channel_right="agent", + dropped_queue_frames=self._dropped_queue_frames, + dropped_client_buffer_frames=self._dropped_client_buffer_frames, + **_metadata_fields(self._metadata), + ) + + if not enqueue_upload: + return result + + queued_object = enqueue_entire_call_upload( + path=self._path, + object_name=self.object_name, + session_id=self.session_id, + bytes=file_bytes, + duration_ms=duration_ms, + dropped_queue_frames=self._dropped_queue_frames, + dropped_client_buffer_frames=self._dropped_client_buffer_frames, + metadata=self._metadata, + logger_override=self._logger, + timeline=self._timeline, + ) + return EntireCallRecordingResult( + object_name=self.object_name, + path=self._path, + bytes=file_bytes, + frames=self._frames_written, + duration_ms=duration_ms, + dropped_queue_frames=self._dropped_queue_frames, + dropped_client_buffer_frames=self._dropped_client_buffer_frames, + upload_enqueued=queued_object is not None, + ) + + +def create_entire_call_recorder_from_env( + *, + session_id: str, + sample_rate: int, + channels: int, + sample_width: int, + frame_ms: int, + bytes_per_frame: int, + logger_override: Any | None = None, + timeline: Any | None = None, + metadata: Mapping[str, Any] | None = None, +) -> EntireCallRecorder | None: + active_logger = logger_override or logger + metadata = metadata or {} + if not _env_bool("ENTIRE_CALL_RECORDING_ENABLED", True): + log_flow_event( + active_logger, + "entire_call_recording_disabled", + session_id=session_id, + reason="disabled_by_env", + **_metadata_fields(metadata), + ) + _emit_timeline( + timeline, + "entire_call_recording_disabled", + session_id=session_id, + reason="disabled_by_env", + **_metadata_fields(metadata), + ) + return None + + if _oci_upload_config_from_env() is None: + log_flow_event( + active_logger, + "entire_call_recording_disabled", + session_id=session_id, + reason="missing_oci_config", + **_metadata_fields(metadata), + ) + _emit_timeline( + timeline, + "entire_call_recording_disabled", + session_id=session_id, + reason="missing_oci_config", + **_metadata_fields(metadata), + ) + return None + + recorder = EntireCallRecorder( + session_id=session_id, + sample_rate=sample_rate, + channels=channels, + sample_width=sample_width, + frame_ms=frame_ms, + bytes_per_frame=bytes_per_frame, + tmp_dir=_tmp_dir_from_env(), + logger_override=active_logger, + timeline=timeline, + metadata=metadata, + ) + try: + recorder.start() + except Exception: + log_flow_event( + active_logger, + "entire_call_recording_disabled", + session_id=session_id, + reason="start_failed", + **_metadata_fields(metadata), + ) + _emit_timeline( + timeline, + "entire_call_recording_disabled", + session_id=session_id, + reason="start_failed", + **_metadata_fields(metadata), + ) + active_logger.exception( + "[entire-call-recording][start_failed] session_id=%s", + session_id, + ) + return None + return recorder diff --git a/src/app/utils/interruption_tracker.py b/src/app/utils/interruption_tracker.py new file mode 100644 index 0000000..e637b29 --- /dev/null +++ b/src/app/utils/interruption_tracker.py @@ -0,0 +1,418 @@ +# app/utils/interruption_tracker.py +from __future__ import annotations + +import inspect +import logging +import re +import unicodedata +from dataclasses import dataclass +from typing import Any, Callable, Dict, Optional, Set +"""from agent.classifier.interruption_classifier import InterruptionClassifier + +interruption_classifier = InterruptionClassifier() + +def is_backchannel(text: str) -> bool: + result = interruption_classifier.run(text) + print(f"[is_backchannel] {text} -> {result}") + return result""" + +_BACKCHANNEL_SET = { + "ta", "tá", "ok", "okay", "certo", "beleza", "blz", + "tudo bem", "tudo bem?", "tudo bom", "tudo bom?", + "aham", "uhum", "hum", "hm", "uh-huh", + "entendi", "isso", "claro", +} + + +def _norm_pt(s: str) -> str: + s = (s or "").strip().lower() + s = unicodedata.normalize("NFKD", s) + s = "".join(ch for ch in s if not unicodedata.combining(ch)) + s = re.sub(r"\s+", " ", s) + s = re.sub(r"[^\w\s?]", "", s) # remove pontuação (mantém ? e -) + return s.strip() + + +def is_backchannel(text: str) -> bool: + t = _norm_pt(text) + print(f"[is_backchannel] {text} -> {t}") + if not t: + return True + if t in _BACKCHANNEL_SET: + return True + if len(t) <= 2: + return True + #if t.startswith("ta ") or t.startswith("tá "): + # return True + return False + +def remaining_after_prefix(full: str, spoken: str) -> str: + """ + Mais robusto: + - tenta prefixo exato + - tenta common-prefix (char a char) + - tenta find() + - fallback: retorna full (melhor do que ficar em silêncio) + """ + full = full or "" + spoken = spoken or "" + if not full: + return "" + + if not spoken: + return full + + if full.startswith(spoken): + return full[len(spoken):] + + # longest common prefix + m = 0 + for a, b in zip(full, spoken): + if a == b: + m += 1 + else: + break + if m > 0: + return full[m:] + + idx = full.find(spoken) + if idx >= 0: + return full[idx + len(spoken):] + + # fallback: repete o full (melhor do que silêncio) + return full + + +@dataclass +class InterruptionTrace: + stage: str + full_text: str + started_at: float + spoken_text: str = "" + ended_at: float = 0.0 + interrupted: bool = False + interrupter_text: str = "" + interrupter_transcript_raw: str = "" + + +class InterruptionTracker: + """ + Encapsula: + - detecção de backchannel + - rastreio de interrupção (full vs spoken) + - captura do que o usuário disse ao interromper + - auto-resume se for backchannel OU interrupção vazia + - payload "interrupt" para ser passado ao pipeline.run + - interrupt_now() para cortar o áudio IMEDIATAMENTE (sem esperar is_final) + """ + + def __init__( + self, + *, + allow_interruptions_for_stage: Callable[[str], bool], + logger: Optional[logging.Logger] = None, + auto_resume_stages: Optional[Set[str]] = None, + max_auto_resume: int = 2, + auto_resume_on_empty_interrupt: bool = True, + auto_resume_backchannel_any_stage: bool = True, + auto_resume_empty_any_stage: bool = True, + on_interrupt: Optional[Callable[[dict], None]] = None, + ) -> None: + self.allow_interruptions_for_stage = allow_interruptions_for_stage + self.log = logger or logging.getLogger(__name__) + self.on_interrupt = on_interrupt + + self.auto_resume_stages = {s.upper() for s in (auto_resume_stages or {"INTRO", "PRESENTATION"})} + self.max_auto_resume = max(0, int(max_auto_resume)) + self.auto_resume_on_empty_interrupt = bool(auto_resume_on_empty_interrupt) + + self.auto_resume_backchannel_any_stage = bool(auto_resume_backchannel_any_stage) + self.auto_resume_empty_any_stage = bool(auto_resume_empty_any_stage) + + self._loop = None + self._agent_speaking = None + self._session = None + + self.active_trace: Optional[InterruptionTrace] = None + self.last_interrupt_info: Optional[dict] = None + + # ✅ handle do say() atual (para cortar imediatamente) + self._current_handle = None + + # ✅ buffer caso note_user_interrupt chegue antes do active_trace + self._pending_interrupter_text: str = "" + self._pending_interrupter_raw: str = "" + + def is_backchannel_text(self, text: str) -> bool: + return is_backchannel(text) + + def attach(self, session: Any, agent_speaking_event: Any, loop: Any) -> None: + self._loop = loop + self._agent_speaking = agent_speaking_event + self._session = session + session.on("conversation_item_added")(self._on_conversation_item_added) + + def consume_last_interrupt_info(self) -> Optional[dict]: + info = self.last_interrupt_info + self.last_interrupt_info = None + return info + + def note_user_interrupt(self, user_text: str, raw_transcript: str) -> None: + """ + Registra o texto do usuário que interrompeu. + + ✅ Se ainda não existe active_trace (race com callbacks), + guarda num buffer e aplica assim que começar o próximo say_with_trace. + """ + if not user_text: + return + + if self.active_trace and not self.active_trace.interrupter_text: + self.active_trace.interrupter_text = user_text + self.active_trace.interrupter_transcript_raw = raw_transcript or "" + return + + # fallback: guarda para aplicar quando o trace começar + if not self._pending_interrupter_text: + self._pending_interrupter_text = user_text + self._pending_interrupter_raw = raw_transcript or "" + + def should_ignore_user_input(self, user_text: str, agent_is_speaking: bool) -> bool: + if not user_text: + return True + if agent_is_speaking and is_backchannel(user_text): + return True + return False + + def interrupt_now(self) -> bool: + """ + Tenta interromper a fala atual imediatamente. + Retorna True se conseguiu disparar algum método de interrupção. + """ + h = self._current_handle + + # 1) tenta no handle retornado por say() + if h is not None: + for meth in ("interrupt", "cancel", "stop"): + fn = getattr(h, meth, None) + if callable(fn): + try: + ret = fn() + if inspect.isawaitable(ret) and self._loop: + self._loop.create_task(ret) + return True + except Exception: + self.log.exception("interrupt_now falhou usando handle.%s()", meth) + return False + + # 2) fallback: tenta no session (se existir) + s = self._session + if s is not None: + for meth in ("interrupt", "cancel", "stop"): + fn = getattr(s, meth, None) + if callable(fn): + try: + ret = fn() + if inspect.isawaitable(ret) and self._loop: + self._loop.create_task(ret) + return True + except TypeError: + continue + except Exception: + self.log.exception("interrupt_now falhou usando session.%s()", meth) + return False + + return False + + def _on_conversation_item_added(self, ev: Any) -> None: + """ + Captura o item do assistant "commitado". + Quando há interrupção, o texto vem truncado e item.interrupted=True. + """ + if not self.active_trace: + return + + item = getattr(ev, "item", None) + if not item: + return + + role = getattr(item, "role", None) + role_s = str(role).lower() + if "assistant" not in role_s: + return + + try: + self.active_trace.spoken_text = getattr(item, "text_content", "") or "" + self.active_trace.interrupted = bool(getattr(item, "interrupted", False)) + except Exception: + pass + + def force_interrupt_on_end(self, *, reason: str = "call_end") -> Optional[dict]: + """ + Se a call cair enquanto o agente está falando, pode não chegar um item final/truncado. + Cria um payload de interrupção a partir do trace ativo e guarda em last_interrupt_info. + """ + trace = self.active_trace + if not trace: + return None + + # Marca como interrompido por encerramento + trace.interrupted = True + + spoken = trace.spoken_text or "" + full = trace.full_text or "" + remaining = remaining_after_prefix(full, spoken).strip() + + loop = self._loop + now_ms = int(loop.time() * 1000) if loop else 0 + + info = { + "id": f"it-{now_ms}", + "reason": reason, + "stage": trace.stage, + "assistant_full": full, + "assistant_spoken": spoken, + "assistant_remaining": remaining, + "cut_chars": len(spoken), + "ratio_spoken": (len(spoken) / max(1, len(full))) if full else 0.0, + "elapsed_s": 0.0, + "user_interrupt_text": trace.interrupter_text or "", + "user_interrupt_transcript_raw": trace.interrupter_transcript_raw or "", + } + + self.last_interrupt_info = info + + if self.on_interrupt: + try: + self.on_interrupt(info) + except Exception: + self.log.exception("on_interrupt falhou") + + return info + + async def _call_say(self, session: Any, text: str, allow: bool): + res = session.say(text, allow_interruptions=allow, add_to_chat_ctx=True) + if inspect.isawaitable(res): + return await res + return res + + async def say_with_trace(self, session: Any, stage: str, text_to_say: str) -> InterruptionTrace: + stage_u = (stage or "UNKNOWN").upper() + allow = bool(self.allow_interruptions_for_stage(stage_u)) + full = (text_to_say or "").strip() + + if not full: + loop = self._loop + t = loop.time() if loop else 0.0 + return InterruptionTrace(stage=stage_u, full_text="", started_at=t) + + attempt = 0 + last_trace: Optional[InterruptionTrace] = None + + while True: + loop = self._loop + start_t = loop.time() if loop else 0.0 + + trace = InterruptionTrace(stage=stage_u, full_text=full, started_at=start_t) + self.active_trace = trace + + # ✅ aplica interrupter pendente, caso tenha chegado antes do trace existir + if self._pending_interrupter_text and not trace.interrupter_text: + trace.interrupter_text = self._pending_interrupter_text + trace.interrupter_transcript_raw = self._pending_interrupter_raw + self._pending_interrupter_text = "" + self._pending_interrupter_raw = "" + + if self._agent_speaking: + self._agent_speaking.set() + + handle = None + try: + handle = await self._call_say(session, full, allow) + + # ✅ guarda handle atual para interrupt_now() + self._current_handle = handle + + # ✅ aguarda playout + join = getattr(handle, "join", None) + if callable(join): + fut = join() + if fut is not None: + await fut + else: + wait = getattr(handle, "wait_for_playout", None) + if callable(wait): + await wait() + + finally: + end_t = loop.time() if loop else 0.0 + trace.ended_at = end_t + + if self._agent_speaking: + self._agent_speaking.clear() + + self.active_trace = None + self._current_handle = None + + # interrupted flag + trace.interrupted = bool(getattr(handle, "interrupted", False)) if handle else False + + # fallback spoken_text + if trace.interrupted and not trace.spoken_text: + trace.spoken_text = "" + if (not trace.interrupted) and not trace.spoken_text: + trace.spoken_text = trace.full_text + + last_trace = trace + + if trace.interrupted: + remaining = remaining_after_prefix(trace.full_text, trace.spoken_text).strip() + now_ms = int(loop.time() * 1000) if loop else 0 + + info: Dict[str, Any] = { + "id": f"it-{now_ms}", + "reason": "barge_in", + "stage": trace.stage, + "assistant_full": trace.full_text, + "assistant_spoken": trace.spoken_text, + "assistant_remaining": remaining, + "cut_chars": len(trace.spoken_text or ""), + "ratio_spoken": (len(trace.spoken_text) / max(1, len(trace.full_text))) if trace.full_text else 0.0, + "elapsed_s": round(trace.ended_at - trace.started_at, 3), + "user_interrupt_text": trace.interrupter_text, + "user_interrupt_transcript_raw": trace.interrupter_transcript_raw, + } + + self.last_interrupt_info = info + + if self.on_interrupt: + try: + self.on_interrupt(info) + except Exception: + self.log.exception("on_interrupt falhou") + + empty_interrupt = (not (trace.interrupter_text or "").strip()) and self.auto_resume_on_empty_interrupt + back = is_backchannel(trace.interrupter_text) + + stage_ok = stage_u in self.auto_resume_stages + if back and self.auto_resume_backchannel_any_stage: + stage_ok = True + if empty_interrupt and self.auto_resume_empty_any_stage: + stage_ok = True + + can_resume = ( + stage_ok + and attempt < self.max_auto_resume + and (empty_interrupt or back) + ) + + if can_resume and remaining: + attempt += 1 + full = remaining + trace.interrupter_text = "" + trace.interrupter_transcript_raw = "" + continue + + break + + return last_trace or InterruptionTrace(stage=stage_u, full_text=full, started_at=(self._loop.time() if self._loop else 0.0)) diff --git a/src/app/utils/logging.py b/src/app/utils/logging.py new file mode 100644 index 0000000..71cf011 --- /dev/null +++ b/src/app/utils/logging.py @@ -0,0 +1,958 @@ +from __future__ import annotations + +import atexit +import copy +import json +import logging +import os +import queue +import re +import sys +import time +import uuid +from collections.abc import Iterable, Mapping +from contextvars import ContextVar +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from threading import Event, Lock, Thread +from typing import Any + +from app.utils.structured_otlp import publish_structured_span +from app.utils.structured_pubsub import publish_structured_event + +_LOGGER_CACHE: dict[str, logging.Logger] = {} +_LOGGER_LOCK = Lock() +_BASE_LOGGER_INITIALIZED = False +_LOG_SESSION_ID: ContextVar[str] = ContextVar("log_session_id", default="") + + +def set_log_session_id(session_id: str | None) -> None: + """Bind the call session to the current async context and its child tasks.""" + _LOG_SESSION_ID.set(str(session_id or "").strip()) + + +class _SessionConsoleFormatter(logging.Formatter): + def __init__(self, fmt: str, *, fixed_session_id: str = "") -> None: + super().__init__(fmt) + self._fixed_session_id = str(fixed_session_id or "").strip() + + def format(self, record: logging.LogRecord) -> str: + rendered = super().format(record) + session_id = str( + getattr(record, "session_id", "") + or self._fixed_session_id + or _LOG_SESSION_ID.get() + or "" + ).strip() + if not session_id or re.search( + r"(?i)(?:[\"']session_?id[\"']|\bsession_?id\b)\s*[:=]", + rendered, + ): + return rendered + return f"{rendered} | session_id={session_id}" + + +class _CallFileFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + match getattr(record, "structured_event", False): + case True: + return record.getMessage() + case _: + return super().format(record) + + +def _resolve_level(default: int) -> int: + raw = (os.getenv("LOG_LEVEL", "") or "").strip().upper() + match raw: + case "": + return default + case _: + value = logging.getLevelName(raw) + match value: + case int(): + return value + case _: + return default + + +EVENT_RECEBIMENTO_MSG = "recebimento msg" +EVENT_ENVIO_MSG = "envio msg" + +_FINALIZATION_MATCHERS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("falha_stt", ("stt",)), + ("falha_tts", ("tts",)), + ("falha_agente", ("agent_backend", "falha agente")), + ("falha_tia", ("agent_runtime", "bridge", "capacity")), + ("silencio_longo", ("no_user_response", "silencio", "sil\u00eancio")), + ("outro_assunto", ("outro_assunto", "other_subject")), + ("nao_resolvido", ("nao_resolvido", "n\u00e3o resolvido", "unresolved")), + ("resolvido", ("stage_done", "resolved", "resolvido", "done")), + ("hang_up", ("disconnect", "hang")), +) + +_ERROR_MATCHERS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("transferido", ("transferred", "transferido", "transferencia", "transfer\u00eancia")), + ("silencio_longo", ("stop_silencio_longo", "no_user_response", "silencio", "sil\u00eancio")), + ("capacidade_tia", ("capacity",)), + ("falha_tia", ("agent_runtime", "livekit")), + ("falha_stt", ("stt",)), + ("falha_tts", ("tts",)), + ("falha_comunicacao_agente", ("agent_backend", "remote_agent", "falha agente")), + ("falha_comunicacao_gstreamer", ("bridge", "gstreamer", "gst")), + ("capacidade_agente", ("agent_runtime",)), +) + + +@dataclass(frozen=True, slots=True) +class StructuredLogContext: + callid: str + session_id: str + num_telefone: str + cod_ani: str + nome_agente: str + message_id_seed: str = "" + + def as_metadata(self) -> dict[str, str]: + return { + "callid": self.callid, + "session_id": self.session_id, + "num_telefone": self.num_telefone, + "cod_ani": self.cod_ani, + "nome_agente": self.nome_agente, + "message_id_seed": self.message_id_seed, + } + + +def _pick_value(payload: Mapping[str, Any] | None, *keys: str) -> str: + match payload: + case Mapping(): + pass + case _: + return "" + + for key in keys: + value = payload.get(key) + if value not in (None, ""): + return str(value).strip() + + lowered = {str(key).lower(): value for key, value in payload.items()} + for key in keys: + value = lowered.get(str(key).lower()) + if value not in (None, ""): + return str(value).strip() + + return "" + + +def normalize_agent_log_name(value: Any) -> str: + raw = str(value or "").strip().lower() + match raw: + case "conta" | "contas": + return "conta" + case "ofert" | "oferta" | "ofertas": + return "ofert" + case "cobra" | "cobranca" | "cobran\u00e7a" | "cobrancas" | "cobran\u00e7as": + return "cobra" + case "": + return "unknown" + case _: + return raw + + +def build_callid( + *, + router_call_key_day: str = "", + router_call_key: str = "", + call_id_ged: str = "", + protocol: str = "", + room: str = "", +) -> str: + day = str(router_call_key_day or "").strip() + key = str(router_call_key or "").strip() + match (day, key): + case ("", ""): + return str(call_id_ged or protocol or room or "unknown").strip() or "unknown" + case _: + return f"{day}{key}" + + +def structured_context_from_start_data( + data: Mapping[str, Any] | None, + *, + session_id: str = "", +) -> StructuredLogContext: + router_day = _pick_value(data, "routerCallKeyDay", "RouterCallKeyDay", "router_call_key_day") + router_key = _pick_value(data, "routerCallKey", "RouterCallKey", "router_call_key") + call_id_ged = _pick_value(data, "callIdGed", "call_id_ged") + callid = build_callid( + router_call_key_day=router_day, + router_call_key=router_key, + call_id_ged=call_id_ged, + ) + return StructuredLogContext( + callid=callid, + session_id=( + str(session_id or "").strip() + or _pick_value(data, "session_id", "sessionId") + ), + num_telefone=_pick_value(data, "gsm", "GSM", "msisdn", "NUM_TELEFONE", "phone"), + cod_ani=_pick_value(data, "ani", "ANI", "cod_ani"), + nome_agente=normalize_agent_log_name(_pick_value(data, "agent", "agente", "nome_agente", "Nome_agente")), + message_id_seed=call_id_ged or f"MSG-{callid}", + ) + + +def structured_context_from_metadata(metadata: Mapping[str, Any] | None) -> StructuredLogContext: + match metadata: + case Mapping(): + structured_context = metadata.get("structured_log_context") + case _: + structured_context = None + + match structured_context: + case Mapping(): + return StructuredLogContext( + callid=_pick_value(structured_context, "callid") or "unknown", + session_id=_pick_value(structured_context, "session_id", "sessionId"), + num_telefone=_pick_value(structured_context, "num_telefone") or "unknown", + cod_ani=_pick_value(structured_context, "cod_ani") or "unknown", + nome_agente=normalize_agent_log_name(_pick_value(structured_context, "nome_agente", "Nome_agente")), + message_id_seed=_pick_value(structured_context, "message_id_seed"), + ) + case _: + pass + + match metadata: + case Mapping(): + session_data = metadata.get("session_data") + remote_agent = metadata.get("remote_agent") + case _: + session_data = {} + remote_agent = {} + + callid = build_callid( + router_call_key_day=_pick_value(remote_agent, "RouterCallKeyDay", "routerCallKeyDay"), + router_call_key=_pick_value(remote_agent, "RouterCallKey", "routerCallKey"), + call_id_ged=_pick_value(metadata, "call_id_ged", "callIdGed"), + protocol=_pick_value(metadata, "protocol"), + room=_pick_value(metadata, "timeline_id"), + ) + + return StructuredLogContext( + callid=callid, + session_id=( + _pick_value(metadata, "session_id", "sessionId") + or _pick_value(remote_agent, "session_id", "sessionId") + ), + num_telefone=( + _pick_value(session_data, "gsm", "GSM", "msisdn", "NUM_TELEFONE", "phone") + or _pick_value(remote_agent, "GSM", "gsm", "NUM_TELEFONE") + or "unknown" + ), + cod_ani=_pick_value(session_data, "ani", "ANI") or _pick_value(remote_agent, "ANI", "ani") or "unknown", + nome_agente=normalize_agent_log_name( + _pick_value(remote_agent, "agent", "agente") or _pick_value(session_data, "agent", "agente") + ), + message_id_seed=( + _pick_value(metadata, "call_id_ged", "callIdGed") + or _pick_value(remote_agent, "callIdGed", "call_id_ged") + or f"MSG-{callid}" + ), + ) + + +def format_event_timestamp(ns: int | None = None) -> str: + match ns: + case None: + ts_ns = time.time_ns() + case _: + ts_ns = int(ns) + seconds, remainder_ns = divmod(ts_ns, 1_000_000_000) + dt = datetime.fromtimestamp(seconds, tz=timezone.utc) + return f"{dt:%Y-%m-%dT%H:%M:%S}.{remainder_ns // 1_000_000:03d}Z" + + +def _format_optional_event_timestamp(ns: int | None) -> str | None: + match ns: + case None: + return None + case _: + return format_event_timestamp(ns) + + +def event_latency_ms(start_ns: int | None, end_ns: int | None = None) -> int | None: + match start_ns: + case None: + return None + case _: + started_ns = int(start_ns) + + match end_ns: + case None: + finished_ns = time.time_ns() + case _: + finished_ns = int(end_ns) + return max(0, round((finished_ns - started_ns) / 1_000_000)) + + +def _join_log_parts(*parts: str) -> str: + return " ".join(str(item or "").strip().lower() for item in parts if str(item or "").strip()) + + +def _match_first_token(text: str, matchers: tuple[tuple[str, tuple[str, ...]], ...]) -> str: + for key, tokens in matchers: + if any(token in text for token in tokens): + return key + return "" + + +def _structured_event_kind(tipo_evento: str = "") -> str: + normalized = str(tipo_evento or "").strip().lower().replace("_", " ") + match normalized: + case "recebimento" | "recebimento msg": + return "recebimento" + case "envio" | "envio msg": + return "envio" + case _: + return "" + + +def _event_kind_allows(kind: str, *allowed: str) -> bool: + return not kind or kind in allowed + + +def finalization_from_status( + *, + status: str = "", + reason: str = "", + resource: str = "", +) -> str | None: + normalized = _join_log_parts(status, reason, resource) + match normalized: + case "": + return None + case _: + pass + + match _match_first_token(normalized, _FINALIZATION_MATCHERS): + case "falha_stt": + return "Falha STT" + case "falha_tts": + return "Falha TTS" + case "falha_agente": + return "Falha Agente" + case "falha_tia": + return "Falha TIA" + case "silencio_longo": + return "Sil\u00eancio Longo" + case "outro_assunto": + return "Outro Assunto" + case "nao_resolvido": + return "N\u00e3o Resolvido" + case "resolvido": + return "Resolvido" + case "hang_up": + return "hang up" + case _: + return reason or status or None + + +def error_message_from_resource( + resource: str = "", + status: str = "", + reason: str = "", + *, + tipo_evento: str = "", +) -> str | None: + normalized = _join_log_parts(resource, status, reason) + match normalized: + case "": + return None + case _: + pass + + event_kind = _structured_event_kind(tipo_evento) + match _match_first_token(normalized, _ERROR_MATCHERS): + case "transferido" if _event_kind_allows(event_kind, "envio"): + return "Transferido" + case "silencio_longo" if _event_kind_allows(event_kind, "recebimento"): + return "Silencio Longo" + case "capacidade_tia": + return "Capacidade TIA" + case "falha_tia" if _event_kind_allows(event_kind, "recebimento", "envio"): + return "Falha TIA" + case "falha_stt" if _event_kind_allows(event_kind, "recebimento"): + return "Falha STT" + case "falha_tts" if _event_kind_allows(event_kind, "envio"): + return "Falha TTS" + case "falha_comunicacao_agente" if _event_kind_allows(event_kind, "envio"): + return "Falha comunicacao" + case "falha_comunicacao_gstreamer" if _event_kind_allows(event_kind, "recebimento"): + return "Falha comunicacao" + case "capacidade_agente": + return "Capacidade Agente" + case _: + return None + + +def _event_field(value: Any) -> Any: + return "" if value is None else value + + +def _event_flag(value: Any) -> int | None: + if value is None: + return None + if isinstance(value, str): + normalized = value.strip().lower() + if not normalized: + return None + if normalized in {"1", "true", "yes", "on"}: + return 1 + if normalized in {"0", "false", "no", "off"}: + return 0 + return 1 if bool(value) else 0 + + +def _env_flag(name: str, default: str = "1") -> bool: + return (os.getenv(name, default) or default).strip().lower() in {"1", "true", "yes", "on"} + + +def _flow_value(value: Any) -> str: + text = str(value) + text = " ".join(text.split()) + return text.replace("|", "/") + + +def _flow_preview(value: Any, *, max_chars: int | None = None) -> str: + if max_chars is None: + try: + max_chars = int(os.getenv("FLOW_LOG_PREVIEW_CHARS", "500")) + except ValueError: + max_chars = 500 + text = _flow_value(value) + if len(text) <= max_chars: + return text + return f"{text[:max_chars]}..." + + +def log_flow_event(logger: Any, step: str, **fields: Any) -> None: + if not _env_flag("FLOW_LOG_ENABLED", "1"): + return + + safe_fields: dict[str, str] = {} + for key, value in fields.items(): + if value is None: + continue + if isinstance(value, str) and not value.strip(): + continue + if key in {"text", "transcript", "reply", "payload", "result", "output", "content"}: + safe_fields[key] = _flow_preview(value) + else: + safe_fields[key] = _flow_value(value) + + parts = [f"step={_flow_value(step or 'unknown')}"] + parts.extend(f"{key}={value}" for key, value in safe_fields.items()) + logger.info("FLOW | %s", " | ".join(parts)) + + +def build_structured_event( + context: StructuredLogContext, + *, + tipo_evento: str, + message_id: str | None = None, + inicio_ns: int | None = None, + fim_ns: int | None = None, + latencia_total_ms: int | None = None, + latencia_tffb_ms: int | None = None, + duracao_audio_ms: int | None = None, + tts_max_gap_ms: int | None = None, + tts_max_underrun_0ms: int | None = None, + tts_underflow_count: int | None = None, + tts_avg_underflow_ms: int | None = None, + interrupcao: bool | int | str | None = None, + erro_msg: str | None = None, + erro_detalhe: str | None = None, + http_cod_status: int | str | None = None, + http_cod_desc: str | None = None, + finalizacao: str | None = None, +) -> dict[str, Any]: + return { + "tipo_evento": str(tipo_evento or ""), + "message_id": str(message_id or uuid.uuid4()), + "dat_hora_inicio": format_event_timestamp(inicio_ns), + "dat_hora_fim": _event_field(_format_optional_event_timestamp(fim_ns)), + "callid": str(context.callid or ""), + "session_id": str(context.session_id or ""), + "num_telefone": str(context.num_telefone or ""), + "cod_ani": str(context.cod_ani or ""), + "latencia_total_STT_TTS": _event_field(latencia_total_ms), + "latencia_TFFB_STT_TTS": _event_field(latencia_tffb_ms), + "duracao_audio": _event_field(duracao_audio_ms), + "tts_max_gap_ms": _event_field(tts_max_gap_ms), + "tts_max_underrun_0ms": _event_field(tts_max_underrun_0ms), + "tts_underflow_count": _event_field(tts_underflow_count), + "tts_avg_underflow_ms": _event_field(tts_avg_underflow_ms), + "interrupcao": _event_field(_event_flag(interrupcao)), + "erro_msg": _event_field(erro_msg), + "erro_detalhe": _event_field(erro_detalhe), + "http_cod_status": _event_field(http_cod_status), + "http_cod_desc": _event_field(http_cod_desc), + "finalizacao": _event_field(finalizacao), + "nome_agente": str(context.nome_agente or ""), + } + + +def log_structured_event( + logger: Any, + context: StructuredLogContext | None, + *, + tipo_evento: str, + message_id: str | None = None, + inicio_ns: int | None = None, + fim_ns: int | None = None, + latencia_total_ms: int | None = None, + latencia_tffb_ms: int | None = None, + duracao_audio_ms: int | None = None, + tts_max_gap_ms: int | None = None, + tts_max_underrun_0ms: int | None = None, + tts_underflow_count: int | None = None, + tts_avg_underflow_ms: int | None = None, + interrupcao: bool | int | str | None = None, + erro_msg: str | None = None, + erro_detalhe: str | None = None, + http_cod_status: int | str | None = None, + http_cod_desc: str | None = None, + finalizacao: str | None = None, +) -> dict[str, Any] | None: + match (os.getenv("STRUCTURED_EVENT_LOG_ENABLED", "1"), context): + case ("1", StructuredLogContext()): + pass + case _: + return None + + event = build_structured_event( + context, + tipo_evento=tipo_evento, + message_id=message_id, + inicio_ns=inicio_ns, + fim_ns=fim_ns, + latencia_total_ms=latencia_total_ms, + latencia_tffb_ms=latencia_tffb_ms, + duracao_audio_ms=duracao_audio_ms, + tts_max_gap_ms=tts_max_gap_ms, + tts_max_underrun_0ms=tts_max_underrun_0ms, + tts_underflow_count=tts_underflow_count, + tts_avg_underflow_ms=tts_avg_underflow_ms, + interrupcao=interrupcao, + erro_msg=erro_msg, + erro_detalhe=erro_detalhe, + http_cod_status=http_cod_status, + http_cod_desc=http_cod_desc, + finalizacao=finalizacao, + ) + logger.info(json.dumps(event, ensure_ascii=False), extra={"structured_event": True}) + publish_structured_event(event) + publish_structured_span(event) + return event + + +def _sanitize_for_filename(value: str) -> str: + value = (value or "").strip() + match value: + case "": + return "unknown" + case _: + pass + value = re.sub(r"\D+", "", value) + return value or "unknown" + + +def _bounded_env_int(name: str, default: int, *, minimum: int, maximum: int) -> int: + try: + value = int(os.getenv(name, str(default)) or str(default)) + except (TypeError, ValueError): + value = default + return max(minimum, min(maximum, value)) + + +class _LogWriterCommand: + __slots__ = ("kind", "event") + + def __init__(self, kind: str) -> None: + self.kind = kind + self.event = Event() + + +class _LogWriter: + """Executa handlers de arquivo numa thread dedicada e observável.""" + + def __init__( + self, + max_queue: int, + *, + warning_interval_s: float = 60.0, + ) -> None: + self._queue: queue.Queue[ + tuple[logging.Handler, logging.LogRecord] | _LogWriterCommand + ] = queue.Queue(maxsize=max(1, max_queue)) + self._thread: Thread | None = None + self._lifecycle_lock = Lock() + self._stats_lock = Lock() + self._closed = False + self._dropped = 0 + self._write_errors = 0 + self._last_drop_warning_at = 0.0 + self._last_error_warning_at = 0.0 + self._warning_interval_s = max(1.0, float(warning_interval_s)) + self._logger = logging.getLogger(f"{__name__}.writer") + + @property + def dropped(self) -> int: + with self._stats_lock: + return self._dropped + + @property + def write_errors(self) -> int: + with self._stats_lock: + return self._write_errors + + def start(self) -> bool: + with self._lifecycle_lock: + if self._closed: + return False + if self._thread is not None and self._thread.is_alive(): + return True + self._thread = Thread( + target=self._run, + name="call-log-writer", + daemon=True, + ) + self._thread.start() + return True + + def submit(self, target: logging.Handler, record: logging.LogRecord) -> bool: + if not self.start(): + self._record_drop(reason="writer_closed") + return False + reason = "" + with self._lifecycle_lock: + if self._closed: + reason = "writer_closed" + else: + try: + self._queue.put_nowait((target, record)) + return True + except queue.Full: + reason = "queue_full" + self._record_drop(reason=reason) + return False + + def flush(self, timeout: float | None = 2.0) -> bool: + with self._lifecycle_lock: + if self._closed: + return self._queue.empty() + if not self.start(): + return False + command = _LogWriterCommand("flush") + deadline = None if timeout is None else time.monotonic() + max(0.0, timeout) + with self._lifecycle_lock: + if self._closed: + return self._queue.empty() + if not self._enqueue_command(command, deadline): + return False + remaining = None if deadline is None else max(0.0, deadline - time.monotonic()) + return command.event.wait(remaining) + + def shutdown(self, timeout: float | None = 2.0) -> bool: + with self._lifecycle_lock: + if self._closed: + thread = self._thread + return thread is None or not thread.is_alive() + self._closed = True + thread = self._thread + + if thread is None: + return True + + command = _LogWriterCommand("shutdown") + deadline = None if timeout is None else time.monotonic() + max(0.0, timeout) + if not self._enqueue_command(command, deadline): + return False + remaining = None if deadline is None else max(0.0, deadline - time.monotonic()) + if not command.event.wait(remaining): + return False + remaining = None if deadline is None else max(0.0, deadline - time.monotonic()) + thread.join(remaining) + return not thread.is_alive() + + def _enqueue_command( + self, + command: _LogWriterCommand, + deadline: float | None, + ) -> bool: + try: + if deadline is None: + self._queue.put(command) + else: + self._queue.put(command, timeout=max(0.0, deadline - time.monotonic())) + return True + except queue.Full: + return False + + def _run(self) -> None: + while True: + item = self._queue.get() + if isinstance(item, _LogWriterCommand): + item.event.set() + if item.kind == "shutdown": + return + continue + + target, record = item + try: + target.handle(record) + except Exception as exc: + self._record_write_error(target=target, exc=exc) + + def _record_drop(self, *, reason: str) -> None: + now = time.monotonic() + with self._stats_lock: + self._dropped += 1 + dropped = self._dropped + should_warn = now - self._last_drop_warning_at >= self._warning_interval_s + if should_warn: + self._last_drop_warning_at = now + if should_warn: + self._logger.warning( + "CALL_LOG_ASYNC_DROP | reason=%s | dropped_total=%s | queue_size=%s", + reason, + dropped, + self._queue.qsize(), + ) + + def _record_write_error( + self, + *, + target: logging.Handler, + exc: Exception, + ) -> None: + now = time.monotonic() + with self._stats_lock: + self._write_errors += 1 + errors = self._write_errors + should_warn = now - self._last_error_warning_at >= self._warning_interval_s + if should_warn: + self._last_error_warning_at = now + if should_warn: + self._logger.warning( + "CALL_LOG_ASYNC_WRITE_FAIL | handler=%s | error=%s: %s | errors_total=%s", + type(target).__name__, + type(exc).__name__, + exc, + errors, + ) + + +_LOG_WRITER = _LogWriter( + _bounded_env_int( + "CALL_LOG_QUEUE_MAX", + 20_000, + minimum=1, + maximum=1_000_000, + ), + warning_interval_s=_bounded_env_int( + "ASYNC_IO_WARNING_INTERVAL_S", + 60, + minimum=1, + maximum=3_600, + ), +) +atexit.register(_LOG_WRITER.shutdown) + + +class _AsyncFileHandler(logging.Handler): + """Enfileira uma cópia estável do registro para escrita em background.""" + + def __init__(self, target: logging.Handler) -> None: + super().__init__(level=target.level) + self._target = target + _LOG_WRITER.start() + + def emit(self, record: logging.LogRecord) -> None: + clone = copy.copy(record) + try: + clone.msg = clone.getMessage() + clone.args = None + except Exception: + pass + _LOG_WRITER.submit(self._target, clone) + + def flush(self) -> None: + writer = globals().get("_LOG_WRITER") + if writer is not None: + writer.flush(timeout=2.0) + + def close(self) -> None: + try: + self.flush() + finally: + try: + self._target.close() + finally: + super().close() + + +def _build_call_file_handler(file_path: Path, level: int) -> logging.FileHandler: + handler = logging.FileHandler(file_path, encoding="utf-8") + handler.setLevel(level) + handler.setFormatter( + _CallFileFormatter( + "%(asctime)s | %(levelname)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + return handler + + +def setup_minimal_logging(*, level: int = logging.INFO) -> logging.Logger: + """ + - Root: WARNING + - Logger base: console simples + - Não cria arquivo por processo + - Arquivo apenas por chamada + """ + global _BASE_LOGGER_INITIALIZED + + level = _resolve_level(level) + logger_name = os.getenv("APP_LOGGER_NAME", "agent_internal_stt") + + match _BASE_LOGGER_INITIALIZED: + case True: + log = logging.getLogger(logger_name) + log.setLevel(level) + return log + case _: + pass + + root = logging.getLogger() + root.setLevel(logging.WARNING) + + for h in list(root.handlers): + root.removeHandler(h) + try: + h.close() + except Exception: + pass + + root_handler = logging.StreamHandler(sys.stdout) + root_handler.setLevel(logging.WARNING) + root_handler.setFormatter( + _SessionConsoleFormatter("%(levelname)s %(name)s: %(message)s") + ) + root.addHandler(root_handler) + + noisy: Iterable[str] = ( + "livekit", + "livekit.agents", + "livekit.plugins", + "livekit.rtc", + "aioice", + "aiortc", + "httpx", + "httpcore", + "asyncio", + "stt_internal", + "transformers", + "uvicorn.access", + ) + for name in noisy: + logging.getLogger(name).setLevel(logging.WARNING) + + log = logging.getLogger(logger_name) + log.setLevel(level) + log.propagate = False + + for h in list(log.handlers): + log.removeHandler(h) + try: + h.close() + except Exception: + pass + + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(level) + console_handler.setFormatter(_SessionConsoleFormatter("%(message)s")) + log.addHandler(console_handler) + + _BASE_LOGGER_INITIALIZED = True + return log + + +def get_call_logger( + *, + phone_number: str | None = None, + session_id: str | None = None, + level: int = logging.INFO, +) -> logging.Logger: + """ + Logger dedicado por ligação. + Nome do arquivo: telefone_timestamp.txt + Ex: 68981100139_20260306_142317.txt + """ + log_dir = Path(os.getenv("LOG_DIR", "./logs")) + logger_name = os.getenv("APP_LOGGER_NAME", "agent_internal_stt") + log_to_file = os.getenv("LOG_TO_FILE", "1") == "1" + level = _resolve_level(level) + + safe_phone = _sanitize_for_filename(phone_number or "") + safe_session = _sanitize_for_filename(session_id or "") + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + cache_key = f"{logger_name}.call.{safe_phone}.{safe_session or timestamp}" + + with _LOGGER_LOCK: + cached = _LOGGER_CACHE.get(cache_key) + match cached: + case logging.Logger(): + return cached + case _: + pass + + log = logging.getLogger(cache_key) + log.setLevel(level) + log.propagate = False + + for h in list(log.handlers): + log.removeHandler(h) + try: + h.close() + except Exception: + pass + + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setLevel(level) + console_handler.setFormatter( + _SessionConsoleFormatter("%(message)s", fixed_session_id=safe_session) + ) + log.addHandler(console_handler) + + match log_to_file: + case True: + log_dir.mkdir(parents=True, exist_ok=True) + + file_name = f"{safe_phone}_{timestamp}.txt" + file_path = log_dir / file_name + + log.addHandler(_AsyncFileHandler(_build_call_file_handler(file_path, level))) + log.info( + "CALL_FILE_LOG_ENABLED | phone=%s | session_id=%s | path=%s", + safe_phone, + safe_session or "-", + file_path, + ) + case _: + pass + + _LOGGER_CACHE[cache_key] = log + return log diff --git a/src/app/utils/pcm.py b/src/app/utils/pcm.py new file mode 100644 index 0000000..62e602c --- /dev/null +++ b/src/app/utils/pcm.py @@ -0,0 +1,42 @@ +import math +import audioop +from array import array + +def pcm_duration_ms(pcm: bytes, sample_rate: int, channels: int) -> float: + # PCM16LE => 2 bytes por sample por canal + if not pcm: + return 0.0 + samples_total = len(pcm) // 2 # total de int16 (inclui canais) + samples_per_channel = samples_total / max(1, channels) + return (samples_per_channel / sample_rate) * 1000.0 + +def dbfs_pcm16le(pcm: bytes) -> float: + """ + dBFS aproximado do PCM16LE. + 0 dBFS = full scale, silêncio tende a -inf (aqui clamp em -120). + """ + if not pcm: + return -120.0 + rms = audioop.rms(pcm, 2) # width=2 bytes (int16) + if rms <= 0: + return -120.0 + return 20.0 * math.log10(rms / 32768.0) + +def pcm16_dbfs(frame_bytes: bytes) -> float: + # frame_bytes: PCM16LE mono + if not frame_bytes: + return -120.0 + samples = array("h") + samples.frombytes(frame_bytes) + if len(samples) == 0: + return -120.0 + s2 = 0.0 + for x in samples: + s2 += float(x) * float(x) + rms = math.sqrt(s2 / len(samples)) if s2 > 0 else 0.0 + if rms <= 1e-9: + return -120.0 + return 20.0 * math.log10(rms / 32768.0) + +def is_silence(frame_bytes: bytes, *, threshold_dbfs: float = -50.0) -> bool: + return pcm16_dbfs(frame_bytes) < threshold_dbfs \ No newline at end of file diff --git a/src/app/utils/structured_otlp.py b/src/app/utils/structured_otlp.py new file mode 100644 index 0000000..761266a --- /dev/null +++ b/src/app/utils/structured_otlp.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import atexit +import json +import os +import re +import sys +import time +from collections.abc import Mapping +from contextvars import ContextVar +from datetime import datetime, timezone +from threading import Lock, Thread +from typing import Any + +from opentelemetry.context import Context +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.resources import SERVICE_NAME, Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.sdk.trace.id_generator import IdGenerator, RandomIdGenerator +from opentelemetry.trace import Status, StatusCode + +_TRACER_PROVIDER: Any | None = None +_TRACER: Any | None = None +_TRACER_ENDPOINT = "" +_TRACER_LOCK = Lock() +_CURRENT_TRACE_ID: ContextVar[int | None] = ContextVar("structured_otlp_trace_id", default=None) + +_HEX_RE = re.compile(r"[^0-9a-fA-F]+") +_ISO_EVENT_TIMESTAMP_RE = re.compile( + r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z$" +) +_LEGACY_EVENT_TIMESTAMP_RE = re.compile(r"^(\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}),(\d{1,9})$") +_EXCLUDED_ATTRIBUTE_KEYS = {"original_message_id"} +_OMIT_EMPTY_ATTRIBUTE_KEYS = {"latencia_TFFB_STT_TTS", "duracao_audio", "interrupcao"} + + +class _SessionTraceIdGenerator(IdGenerator): + def __init__(self) -> None: + self._fallback = RandomIdGenerator() + + def generate_span_id(self) -> int: + return self._fallback.generate_span_id() + + def generate_trace_id(self) -> int: + trace_id = _CURRENT_TRACE_ID.get() + if trace_id is not None: + return trace_id + return self._fallback.generate_trace_id() + + +_ID_GENERATOR = _SessionTraceIdGenerator() + + +def trace_id_from_session_id(session_id: Any) -> str: + trace_id = _HEX_RE.sub("", str(session_id or "")).lower() + if len(trace_id) != 32 or trace_id == ("0" * 32): + return "" + return trace_id + + +def _configured_endpoint() -> str: + return (os.getenv("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", "") or "").strip() + + +def _service_name() -> str: + return ( + os.getenv("OTEL_SERVICE_NAME", "") + or os.getenv("APP_LOGGER_NAME", "") + or "agent_internal_stt" + ).strip() + + +def shutdown_structured_otlp() -> None: + """Drena e encerra o provider corrente no shutdown do processo.""" + global _TRACER_PROVIDER, _TRACER, _TRACER_ENDPOINT + + with _TRACER_LOCK: + provider = _TRACER_PROVIDER + _TRACER_PROVIDER = None + _TRACER = None + _TRACER_ENDPOINT = "" + + if provider is not None: + try: + provider.shutdown() + except Exception as exc: + print( + f"STRUCTURED_EVENT_OTLP_SHUTDOWN_FAIL | error={type(exc).__name__}: {exc}", + file=sys.stderr, + ) + + +def force_flush_structured_otlp(timeout_millis: int = 5_000) -> bool: + """Força o envio pendente fora do caminho de áudio, útil em teardown/testes.""" + with _TRACER_LOCK: + provider = _TRACER_PROVIDER + if provider is None: + return True + try: + return bool(provider.force_flush(timeout_millis=max(0, int(timeout_millis)))) + except Exception as exc: + print( + f"STRUCTURED_EVENT_OTLP_FLUSH_FAIL | error={type(exc).__name__}: {exc}", + file=sys.stderr, + ) + return False + + +def _shutdown_replaced_provider(provider: Any) -> None: + try: + provider.shutdown() + except Exception: + pass + + +def _tracer() -> Any | None: + global _TRACER_PROVIDER, _TRACER, _TRACER_ENDPOINT + + endpoint = _configured_endpoint() + if not endpoint: + return None + + with _TRACER_LOCK: + if _TRACER is not None and _TRACER_ENDPOINT == endpoint: + return _TRACER + + try: + resource = Resource.create(attributes={SERVICE_NAME: _service_name()}) + provider = TracerProvider(resource=resource, id_generator=_ID_GENERATOR) + provider.add_span_processor( + BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint)) + ) + + previous_provider = _TRACER_PROVIDER + _TRACER_PROVIDER = provider + _TRACER = provider.get_tracer("app.utils.structured_otlp") + _TRACER_ENDPOINT = endpoint + tracer = _TRACER + except Exception as exc: + print( + f"STRUCTURED_EVENT_OTLP_DISABLED | error={type(exc).__name__}: {exc}", + file=sys.stderr, + ) + return None + + if previous_provider is not None: + Thread( + target=_shutdown_replaced_provider, + args=(previous_provider,), + name="structured-otlp-old-provider-shutdown", + daemon=True, + ).start() + return tracer + + +atexit.register(shutdown_structured_otlp) + + +def _parse_event_timestamp(value: Any) -> int | None: + raw = str(value or "").strip() + iso_match = _ISO_EVENT_TIMESTAMP_RE.match(raw) + if iso_match is not None: + dt = datetime.strptime(iso_match.group(1), "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc) + fractional_ns = int((iso_match.group(2) or "").ljust(9, "0")[:9]) + return int(dt.timestamp()) * 1_000_000_000 + fractional_ns + + legacy_match = _LEGACY_EVENT_TIMESTAMP_RE.match(raw) + if legacy_match is None: + return None + + dt = datetime.strptime(legacy_match.group(1), "%d/%m/%Y %H:%M:%S") + fractional_ns = int(legacy_match.group(2).ljust(9, "0")[:9]) + return int(dt.timestamp()) * 1_000_000_000 + fractional_ns + + +def _event_times(event: Mapping[str, Any]) -> tuple[int, int]: + start_ns = _parse_event_timestamp(event.get("dat_hora_inicio")) or time.time_ns() + end_ns = _parse_event_timestamp(event.get("dat_hora_fim")) or start_ns + if end_ns < start_ns: + return start_ns, start_ns + return start_ns, end_ns + + +def _attribute_value(value: Any) -> str | bool | int | float | None: + if value is None: + return None + if isinstance(value, str | bool | int | float): + return value + try: + return json.dumps(value, ensure_ascii=False) + except Exception: + return str(value) + + +def _attributes(event: Mapping[str, Any]) -> dict[str, str | bool | int | float]: + attrs: dict[str, str | bool | int | float] = {} + for key, value in event.items(): + if str(key) in _EXCLUDED_ATTRIBUTE_KEYS: + continue + if str(key) in _OMIT_EMPTY_ATTRIBUTE_KEYS and value == "": + continue + attr_value = _attribute_value(value) + if attr_value is not None: + attrs[str(key)] = attr_value + return attrs + + +def publish_structured_span(event: Mapping[str, Any]) -> None: + trace_id = trace_id_from_session_id(event.get("session_id")) + if not trace_id: + return + + tracer = _tracer() + if tracer is None: + return + + try: + start_ns, end_ns = _event_times(event) + tipo_evento = str(event.get("tipo_evento") or "unknown").strip() or "unknown" + + token = _CURRENT_TRACE_ID.set(int(trace_id, 16)) + try: + span = tracer.start_span( + f"structured_log.{tipo_evento}", + context=Context(), + attributes=_attributes(event), + start_time=start_ns, + ) + finally: + _CURRENT_TRACE_ID.reset(token) + + try: + erro_msg = str(event.get("erro_msg") or "").strip() + if erro_msg: + span.set_status(Status(StatusCode.ERROR, erro_msg)) + finally: + span.end(end_time=end_ns) + except Exception as exc: + print(f"STRUCTURED_EVENT_OTLP_PUBLISH_FAIL | error={type(exc).__name__}: {exc}", file=sys.stderr) diff --git a/src/app/utils/structured_pubsub.py b/src/app/utils/structured_pubsub.py new file mode 100644 index 0000000..8f3c771 --- /dev/null +++ b/src/app/utils/structured_pubsub.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import json +import os +import re +import sys +from collections.abc import Mapping +from datetime import datetime, timedelta, timezone +from typing import Any + +_PUBLISHER: Any | None = None +_TOPIC_PATH = "" +_BRT = timezone(timedelta(hours=-3)) +_ISO_EVENT_TIMESTAMP_RE = re.compile( + r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?Z$" +) +_PUBSUB_EVENT_TIMESTAMP_RE = re.compile(r"^(\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2}),(\d{1,9})$") +_EXCLUDED_PAYLOAD_KEYS = {"original_message_id"} + + +def _config() -> tuple[str, str] | None: + project_id = (os.getenv("GCP_PROJECT_ID", "") or "").strip() + topic = (os.getenv("AGENT_PUBSUB_TOPIC", "") or "").strip() + if not project_id or not topic: + return None + return project_id, topic + + +def _topic_path(publisher: Any, project_id: str, topic: str) -> str: + if topic.startswith("projects/") and "/topics/" in topic: + return topic + return str(publisher.topic_path(project_id, topic)) + + +def _publisher() -> tuple[Any, str] | None: + global _PUBLISHER, _TOPIC_PATH + + config = _config() + if config is None: + return None + + if _PUBLISHER is not None and _TOPIC_PATH: + return _PUBLISHER, _TOPIC_PATH + + project_id, topic = config + try: + from google.cloud import pubsub_v1 + + _PUBLISHER = pubsub_v1.PublisherClient() + _TOPIC_PATH = _topic_path(_PUBLISHER, project_id, topic) + return _PUBLISHER, _TOPIC_PATH + except Exception as exc: + print(f"STRUCTURED_EVENT_PUBSUB_DISABLED | error={type(exc).__name__}: {exc}", file=sys.stderr) + return None + + +def _log_publish_error(future: Any) -> None: + try: + future.result() + except Exception as exc: + print(f"STRUCTURED_EVENT_PUBSUB_PUBLISH_FAIL | error={type(exc).__name__}: {exc}", file=sys.stderr) + + +def _format_pubsub_timestamp(value: Any) -> str: + raw = str(value or "").strip() + if not raw: + return "" + + pubsub_match = _PUBSUB_EVENT_TIMESTAMP_RE.match(raw) + if pubsub_match is not None: + return f"{pubsub_match.group(1)},{pubsub_match.group(2).ljust(9, '0')[:9]}" + + iso_match = _ISO_EVENT_TIMESTAMP_RE.match(raw) + if iso_match is None: + return raw + + dt = datetime.strptime(iso_match.group(1), "%Y-%m-%dT%H:%M:%S") + local_dt = dt.replace(tzinfo=timezone.utc).astimezone(_BRT) + fractional_ns = (iso_match.group(2) or "").ljust(9, "0")[:9] + return f"{local_dt:%d/%m/%Y %H:%M:%S},{fractional_ns}" + + +def _pubsub_payload(event: Mapping[str, Any]) -> dict[str, Any]: + payload = {key: value for key, value in event.items() if str(key) not in _EXCLUDED_PAYLOAD_KEYS} + + if "session_id" in payload: + if payload.get("sessionId") in (None, ""): + payload["sessionId"] = payload.get("session_id") + del payload["session_id"] + + if "dat_hora_fim" in payload: + if payload.get("dat_hora_termino") in (None, ""): + payload["dat_hora_termino"] = payload.get("dat_hora_fim") + del payload["dat_hora_fim"] + + if "dat_hora_inicio" in payload: + payload["dat_hora_inicio"] = _format_pubsub_timestamp(payload.get("dat_hora_inicio")) + if "dat_hora_termino" in payload: + payload["dat_hora_termino"] = _format_pubsub_timestamp(payload.get("dat_hora_termino")) + + return payload + + +def publish_structured_event(event: Mapping[str, Any]) -> None: + publisher_config = _publisher() + if publisher_config is None: + return + + publisher, topic_path = publisher_config + try: + payload = _pubsub_payload(event) + payload_json = json.dumps(payload, ensure_ascii=False).encode("utf-8") + future = publisher.publish( + topic_path, + payload_json, + tipo_evento=str(payload.get("tipo_evento") or ""), + dat_hora_inicio=str(payload.get("dat_hora_inicio") or ""), + dat_hora_termino=str(payload.get("dat_hora_termino") or ""), + sessionId=str(payload.get("sessionId") or ""), + callid=str(payload.get("callid") or ""), + nome_agente=str(payload.get("nome_agente") or ""), + cod_ani=str(payload.get("cod_ani") or ""), + http_cod_status=str(payload.get("http_cod_status") or ""), + http_cod_desc=str(payload.get("http_cod_desc") or ""), + ) + future.add_done_callback(_log_publish_error) + except Exception as exc: + print(f"STRUCTURED_EVENT_PUBSUB_PUBLISH_FAIL | error={type(exc).__name__}: {exc}", file=sys.stderr) diff --git a/src/app/utils/stt_audio_upload.py b/src/app/utils/stt_audio_upload.py new file mode 100644 index 0000000..7c66671 --- /dev/null +++ b/src/app/utils/stt_audio_upload.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +import asyncio +import hashlib +import logging +import os +import re +import threading +import time +import weakref +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Mapping + +from app.utils.logging import log_flow_event + +logger = logging.getLogger(__name__) + +_QUEUE_SIZE = 64 +_MAX_WAV_BYTES = 5 * 1024 * 1024 +_MAX_OBJECT_NAME_BYTES = 512 +_MAX_PART_CHARS = 64 +_SAFE_PART_RE = re.compile(r"[^A-Za-z0-9_.-]+") +_OCI_AUTH_MODE_LOCAL = "local" +_OCI_AUTH_MODE_OKE_WORKLOAD_IDENTITY = "oke_workload_identity" + + +@dataclass(frozen=True, slots=True) +class STTAudioUploadItem: + object_name: str + wav_bytes: bytes + req_id: str + message_id: str + session_id: str + metadata: Mapping[str, Any] + logger: Any | None = None + timeline: Any | None = None + + +@dataclass(frozen=True, slots=True) +class OCIUploadConfig: + auth_mode: str + region: str + bucket: str + namespace: str + user: str = "" + key_content: str = "" + fingerprint: str = "" + tenancy: str = "" + + +def _safe_path_part(value: Any, *, fallback: str) -> str: + raw = str(value or "").strip() + if not raw: + raw = fallback + safe = _SAFE_PART_RE.sub("_", raw).strip("._-") or fallback + if len(safe) <= _MAX_PART_CHARS: + return safe + + digest = hashlib.sha1(safe.encode("utf-8")).hexdigest()[:12] + prefix_len = max(1, _MAX_PART_CHARS - len(digest) - 1) + return f"{safe[:prefix_len].rstrip('._-')}-{digest}" + + +def _object_name_within_limit(object_name: str) -> str: + if len(object_name.encode("utf-8")) <= _MAX_OBJECT_NAME_BYTES: + return object_name + + digest = hashlib.sha1(object_name.encode("utf-8")).hexdigest() + today = datetime.now().strftime("%Y-%m-%d") + return f"{today}/oversize/{digest}.wav" + + +def build_stt_vad_object_name( + *, + session_id: Any, + message_id: Any, + req_id: Any, + now: datetime | None = None, +) -> str: + dt = now or datetime.now() + session_part = _safe_path_part(session_id, fallback="unknown-session") + message_part = _safe_path_part(message_id, fallback="unknown-message") + object_name = ( + f"{dt:%Y-%m-%d}/{session_part}/{message_part}.wav" + ) + return _object_name_within_limit(object_name) + + +def _metadata_value(source: Any, *keys: str) -> str: + if isinstance(source, Mapping): + lowered = {str(key).lower(): value for key, value in source.items()} + for key in keys: + value = source.get(key) + if value not in (None, ""): + return str(value).strip() + value = lowered.get(str(key).lower()) + if value not in (None, ""): + return str(value).strip() + return "" + + for key in keys: + value = getattr(source, key, None) + if value not in (None, ""): + return str(value).strip() + return "" + + +def _oci_upload_config_from_env() -> OCIUploadConfig | None: + auth_mode = ( + _OCI_AUTH_MODE_LOCAL + if os.getenv("OCI_AUTH_MODE", "").strip().lower() == _OCI_AUTH_MODE_LOCAL + else _OCI_AUTH_MODE_OKE_WORKLOAD_IDENTITY + ) + shared_values = { + "region": os.getenv("BUCKET_REGION", "").strip(), + "bucket": os.getenv("BUCKET_NAME", "").strip(), + "namespace": os.getenv("BUCKET_NAMESPACE", "").strip(), + } + if not all(shared_values.values()): + return None + + if auth_mode != _OCI_AUTH_MODE_LOCAL: + return OCIUploadConfig(auth_mode=auth_mode, **shared_values) + + local_values = { + "user": os.getenv("BUCKET_USER_ID", "").strip(), + "key_content": os.getenv("BUCKET_PRIVATE_KEY", "").replace("\\n", "\n").strip(), + "fingerprint": os.getenv("BUCKET_FINGERPRINT", "").strip(), + "tenancy": os.getenv("BUCKET_TENANCY_ID", "").strip(), + } + if not all(local_values.values()): + return None + return OCIUploadConfig(auth_mode=auth_mode, **shared_values, **local_values) + + +_OCI_CLIENT: Any | None = None +_OCI_CLIENT_CONFIG: OCIUploadConfig | None = None +_OCI_CLIENT_LOCK = threading.Lock() +# ObjectStorageClient owns a requests Session and must not be used concurrently +# by the STT and entire-call upload workers. +_OCI_UPLOAD_LOCK = threading.Lock() + + +def _oci_client(config: OCIUploadConfig) -> Any: + global _OCI_CLIENT, _OCI_CLIENT_CONFIG + + with _OCI_CLIENT_LOCK: + if _OCI_CLIENT is not None and _OCI_CLIENT_CONFIG == config: + return _OCI_CLIENT + + from oci.object_storage import ObjectStorageClient + + if config.auth_mode == _OCI_AUTH_MODE_LOCAL: + client_config = { + "user": config.user, + "key_content": config.key_content, + "fingerprint": config.fingerprint, + "tenancy": config.tenancy, + "region": config.region, + } + _OCI_CLIENT = ObjectStorageClient(config=client_config) + else: + from oci.auth.signers import get_oke_workload_identity_resource_principal_signer + + signer = get_oke_workload_identity_resource_principal_signer() + _OCI_CLIENT = ObjectStorageClient( + config={"region": config.region}, + signer=signer, + ) + _OCI_CLIENT_CONFIG = config + session = getattr(getattr(_OCI_CLIENT, "base_client", None), "session", None) + if session is not None: + session.trust_env = False + return _OCI_CLIENT + + +def _reset_oci_client() -> None: + """Discard a client whose HTTP connection pool may be in an invalid state.""" + global _OCI_CLIENT, _OCI_CLIENT_CONFIG + + with _OCI_CLIENT_LOCK: + client = _OCI_CLIENT + _OCI_CLIENT = None + _OCI_CLIENT_CONFIG = None + + session = getattr(getattr(client, "base_client", None), "session", None) + close = getattr(session, "close", None) + if callable(close): + try: + close() + except Exception: + logger.debug("failed closing discarded OCI client session", exc_info=True) + + +def _put_object_sync(item: STTAudioUploadItem, config: OCIUploadConfig) -> Any: + with _OCI_UPLOAD_LOCK: + client = _oci_client(config) + return client.put_object( + namespace_name=config.namespace, + bucket_name=config.bucket, + object_name=item.object_name, + put_object_body=item.wav_bytes, + ) + + +class _STTAudioUploadWorker: + def __init__(self, loop: asyncio.AbstractEventLoop) -> None: + self._loop = loop + self._queue: asyncio.Queue[STTAudioUploadItem] = asyncio.Queue(maxsize=_QUEUE_SIZE) + self._task: asyncio.Task[None] | None = None + + def enqueue(self, item: STTAudioUploadItem) -> bool: + if self._task is None or self._task.done(): + self._task = self._loop.create_task( + self._run(), + name="stt-audio-upload-worker", + ) + try: + self._queue.put_nowait(item) + return True + except asyncio.QueueFull: + return False + + async def _run(self) -> None: + while True: + item = await self._queue.get() + try: + await _upload_item(item) + except Exception: + log_flow_event( + item.logger or logger, + "stt_audio_upload_failed", + request_id=item.req_id, + message_id=item.message_id, + session_id=item.session_id, + object_name=item.object_name, + bytes=len(item.wav_bytes), + **_log_metadata_fields(item), + ) + _emit_timeline( + item, + "stt_audio_upload_failed", + message_id=item.message_id, + object_name=item.object_name, + bytes=len(item.wav_bytes), + **_log_metadata_fields(item), + ) + (item.logger or logger).exception( + "[stt][audio_upload_failed] req_id=%s object_name=%s", + item.req_id, + item.object_name, + ) + finally: + self._queue.task_done() + + +_WORKERS: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, _STTAudioUploadWorker] = ( + weakref.WeakKeyDictionary() +) +_WORKERS_LOCK = threading.Lock() + + +def _worker_for_loop(loop: asyncio.AbstractEventLoop) -> _STTAudioUploadWorker: + with _WORKERS_LOCK: + worker = _WORKERS.get(loop) + if worker is None: + worker = _STTAudioUploadWorker(loop) + _WORKERS[loop] = worker + return worker + + +def _emit_timeline(item: STTAudioUploadItem, event: str, **fields: Any) -> None: + if item.timeline is None: + return + try: + item.timeline.emit(event, request_id=item.req_id, **fields) + except Exception: + pass + + +def _log_metadata_fields(item: STTAudioUploadItem) -> dict[str, Any]: + return { + str(key): value + for key, value in item.metadata.items() + if value not in (None, "") + } + + +async def _upload_item(item: STTAudioUploadItem) -> None: + started = time.perf_counter() + config = _oci_upload_config_from_env() + if config is None: + raise RuntimeError("OCI Object Storage upload config is missing") + + response = await asyncio.to_thread(_put_object_sync, item, config) + + duration_ms = round((time.perf_counter() - started) * 1000) + log_flow_event( + item.logger or logger, + "stt_audio_upload_done", + request_id=item.req_id, + message_id=item.message_id, + session_id=item.session_id, + object_name=item.object_name, + bytes=len(item.wav_bytes), + duration_ms=duration_ms, + bucket=config.bucket, + namespace=config.namespace, + oci_status=getattr(response, "status", None), + **_log_metadata_fields(item), + ) + _emit_timeline( + item, + "stt_audio_upload_done", + message_id=item.message_id, + object_name=item.object_name, + bytes=len(item.wav_bytes), + duration_ms=duration_ms, + bucket=config.bucket, + namespace=config.namespace, + oci_status=getattr(response, "status", None), + **_log_metadata_fields(item), + ) + + +def enqueue_stt_vad_audio_upload( + *, + wav_bytes: bytes, + req_id: str, + message_id: str, + structured_log_context: Any = None, + metadata: Mapping[str, Any] | None = None, + logger_override: Any | None = None, + timeline: Any | None = None, +) -> str | None: + if _oci_upload_config_from_env() is None: + return None + + active_logger = logger_override or logger + session_id = _metadata_value(structured_log_context, "session_id", "sessionId") + object_name = build_stt_vad_object_name( + session_id=session_id, + message_id=message_id, + req_id=req_id, + ) + + if len(wav_bytes) > _MAX_WAV_BYTES: + log_flow_event( + active_logger, + "stt_audio_upload_drop", + request_id=req_id, + message_id=message_id, + session_id=session_id, + object_name=object_name, + reason="too_large", + bytes=len(wav_bytes), + ) + return object_name + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + log_flow_event( + active_logger, + "stt_audio_upload_drop", + request_id=req_id, + message_id=message_id, + session_id=session_id, + object_name=object_name, + reason="no_running_loop", + ) + return object_name + + item = STTAudioUploadItem( + object_name=object_name, + wav_bytes=wav_bytes, + req_id=req_id, + message_id=message_id, + session_id=session_id, + metadata=metadata or {}, + logger=active_logger, + timeline=timeline, + ) + queued = _worker_for_loop(loop).enqueue(item) + if not queued: + log_flow_event( + active_logger, + "stt_audio_upload_drop", + request_id=req_id, + message_id=message_id, + session_id=session_id, + object_name=object_name, + reason="queue_full", + bytes=len(wav_bytes), + ) + return object_name + + log_flow_event( + active_logger, + "stt_audio_upload_enqueued", + request_id=req_id, + message_id=message_id, + session_id=session_id, + object_name=object_name, + bytes=len(wav_bytes), + ) + return object_name diff --git a/src/app/utils/turn_ids.py b/src/app/utils/turn_ids.py new file mode 100644 index 0000000..583aec7 --- /dev/null +++ b/src/app/utils/turn_ids.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +import threading +import uuid +from collections import defaultdict, deque +from dataclasses import dataclass +from typing import Any, Mapping + + +_LOCK = threading.Lock() +_PENDING_TRANSCRIPTS: dict[str, deque["RegisteredTurn"]] = defaultdict(deque) +_STARTED_TURN_MESSAGE_IDS: dict[str, str] = {} +_MAX_PENDING_TRANSCRIPTS = 32 + + +@dataclass(frozen=True, slots=True) +class RegisteredTurn: + message_id: str + transcription: str + text: str + + +def _string_or_empty(value: Any) -> str: + return str(value or "").strip() + + +def _mapping_value(payload: Mapping[str, Any], *keys: str) -> str: + for key in keys: + value = payload.get(key) + if value not in (None, ""): + return _string_or_empty(value) + + lowered = {str(key).lower(): value for key, value in payload.items()} + for key in keys: + value = lowered.get(str(key).lower()) + if value not in (None, ""): + return _string_or_empty(value) + + return "" + + +def _source_value(source: Any, *keys: str) -> str: + if isinstance(source, Mapping): + return _mapping_value(source, *keys) + + for key in keys: + value = getattr(source, key, None) + if value not in (None, ""): + return _string_or_empty(value) + return "" + + +def _router_callid(source: Any) -> str: + day = _source_value(source, "RouterCallKeyDay", "routerCallKeyDay", "router_call_key_day") + key = _source_value(source, "RouterCallKey", "routerCallKey", "router_call_key") + if day or key: + return f"{day}{key}" + return "" + + +def turn_message_key(source: Any) -> str: + return ( + _source_value(source, "session_id", "sessionId") + or _router_callid(source) + or _source_value(source, "callid", "call_id") + or _source_value(source, "callIdGed", "call_id_ged", "message_id_seed") + or _source_value(source, "protocol_id", "protocolId", "protocolo", "protocolNumber", "protocol") + or "turn" + ) + + +def reset_turn_message_sequence(source: Any, *, clear_pending: bool = False) -> None: + if not clear_pending: + return + + key = turn_message_key(source) + with _LOCK: + _PENDING_TRANSCRIPTS.pop(key, None) + _STARTED_TURN_MESSAGE_IDS.pop(key, None) + + +def next_turn_message_id(source: Any) -> str: + del source + return str(uuid.uuid4()) + + +def register_started_turn_message_id(source: Any, message_id: str) -> None: + normalized_message_id = _string_or_empty(message_id) + if not normalized_message_id: + return + + key = turn_message_key(source) + with _LOCK: + _STARTED_TURN_MESSAGE_IDS[key] = normalized_message_id + + +def peek_started_turn_message_id(source: Any) -> str: + key = turn_message_key(source) + with _LOCK: + return _STARTED_TURN_MESSAGE_IDS.get(key, "") + + +def clear_started_turn_message_id(source: Any, message_id: str = "") -> None: + key = turn_message_key(source) + normalized_message_id = _string_or_empty(message_id) + with _LOCK: + if not normalized_message_id: + _STARTED_TURN_MESSAGE_IDS.pop(key, None) + return + + if _STARTED_TURN_MESSAGE_IDS.get(key) == normalized_message_id: + _STARTED_TURN_MESSAGE_IDS.pop(key, None) + + +def register_transcribed_turn( + source: Any, + *, + message_id: str, + transcription: str = "", + text: str = "", +) -> None: + normalized_message_id = _string_or_empty(message_id) + if not normalized_message_id: + return + + key = turn_message_key(source) + turn = RegisteredTurn( + message_id=normalized_message_id, + transcription=_string_or_empty(transcription), + text=_string_or_empty(text), + ) + with _LOCK: + queue = _PENDING_TRANSCRIPTS[key] + queue.append(turn) + while len(queue) > _MAX_PENDING_TRANSCRIPTS: + queue.popleft() + + +def consume_transcribed_turn( + source: Any, + *, + transcription: str = "", + text: str = "", +) -> RegisteredTurn | None: + key = turn_message_key(source) + normalized_transcription = _string_or_empty(transcription) + normalized_text = _string_or_empty(text) + + with _LOCK: + queue = _PENDING_TRANSCRIPTS.get(key) + if not queue: + return None + + for turn in tuple(queue): + if normalized_transcription and turn.transcription == normalized_transcription: + queue.remove(turn) + return turn + if normalized_text and turn.text == normalized_text: + queue.remove(turn) + return turn + + return queue.popleft() diff --git a/src/app/ws_gateway/call_config.py b/src/app/ws_gateway/call_config.py new file mode 100644 index 0000000..585ee8a --- /dev/null +++ b/src/app/ws_gateway/call_config.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import Any, Dict, Mapping + +from app.common.call_config import normalize_call_config + + +def build_call_config(payload: Mapping[str, Any] | None) -> Dict[str, Any]: + return normalize_call_config(payload) diff --git a/src/app/ws_gateway/fake_remote_agent.py b/src/app/ws_gateway/fake_remote_agent.py new file mode 100644 index 0000000..0387a63 --- /dev/null +++ b/src/app/ws_gateway/fake_remote_agent.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from typing import Any, Mapping + + +_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." +) + + +def _as_str(value: Any) -> str: + return str(value or "").strip() + + +def _normalize_agent_name(value: Any) -> str: + raw = _as_str(value).lower() + aliases = { + "conta": "conta", + "contas": "conta", + "ofert": "oferta", + "oferta": "oferta", + "ofertas": "oferta", + "cobra": "cobranca", + "cobranca": "cobranca", + "cobranca": "cobranca", + } + return aliases.get(raw, raw or "conta") + + +def _payload_body(payload: Mapping[str, Any]) -> Mapping[str, Any]: + body = payload.get("payload") + if isinstance(body, Mapping): + return body + return payload + + +def _extract_agent_name(payload: Mapping[str, Any]) -> str: + body = _payload_body(payload) + return _normalize_agent_name( + body.get("agent") + or payload.get("agent") + or payload.get("_agent") + ) + + +def _extract_stage(payload: Mapping[str, Any]) -> str: + body = _payload_body(payload) + return _as_str( + body.get("stage") + or payload.get("stage") + or payload.get("_stage") + ).upper() or "PRESENTATION" + + +def _extract_user_text(payload: Mapping[str, Any]) -> str: + body = _payload_body(payload) + for key in ("message", "text", "utterance", "transcript", "content"): + text = _as_str(body.get(key) or payload.get(key)) + if text: + return text + return "" + + +def _is_end_request(payload: Mapping[str, Any]) -> bool: + body = _payload_body(payload) + if _as_str(payload.get("action")).lower() == "end": + return True + if _as_str(payload.get("type")).lower() == "end": + return True + if _as_str(body.get("type")).lower() == "end": + return True + return False + + +def _next_stage(current_stage: str, text: str, *, is_end: bool) -> str: + lowered = _as_str(text).lower() + if is_end or any(token in lowered for token in ("encerrar", "finalizar", "obrigado", "tchau")): + return "DONE" + + stage = (current_stage or "PRESENTATION").upper() + if stage in {"", "INTRO", "PRESENTATION"}: + return "ARGUMENTATION" + if stage == "ARGUMENTATION": + return "FORMALIZATION" + if stage == "FORMALIZATION": + return "DONE" + return stage + + +def _reply_text(agent_name: str, next_stage: str, user_text: str) -> str: + transcript = _as_str(user_text) + + if next_stage == "DONE": + if agent_name == "conta": + return "Atendimento simulado de conta encerrado. Obrigado." + if agent_name == "oferta": + return "Atendimento simulado de oferta encerrado. Obrigado." + return "Atendimento simulado de cobrança encerrado. Obrigado." + + if agent_name == "conta": + if not transcript: + return _CONTA_OPENING + base = "Simulação de conta ativa. Posso seguir com os detalhes da fatura." + elif agent_name == "oferta": + base = "Simulação de oferta ativa. Tenho uma oferta pronta para você." + else: + base = "Simulação de cobrança ativa. Posso continuar com a negociação." + + if transcript: + return f"{base} Texto transcrito no STT: {transcript}." + return base + + +def build_fake_remote_agent_response(payload: Mapping[str, Any]) -> dict[str, Any]: + agent_name = _extract_agent_name(payload) + current_stage = _extract_stage(payload) + user_text = _extract_user_text(payload) + is_end = _is_end_request(payload) + next_stage = _next_stage(current_stage, user_text, is_end=is_end) + content = _reply_text(agent_name, next_stage, user_text) + + if agent_name == "conta": + action = _as_str(payload.get("action")).lower() or "chat" + result_payload = { + "type": "final", + "content": content, + "tool_calls": [], + } + if next_stage == "DONE": + result_payload["result"] = [{"status": "ok", "reason": "fake_done"}] + return { + "type": "result", + "action": action or "chat", + "stage": next_stage, + "result": result_payload, + } + + response_type = "done" if next_stage == "DONE" else "final" + result = [{"status": "ok", "reason": "fake_done"}] if next_stage == "DONE" else [] + return { + "type": response_type, + "stage": next_stage, + "text": content, + "result": result, + } diff --git a/src/app/ws_gateway/main.py b/src/app/ws_gateway/main.py new file mode 100644 index 0000000..529aaa6 --- /dev/null +++ b/src/app/ws_gateway/main.py @@ -0,0 +1,2100 @@ +# ws_bridge.py +import asyncio +import json +import os +import time +import uuid +import audioop +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, Mapping, Optional + +from dotenv import load_dotenv +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import FileResponse, JSONResponse + +from livekit import rtc +from livekit.api import AccessToken, VideoGrants +from livekit import api as lkapi + +from app.common.call_config import ( + resolve_agent_backend_name, + resolve_stt_overrides, + resolve_tts_overrides, + resolve_ws_overrides, +) +from app.ws_gateway.fake_remote_agent import build_fake_remote_agent_response +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_bootstrap import build_bridge_session_bootstrap +from app.ws_gateway.session_lifecycle import ( + RoomLifecycleState, + close_websocket, + register_room_lifecycle_handlers, + watch_call_done, +) +from app.ws_gateway.readiness import ( + BridgeReadinessManager, + build_bridge_failed_stop_message, + build_capacity_stop_message, +) +from app.ws_gateway.session_start import recv_start_message +from app.utils.call_timeline import CallTimeline +from app.utils.audio_backlog import ShedResult, rms_threshold_from_dbfs, shed_queue_backlog +from app.utils.background import AudioActivity, BridgeOutputStats, ws_out_loop +from app.utils.full_call_recording import create_entire_call_recorder_from_env +from app.utils.pcm import pcm16_dbfs +from app.utils.logging import log_flow_event, setup_minimal_logging + +logger = setup_minimal_logging() +app = FastAPI() +READINESS_GATE = BridgeReadinessManager() + +SAMPLE_RATE = 16000 +CHANNELS = 1 +FRAME_MS = 20 +SAMPLES_PER_FRAME = int(SAMPLE_RATE * FRAME_MS / 1000) # 320 +BYTES_PER_FRAME = SAMPLES_PER_FRAME * 2 * CHANNELS # 640 + +HOLD_SILENCE_DBFS = float(os.getenv("HOLD_SILENCE_DBFS", "-50")) +RMS_TH = int(32768 * (10 ** (HOLD_SILENCE_DBFS / 20.0))) +SILENCE_CHECK_EVERY = int(os.getenv("SILENCE_CHECK_EVERY", "5")) # checa a cada 5 frames (100ms) + +# --- ENV / AMBIENTE --- +APP_ENV = (os.getenv("APP_ENV") or os.getenv("ENV") or "dev").strip().lower() +DOTENV_PATH = os.getenv("DOTENV_PATH", f".env.{APP_ENV}") + +AGENT_BASE_NAME = os.getenv("AGENT_BASE_NAME", "ws-voice-agent").strip() +AGENT_NAME = os.getenv("AGENT_NAME", f"{AGENT_BASE_NAME}-{APP_ENV}").strip() + +LK_CATCHUP_ENABLED = os.getenv("LK_CATCHUP_ENABLED", "1") == "1" +LK_CATCHUP_KEEP_MS = int(os.getenv("LK_CATCHUP_KEEP_MS", "1000")) +LK_CATCHUP_MAX_FAST_FRAMES = int(os.getenv("LK_CATCHUP_MAX_FAST_FRAMES", "4000")) + +# --- Marcador de "fala ativa" do ÁUDIO DO AGENTE (só telemetria/idle; NÃO filtra áudio) --- +AGENT_OUT_SPEECH_DBFS = float(os.getenv("AGENT_OUT_SPEECH_DBFS", "-52")) # acima disso = fala ativa +AGENT_OUT_SPEECH_RMS_TH = int(32768 * (10 ** (AGENT_OUT_SPEECH_DBFS / 20.0))) +AGENT_OUT_RMS_EVERY = int(os.getenv("AGENT_OUT_RMS_EVERY", "1")) # 1 = checa todo frame +FLOW_AUDIO_BURST_GAP_S = float(os.getenv("FLOW_AUDIO_BURST_GAP_S", "0.8")) +FLOW_LOG_AUDIO_BURST_END = ( + os.getenv("FLOW_LOG_AUDIO_BURST_END", "0").strip().lower() + in {"1", "true", "yes", "on"} +) +VOICE_CLIENT_HTML = Path(__file__).with_name("voice_client.html") + + +def _load_runtime_env() -> None: + if DOTENV_PATH: + load_dotenv(DOTENV_PATH, override=False) + load_dotenv(override=False) + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _env_int(name: str, default: int) -> int: + try: + return int(os.getenv(name, str(default))) + except ValueError: + return default + + +def _env_float(name: str, default: float) -> float: + try: + return float(os.getenv(name, str(default))) + except ValueError: + return default + + +def _frames_from_ms(value_ms: int, *, minimum: int = 1) -> int: + value_ms = max(0, int(value_ms)) + return max(minimum, (value_ms + FRAME_MS - 1) // FRAME_MS) + + +@dataclass(frozen=True) +class AudioInputBacklogConfig: + shed_enabled: bool + shed_threshold_ms: int + shed_keep_ms: int + latency_metrics_enabled: bool + latency_alert_ms: int + latency_log_interval_s: float + livekit_source_queue_size_ms: int + livekit_source_clear_on_shed: bool + config_source: str = "env" + call_config_overrides: tuple[str, ...] = () + # Descarte guiado por energia: no regime de drift (excesso pequeno) so + # descarta silencio; so cai no descarte cego quando o excesso passa de + # energy_shed_max_excess_ms (regime de rajada). + energy_shed_enabled: bool = True + energy_shed_max_excess_ms: int = 1000 + silence_dbfs: float = -50.0 + + @property + def shed_threshold_frames(self) -> int: + return _frames_from_ms(self.shed_threshold_ms) + + @property + def shed_keep_frames(self) -> int: + return min( + self.shed_threshold_frames, + _frames_from_ms(self.shed_keep_ms), + ) + + @property + def silence_rms_threshold(self) -> int: + return rms_threshold_from_dbfs(self.silence_dbfs) + + +def _call_config_has_value(overrides: Mapping[str, Any], key: str) -> bool: + return overrides.get(key) not in (None, "") + + +def _config_bool( + overrides: Mapping[str, Any], + key: str, + env_name: str, + default: bool, +) -> bool: + env_value = _env_bool(env_name, default) + if not _call_config_has_value(overrides, key): + return env_value + return str(overrides.get(key)).strip().lower() in {"1", "true", "yes", "on"} + + +def _config_int( + overrides: Mapping[str, Any], + key: str, + env_name: str, + default: int, +) -> int: + env_value = _env_int(env_name, default) + if not _call_config_has_value(overrides, key): + return env_value + try: + return int(str(overrides.get(key)).strip()) + except (TypeError, ValueError): + return env_value + + +def _config_float( + overrides: Mapping[str, Any], + key: str, + env_name: str, + default: float, +) -> float: + env_value = _env_float(env_name, default) + if not _call_config_has_value(overrides, key): + return env_value + try: + return float(str(overrides.get(key)).strip()) + except (TypeError, ValueError): + return env_value + + +def audio_input_backlog_config_from_env( + call_config: Optional[Mapping[str, Any]] = None, +) -> AudioInputBacklogConfig: + ws_overrides = resolve_ws_overrides(call_config) + override_fields = tuple( + key + for key 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", + "audio_in_backlog_energy_shed_enabled", + "audio_in_backlog_energy_shed_max_excess_ms", + "audio_in_backlog_silence_dbfs", + ) + if _call_config_has_value(ws_overrides, key) + ) + shed_enabled = _config_bool( + ws_overrides, + "audio_in_backlog_shed_enabled", + "AUDIO_IN_BACKLOG_SHED_ENABLED", + True, + ) + default_source_queue_ms = 500 if shed_enabled else 5000 + return AudioInputBacklogConfig( + shed_enabled=shed_enabled, + shed_threshold_ms=max( + FRAME_MS, + _config_int( + ws_overrides, + "audio_in_backlog_shed_threshold_ms", + "AUDIO_IN_BACKLOG_SHED_THRESHOLD_MS", + 500, + ), + ), + shed_keep_ms=max( + FRAME_MS, + _config_int( + ws_overrides, + "audio_in_backlog_shed_keep_ms", + "AUDIO_IN_BACKLOG_SHED_KEEP_MS", + 300, + ), + ), + latency_metrics_enabled=_config_bool( + ws_overrides, + "audio_in_latency_metrics_enabled", + "AUDIO_IN_LATENCY_METRICS_ENABLED", + True, + ), + latency_alert_ms=max( + 0, + _config_int( + ws_overrides, + "audio_in_latency_alert_ms", + "AUDIO_IN_LATENCY_ALERT_MS", + 1000, + ), + ), + latency_log_interval_s=max( + 0.1, + _config_float( + ws_overrides, + "audio_in_latency_log_interval_s", + "AUDIO_IN_LATENCY_LOG_INTERVAL_S", + 15.0, + ), + ), + livekit_source_queue_size_ms=max( + FRAME_MS, + _config_int( + ws_overrides, + "livekit_audio_source_queue_size_ms", + "LIVEKIT_AUDIO_SOURCE_QUEUE_SIZE_MS", + default_source_queue_ms, + ), + ), + livekit_source_clear_on_shed=_config_bool( + ws_overrides, + "livekit_audio_source_clear_on_shed", + "LIVEKIT_AUDIO_SOURCE_CLEAR_ON_SHED", + shed_enabled, + ), + energy_shed_enabled=_config_bool( + ws_overrides, + "audio_in_backlog_energy_shed_enabled", + "AUDIO_IN_BACKLOG_ENERGY_SHED_ENABLED", + True, + ), + energy_shed_max_excess_ms=max( + 0, + _config_int( + ws_overrides, + "audio_in_backlog_energy_shed_max_excess_ms", + "AUDIO_IN_BACKLOG_ENERGY_SHED_MAX_EXCESS_MS", + 1000, + ), + ), + silence_dbfs=_config_float( + ws_overrides, + "audio_in_backlog_silence_dbfs", + "AUDIO_IN_BACKLOG_SILENCE_DBFS", + -60.0, + ), + config_source="call_config" if override_fields else "env", + call_config_overrides=override_fields, + ) + + +class AudioInputLatencyTracker: + def __init__( + self, + config: AudioInputBacklogConfig, + *, + timeline: Optional[CallTimeline] = None, + call_context: Optional[Mapping[str, Any]] = None, + debug_event_publisher: Optional[Callable[[str, Mapping[str, Any]], None]] = None, + ) -> None: + self.config = config + self.timeline = timeline + self.call_context = dict(call_context or {}) + self.debug_event_publisher = debug_event_publisher + self.enabled_at: float | None = None + self.last_latency_log_at = 0.0 + self.total_dropped_frames = 0 + self.peak_excess_ms = 0 + self.peak_raw_excess_ms = 0 + + def mark_enabled(self, now: Optional[float] = None) -> None: + if self.enabled_at is None: + self.enabled_at = time.monotonic() if now is None else now + self.last_latency_log_at = 0.0 + + def snapshot( + self, + *, + frame_count: int, + now: Optional[float] = None, + queue_size_frames: int = 0, + ) -> Dict[str, int]: + if self.enabled_at is None: + self.mark_enabled(now) + now = time.monotonic() if now is None else now + enabled_at = self.enabled_at if self.enabled_at is not None else now + elapsed_ms = max(0, round((now - enabled_at) * 1000)) + frames_since_enable = max(0, int(frame_count)) + received_audio_ms = frames_since_enable * FRAME_MS + effective_frames = max(0, frames_since_enable - self.total_dropped_frames) + effective_audio_ms = effective_frames * FRAME_MS + raw_excess_ms = received_audio_ms - elapsed_ms + excess_ms = effective_audio_ms - elapsed_ms + raw_lag_ms = max(0, raw_excess_ms) + lag_ms = max(0, excess_ms) + self.peak_excess_ms = max(self.peak_excess_ms, excess_ms) + self.peak_raw_excess_ms = max(self.peak_raw_excess_ms, raw_excess_ms) + return { + "frames_since_enable": frames_since_enable, + "received_audio_ms": received_audio_ms, + "effective_frames_since_enable": effective_frames, + "effective_audio_ms": effective_audio_ms, + "elapsed_since_enable_ms": elapsed_ms, + "excess_ms": excess_ms, + "lag_ms": lag_ms, + "peak_excess_ms": self.peak_excess_ms, + "raw_excess_ms": raw_excess_ms, + "raw_lag_ms": raw_lag_ms, + "peak_raw_excess_ms": self.peak_raw_excess_ms, + "queue_frames": max(0, int(queue_size_frames)), + "queue_ms": max(0, int(queue_size_frames)) * FRAME_MS, + "total_dropped_frames": self.total_dropped_frames, + "total_dropped_ms": self.total_dropped_frames * FRAME_MS, + "saved_latency_ms": self.total_dropped_frames * FRAME_MS, + } + + def maybe_log_latency( + self, + *, + frame_count: int, + now: Optional[float] = None, + queue_size_frames: int = 0, + reason: str, + force: bool = False, + ) -> None: + if not self.config.latency_metrics_enabled: + return + now = time.monotonic() if now is None else now + fields = self.snapshot( + frame_count=frame_count, + now=now, + queue_size_frames=queue_size_frames, + ) + should_log = ( + force + or fields["lag_ms"] >= self.config.latency_alert_ms + or fields["queue_ms"] >= self.config.latency_alert_ms + or (now - self.last_latency_log_at) >= self.config.latency_log_interval_s + ) + if not should_log: + return + self.last_latency_log_at = now + payload = { + **self.call_context, + **fields, + "reason": reason, + "alert_ms": self.config.latency_alert_ms, + } + log_flow_event(logger, "audio_in_latency", **payload) + if self.timeline is not None: + self.timeline.emit("audio_in_latency", **payload) + + def record_shed( + self, + *, + source: str, + dropped_frames: int, + queue_before_frames: int, + queue_after_frames: int, + frame_count: int, + mode: str = "blind", + dropped_silent: int = 0, + dropped_voiced: int = 0, + now: Optional[float] = None, + ) -> None: + if dropped_frames <= 0: + return + now = time.monotonic() if now is None else now + self.total_dropped_frames += int(dropped_frames) + fields = self.snapshot( + frame_count=frame_count, + now=now, + queue_size_frames=queue_after_frames, + ) + payload = { + **self.call_context, + **fields, + "source": source, + "mode": mode, + "content_policy": "silence_only" if mode == "energy" else "oldest_frames", + "sync_action": "compress_silence" if mode == "energy" else "drop_audio_to_catch_up", + "dropped_frames": int(dropped_frames), + "dropped_ms": int(dropped_frames) * FRAME_MS, + "dropped_silent_frames": int(dropped_silent), + "dropped_silent_ms": int(dropped_silent) * FRAME_MS, + "dropped_voiced_frames": int(dropped_voiced), + "dropped_voiced_ms": int(dropped_voiced) * FRAME_MS, + "saved_ms": int(dropped_frames) * FRAME_MS, + "queue_before_frames": int(queue_before_frames), + "queue_before_ms": int(queue_before_frames) * FRAME_MS, + "queue_after_frames": int(queue_after_frames), + "queue_after_ms": int(queue_after_frames) * FRAME_MS, + "shed_threshold_ms": self.config.shed_threshold_ms, + "shed_keep_ms": self.config.shed_keep_ms, + } + log_flow_event(logger, "audio_in_latency_shed", **payload) + if self.timeline is not None: + self.timeline.emit("audio_in_latency_shed", **payload) + if self.debug_event_publisher is not None: + self.debug_event_publisher("bridge.audio_in.shed", payload) + + def record_livekit_source_clear( + self, + *, + source: str, + queued_ms: int, + frame_count: int, + queue_size_frames: int, + now: Optional[float] = None, + ) -> None: + now = time.monotonic() if now is None else now + fields = self.snapshot( + frame_count=frame_count, + now=now, + queue_size_frames=queue_size_frames, + ) + payload = { + **self.call_context, + **fields, + "source": source, + "livekit_queued_ms": max(0, int(queued_ms)), + } + log_flow_event(logger, "audio_in_livekit_source_cleared", **payload) + if self.timeline is not None: + self.timeline.emit("audio_in_livekit_source_cleared", **payload) + + +def _shed_audio_queue_backlog( + audio_q: asyncio.Queue[bytes], + *, + config: AudioInputBacklogConfig, + tracker: Optional[AudioInputLatencyTracker], + source: str, + frame_count: int, + now: Optional[float] = None, +) -> ShedResult: + if not config.shed_enabled: + return ShedResult() + queue_before = audio_q.qsize() + if queue_before <= config.shed_threshold_frames: + return ShedResult() + + keep_frames = config.shed_keep_frames + over_keep_ms = max(0, queue_before - keep_frames) * FRAME_MS + # Regime de drift (excesso pequeno): so descarta silencio, preservando fala. + # Regime de rajada (excesso grande): descarte cego para recuperar latencia. + blind = ( + not config.energy_shed_enabled + or over_keep_ms > config.energy_shed_max_excess_ms + ) + result = shed_queue_backlog( + audio_q, + keep_frames=keep_frames, + rms_threshold=config.silence_rms_threshold, + blind=blind, + ) + + if result.dropped and tracker is not None: + tracker.record_shed( + source=source, + dropped_frames=result.dropped, + dropped_silent=result.dropped_silent, + dropped_voiced=result.dropped_voiced, + mode=result.mode, + queue_before_frames=queue_before, + queue_after_frames=audio_q.qsize(), + frame_count=frame_count, + now=now, + ) + return result + + +def _audio_input_latency_burst_fields( + tracker: Optional[AudioInputLatencyTracker], + *, + frame_count: int, + now: float, + queue_size_frames: int, +) -> Dict[str, Any]: + if tracker is None or not tracker.config.latency_metrics_enabled: + return {} + fields = tracker.snapshot( + frame_count=frame_count, + now=now, + queue_size_frames=queue_size_frames, + ) + return { + "input_excess_ms": fields["excess_ms"], + "input_lag_ms": fields["lag_ms"], + "input_raw_excess_ms": fields["raw_excess_ms"], + "input_raw_lag_ms": fields["raw_lag_ms"], + "input_queue_ms": fields["queue_ms"], + "input_total_dropped_ms": fields["total_dropped_ms"], + } + + +def _maybe_clear_livekit_source_queue( + source: rtc.AudioSource, + *, + tracker: Optional[AudioInputLatencyTracker], + reason: str, + frame_count: int, + queue_size_frames: int, + now: Optional[float] = None, +) -> int: + if tracker is None or not tracker.config.livekit_source_clear_on_shed: + return 0 + clear_queue = getattr(source, "clear_queue", None) + if not callable(clear_queue): + return 0 + try: + queued_ms = max(0, round(float(getattr(source, "queued_duration", 0.0)) * 1000)) + except Exception: + queued_ms = 0 + if queued_ms <= 0: + return 0 + try: + clear_queue() + except Exception: + logger.debug("LIVEKIT_AUDIO_SOURCE_CLEAR_FAIL", exc_info=True) + return 0 + tracker.record_livekit_source_clear( + source=reason, + queued_ms=queued_ms, + frame_count=frame_count, + queue_size_frames=queue_size_frames, + now=now, + ) + return queued_ms + + +def _is_new_audio_burst(now: float, last_audio_at: float) -> bool: + return last_audio_at <= 0.0 or (now - last_audio_at) >= FLOW_AUDIO_BURST_GAP_S + + +class FlowAudioBurstLogger: + def __init__(self, *, start_step: str, end_step: str) -> None: + self.start_step = start_step + self.end_step = end_step + self.seq = 0 + self.active = False + self.first_frame = 0 + self.last_frame = 0 + self.last_audio_at = 0.0 + self.active_frames = 0 + self.peak_rms = 0 + self.peak_dbfs = -120.0 + + def maybe_end(self, *, now: float, reason: str) -> None: + if self.active and _is_new_audio_burst(now, self.last_audio_at): + self._finish(reason=reason) + + def observe( + self, + *, + rms: int, + dbfs: float, + frame: int, + now: float, + start_fields: Optional[Dict[str, Any]] = None, + ) -> Optional[int]: + self.maybe_end(now=now, reason="silence_gap") + if rms <= RMS_TH: + return None + + new_burst = not self.active + if new_burst: + self.seq += 1 + self.active = True + self.first_frame = frame + self.active_frames = 0 + self.peak_rms = 0 + self.peak_dbfs = -120.0 + + self.last_frame = frame + self.last_audio_at = now + self.active_frames += 1 + if rms >= self.peak_rms: + self.peak_rms = rms + self.peak_dbfs = dbfs + + if new_burst: + log_flow_event( + logger, + self.start_step, + seq=self.seq, + rms=rms, + dbfs=f"{dbfs:.2f}", + frame=frame, + **(start_fields or {}), + ) + return self.seq + + return None + + def _finish(self, *, reason: str) -> None: + if FLOW_LOG_AUDIO_BURST_END: + duration_ms = max(FRAME_MS, (self.last_frame - self.first_frame + 1) * FRAME_MS) + log_flow_event( + logger, + self.end_step, + seq=self.seq, + reason=reason, + duration_ms=duration_ms, + active_frames=self.active_frames, + first_frame=self.first_frame, + last_frame=self.last_frame, + peak_rms=self.peak_rms, + peak_dbfs=f"{self.peak_dbfs:.2f}", + ) + self.active = False + + +class BridgeDebugState: + """ + Debug timeline (somente DEBUG). + Mantive pra você poder ligar nível DEBUG quando precisar investigar latência, + sem poluir INFO. + """ + + def __init__(self, *, base_t: float, timeline: Optional[CallTimeline] = None): + self.base_t = base_t + self.timeline = timeline + self.first_ws_in: Optional[float] = None + self.first_lk_out: Optional[float] = None + self.in_frames: int = 0 + self.out_frames: int = 0 + + def tlog(self, msg: str, **kv): + logger.debug("[T+%.3fs] %s | %s", time.monotonic() - self.base_t, msg, kv) + + def mark_ws_in(self): + if self.first_ws_in is None: + self.first_ws_in = time.monotonic() + self.tlog("WS->BRIDGE first_frame", in_frames=self.in_frames) + if self.timeline is not None: + self.timeline.emit( + "client_audio_first_frame_received", + in_frames=self.in_frames, + ) + + def mark_lk_out(self, backlog: int): + if self.first_lk_out is None: + self.first_lk_out = time.monotonic() + self.tlog("BRIDGE->LK first_capture", out_frames=self.out_frames, backlog=backlog) + if self.timeline is not None: + self.timeline.emit( + "client_audio_first_frame_published", + out_frames=self.out_frames, + backlog_frames=backlog, + ) + + +def make_token(identity: str, room_name: str) -> str: + grants = VideoGrants(room_join=True, room=room_name) + return ( + AccessToken( + api_key=os.environ["LIVEKIT_API_KEY"], + api_secret=os.environ["LIVEKIT_API_SECRET"], + ) + .with_identity(identity) + .with_grants(grants) + .to_jwt() + ) + + +def is_agent(p: rtc.Participant) -> bool: + try: + return int(p.kind) == 4 + except Exception: + ident = str(getattr(p, "identity", "") or "") + return ident == "agent" or ident.startswith("agent") + + +@app.get("/voice-client") +def voice_client() -> FileResponse: + return FileResponse(VOICE_CLIENT_HTML) + + +@app.websocket("/fake-agent/ws") +async def fake_remote_agent_ws(ws: WebSocket): + await ws.accept() + logger.info("FAKE_REMOTE_AGENT_CONNECT") + + try: + while True: + raw = await ws.receive_text() + payload = json.loads(raw) + response = build_fake_remote_agent_response(payload) + logger.info( + "FAKE_REMOTE_AGENT_REPLY | agent=%s | stage=%s", + str(response.get("agent") or payload.get("agent") or payload.get("action") or "-"), + str(response.get("stage") or "-"), + ) + await ws.send_text(json.dumps(response, ensure_ascii=False)) + except WebSocketDisconnect: + logger.info("FAKE_REMOTE_AGENT_DISCONNECT") + except Exception: + logger.exception("FAKE_REMOTE_AGENT_FAIL") + try: + await ws.send_text(json.dumps({"error": "fake_remote_agent_failed"})) + except Exception: + pass + try: + await close_websocket(ws, code=1011, reason="fake_agent_failed") + except Exception: + pass + + +async def dispatch_agent(lk_url: str, room_name: str, metadata: Dict[str, Any]) -> None: + lk = lkapi.LiveKitAPI( + url=lk_url, + api_key=os.environ["LIVEKIT_API_KEY"], + api_secret=os.environ["LIVEKIT_API_SECRET"], + ) + try: + meta_str = json.dumps(metadata, ensure_ascii=False) + await lk.agent_dispatch.create_dispatch( + lkapi.CreateAgentDispatchRequest( + agent_name=AGENT_NAME, + room=room_name, + metadata=meta_str, + ) + ) + finally: + await lk.aclose() + +async def ws_audio_receiver( + ws: WebSocket, + out_q: asyncio.Queue[bytes], + dbg: Optional[BridgeDebugState] = None, + *, + audio_enabled: Optional[asyncio.Event] = None, + recording_sink: Any = None, + input_backlog_config: Optional[AudioInputBacklogConfig] = None, + input_latency_tracker: Optional[AudioInputLatencyTracker] = None, +) -> None: + """ + Pipe puro: + - sempre lê do WS (evita backpressure/buffer) + - só enfileira frames quando audio_enabled estiver setado + - não faz lógica de "barge-in" aqui + """ + buf = bytearray() + frame_idx = 0 + + enabled_prev = False + dropped_bytes = 0 + client_bursts = FlowAudioBurstLogger( + start_step="audio_in", + end_step="audio_in_end", + ) + + while True: + msg = await ws.receive() + + if msg.get("type") == "websocket.disconnect": + raise WebSocketDisconnect(code=msg.get("code", 1000)) + + data = msg.get("bytes") + if not data: + continue + + enabled = True if audio_enabled is None else audio_enabled.is_set() + + # enquanto não estiver liberado, consome e descarta + if not enabled: + dropped_bytes += len(data) + buf.clear() + continue + + # transição: bridge liberou captura do cliente + if enabled and not enabled_prev: + enabled_prev = True + buf.clear() + frame_idx = 0 + now_enabled = time.monotonic() + if input_latency_tracker is not None: + input_latency_tracker.mark_enabled(now_enabled) + input_latency_tracker.maybe_log_latency( + frame_count=0, + now=now_enabled, + queue_size_frames=out_q.qsize(), + reason="receiver_enabled", + force=True, + ) + if dropped_bytes: + logger.info("CLIENT_AUDIO_ENABLED | dropped_bytes_before_enable=%s", dropped_bytes) + + buf.extend(data) + while len(buf) >= BYTES_PER_FRAME: + frame_bytes = bytes(buf[:BYTES_PER_FRAME]) + del buf[:BYTES_PER_FRAME] + + if dbg is not None: + dbg.in_frames += 1 + if dbg.in_frames == 1: + dbg.mark_ws_in() + frame_count = dbg.in_frames if dbg is not None else frame_idx + 1 + + if recording_sink is not None: + try: + recording_sink.record_client_frame(frame_bytes) + except Exception: + pass + + # mantém apenas atividade / medição (não interfere em áudio do agente) + now = time.monotonic() + if frame_idx % SILENCE_CHECK_EVERY == 0: + try: + rms = audioop.rms(frame_bytes, 2) + dbfs = pcm16_dbfs(frame_bytes) if rms > RMS_TH else -120.0 + seq = client_bursts.observe( + rms=rms, + dbfs=dbfs, + frame=frame_count, + now=now, + start_fields=_audio_input_latency_burst_fields( + input_latency_tracker, + frame_count=frame_count, + now=now, + queue_size_frames=out_q.qsize(), + ), + ) + if seq is not None: + if dbg is not None and dbg.timeline is not None: + dbg.timeline.emit( + "client_audio_activity_detected", + seq=seq, + rms=rms, + dbfs=round(dbfs, 2), + ) + elif rms <= RMS_TH: + client_bursts.maybe_end(now=now, reason="silence_gap") + except Exception: + pass + frame_idx += 1 + + try: + out_q.put_nowait(frame_bytes) + except asyncio.QueueFull: + queue_before = out_q.qsize() + selective = ShedResult() + if input_backlog_config is not None and input_backlog_config.energy_shed_enabled: + selective = shed_queue_backlog( + out_q, + keep_frames=max(0, queue_before - 1), + rms_threshold=input_backlog_config.silence_rms_threshold, + blind=False, + ) + if selective.dropped and input_latency_tracker is not None: + input_latency_tracker.record_shed( + source="receiver_queue_full", + dropped_frames=selective.dropped, + mode=selective.mode, + dropped_silent=selective.dropped_silent, + dropped_voiced=selective.dropped_voiced, + queue_before_frames=queue_before, + queue_after_frames=out_q.qsize(), + frame_count=frame_count, + now=now, + ) + try: + out_q.put_nowait(frame_bytes) + except asyncio.QueueFull: + try: + _ = out_q.get_nowait() + except asyncio.QueueEmpty: + pass + else: + if input_latency_tracker is not None: + input_latency_tracker.record_shed( + source="receiver_queue_full", + dropped_frames=1, + mode="queue_full_blind", + dropped_voiced=1, + queue_before_frames=queue_before, + queue_after_frames=max(0, out_q.qsize() - 1), + frame_count=frame_count, + now=now, + ) + out_q.put_nowait(frame_bytes) + + if input_backlog_config is not None: + _shed_audio_queue_backlog( + out_q, + config=input_backlog_config, + tracker=input_latency_tracker, + source="receiver", + frame_count=frame_count, + now=now, + ) + + if input_latency_tracker is not None: + input_latency_tracker.maybe_log_latency( + frame_count=frame_count, + now=now, + queue_size_frames=out_q.qsize(), + reason="receiver_audio", + ) + + +async def connect_publish_livekit( + room: rtc.Room, + lk_url: str, + token: str, + track: rtc.LocalAudioTrack, + opts: rtc.TrackPublishOptions, + track_published: asyncio.Event, + track_subscribed: asyncio.Event, + dbg: Optional[BridgeDebugState] = None, + timeline: Optional[CallTimeline] = None, +) -> None: + try: + await room.connect(lk_url, token) + if timeline is not None: + timeline.emit("livekit_room_connected") + publication = await room.local_participant.publish_track(track, opts) + + track_published.set() + if dbg: + dbg.tlog("LK connect+publish OK") + if timeline is not None: + timeline.emit("livekit_track_published") + + await publication.wait_for_subscription() + track_subscribed.set() + if dbg: + dbg.tlog("LK track subscribed") + if timeline is not None: + timeline.emit("livekit_track_subscribed") + except Exception: + logger.exception("LIVEKIT_SETUP_FAIL | connect/publish") + if timeline is not None: + timeline.emit("livekit_setup_failed") + raise + + +async def publish_queue_to_livekit( + source: rtc.AudioSource, + in_q: asyncio.Queue[bytes], + track_subscribed: asyncio.Event, + dbg: Optional[BridgeDebugState] = None, + *, + audio_enabled: Optional[asyncio.Event] = None, + input_backlog_config: Optional[AudioInputBacklogConfig] = None, + input_latency_tracker: Optional[AudioInputLatencyTracker] = None, +) -> None: + if dbg: + dbg.tlog("BRIDGE waiting track_subscribed") + + await track_subscribed.wait() + + # só começa a publicar áudio do cliente depois da liberação do bridge + if audio_enabled is not None: + if dbg: + dbg.tlog("BRIDGE waiting audio_enabled") + await audio_enabled.wait() + + now = time.monotonic() + if input_latency_tracker is not None: + input_latency_tracker.mark_enabled(now) + + frame_count = dbg.in_frames if dbg is not None else 0 + if input_backlog_config is not None: + _shed_audio_queue_backlog( + in_q, + config=input_backlog_config, + tracker=input_latency_tracker, + source="publisher_start", + frame_count=frame_count, + now=now, + ) + if input_latency_tracker is not None: + input_latency_tracker.maybe_log_latency( + frame_count=frame_count, + now=now, + queue_size_frames=in_q.qsize(), + reason="publisher_start", + force=True, + ) + + backlog0 = in_q.qsize() + publish_frame_idx = 0 + publish_bursts = FlowAudioBurstLogger( + start_step="audio_to_livekit", + end_step="audio_to_livekit_end", + ) + if dbg: + backlog_frames = in_q.qsize() + backlog_s = backlog_frames * (FRAME_MS / 1000.0) + dbg.tlog("BRIDGE start publish", backlog_frames=backlog_frames, backlog_s=round(backlog_s, 3)) + + if LK_CATCHUP_ENABLED and backlog0 > 0 and not ( + input_backlog_config is not None and input_backlog_config.shed_enabled + ): + keep_frames = max(1, int(LK_CATCHUP_KEEP_MS / FRAME_MS)) + if backlog0 > keep_frames: + if dbg: + dbg.tlog("BRIDGE catchup begin", backlog=backlog0, keep_frames=keep_frames, keep_ms=LK_CATCHUP_KEEP_MS) + + drained = 0 + while in_q.qsize() > keep_frames and drained < LK_CATCHUP_MAX_FAST_FRAMES: + try: + frame_bytes = in_q.get_nowait() + except asyncio.QueueEmpty: + break + + if dbg is not None: + dbg.out_frames += 1 + if dbg.out_frames == 1: + dbg.mark_lk_out(in_q.qsize()) + + frame = rtc.AudioFrame.create( + sample_rate=SAMPLE_RATE, + num_channels=CHANNELS, + samples_per_channel=SAMPLES_PER_FRAME, + ) + mv = frame.data.cast("B") + mv[:BYTES_PER_FRAME] = frame_bytes + + await source.capture_frame(frame) + drained += 1 + + if dbg: + dbg.tlog("BRIDGE catchup end", drained=drained, backlog_now=in_q.qsize()) + + next_t = time.monotonic() + while True: + now = time.monotonic() + frame_count = dbg.in_frames if dbg is not None else publish_frame_idx + if input_backlog_config is not None: + shed_result = _shed_audio_queue_backlog( + in_q, + config=input_backlog_config, + tracker=input_latency_tracker, + source="publisher_loop", + frame_count=frame_count, + now=now, + ) + if shed_result.dropped and shed_result.mode == "blind": + _maybe_clear_livekit_source_queue( + source, + tracker=input_latency_tracker, + reason="publisher_loop_shed", + frame_count=frame_count, + queue_size_frames=in_q.qsize(), + now=now, + ) + next_t = time.monotonic() + + frame_bytes = await in_q.get() + + if dbg is not None: + dbg.out_frames += 1 + if dbg.out_frames == 1: + dbg.mark_lk_out(in_q.qsize()) + + now = time.monotonic() + if publish_frame_idx % SILENCE_CHECK_EVERY == 0: + try: + rms = audioop.rms(frame_bytes, 2) + dbfs = pcm16_dbfs(frame_bytes) if rms > RMS_TH else -120.0 + if rms > RMS_TH: + publish_bursts.observe( + rms=rms, + dbfs=dbfs, + frame=dbg.out_frames if dbg is not None else publish_frame_idx, + now=now, + start_fields={ + "backlog_frames": in_q.qsize(), + "livekit_queued_ms": max(0, round(source.queued_duration * 1000)), + **_audio_input_latency_burst_fields( + input_latency_tracker, + frame_count=dbg.in_frames if dbg is not None else publish_frame_idx + 1, + now=now, + queue_size_frames=in_q.qsize(), + ), + }, + ) + else: + publish_bursts.maybe_end(now=now, reason="silence_gap") + except Exception: + pass + publish_frame_idx += 1 + + if now - next_t > 0.2: + next_t = now + + if now < next_t: + await asyncio.sleep(next_t - now) + + frame = rtc.AudioFrame.create( + sample_rate=SAMPLE_RATE, + num_channels=CHANNELS, + samples_per_channel=SAMPLES_PER_FRAME, + ) + mv = frame.data.cast("B") + mv[:BYTES_PER_FRAME] = frame_bytes + + await source.capture_frame(frame) + next_t += (FRAME_MS / 1000.0) + + +async def stream_agent_audio_to_queue( + agent_q: asyncio.Queue[bytes], + agent_participant: rtc.Participant, + activity: AudioActivity, + timeline: Optional[CallTimeline] = None, + call_context: Optional[Mapping[str, Any]] = None, +): + flow_context = dict(call_context or {}) + stream = rtc.AudioStream.from_participant( + participant=agent_participant, + track_source=rtc.TrackSource.SOURCE_MICROPHONE, + sample_rate=SAMPLE_RATE, + num_channels=CHANNELS, + frame_size_ms=FRAME_MS, + ) + + buf = bytearray() + frame_idx = 0 + + first_frame_sent = False + last_agent_audio_at = 0.0 + agent_audio_burst_seq = 0 + dropped_queue_frames = 0 + + async for ev in stream: + b = bytes(ev.frame.data) + buf.extend(b) + + while len(buf) >= BYTES_PER_FRAME: + frame = bytes(buf[:BYTES_PER_FRAME]) + del buf[:BYTES_PER_FRAME] + + now = time.monotonic() + + # mede RMS só para telemetria/atividade — nunca para descartar frame + rms = None + if frame_idx % AGENT_OUT_RMS_EVERY == 0: + try: + rms = audioop.rms(frame, 2) + except Exception: + rms = None + frame_idx += 1 + + # todo frame do agente é encaminhado bit a bit; a energia só marca + # "fala ativa" (usado por idle/hold e mock-stop), não filtra o áudio. + if rms is not None and rms > AGENT_OUT_SPEECH_RMS_TH: + new_burst = _is_new_audio_burst(now, last_agent_audio_at) + last_agent_audio_at = now + activity.last_agent_out = now + if new_burst: + agent_audio_burst_seq += 1 + log_flow_event( + logger, + "audio_from_agent", + **flow_context, + seq=agent_audio_burst_seq, + participant=str(getattr(agent_participant, "identity", "") or ""), + rms=rms if rms is not None else "", + frame=frame_idx, + ) + if timeline is not None: + timeline.emit( + "agent_audio_activity_detected", + **flow_context, + seq=agent_audio_burst_seq, + participant_identity=str(getattr(agent_participant, "identity", "") or ""), + rms=rms, + ) + if not first_frame_sent: + first_frame_sent = True + if timeline is not None: + timeline.emit( + "agent_audio_first_frame_received", + **flow_context, + participant_identity=str(getattr(agent_participant, "identity", "") or ""), + ) + + try: + agent_q.put_nowait(frame) + except asyncio.QueueFull: + dropped_queue_frames += 1 + if dropped_queue_frames == 1 or dropped_queue_frames % 50 == 0: + log_flow_event( + logger, + "audio_from_agent_queue_overflow", + **flow_context, + participant=str(getattr(agent_participant, "identity", "") or ""), + dropped_agent_audio_frames=dropped_queue_frames, + dropped_voiced_frames=1, + queue_size=agent_q.qsize(), + content_policy="oldest_frame", + sync_action="drop_audio_to_catch_up", + ) + if timeline is not None: + timeline.emit( + "agent_audio_queue_overflow", + **flow_context, + participant_identity=str(getattr(agent_participant, "identity", "") or ""), + dropped_agent_audio_frames=dropped_queue_frames, + dropped_voiced_frames=1, + queue_size=agent_q.qsize(), + content_policy="oldest_frame", + sync_action="drop_audio_to_catch_up", + ) + _ = agent_q.get_nowait() + agent_q.put_nowait(frame) + + +@app.on_event("startup") +async def _startup(): + _load_runtime_env() + + +@app.websocket("/ws/agent") +async def ws_agent(ws: WebSocket): + _load_runtime_env() + call_started_unix_ms = round(time.time() * 1000) + await ws.accept() + + lk_url = os.environ["LIVEKIT_URL"] + call_t0 = time.monotonic() + capacity_lease = None + ws_close_code = 1000 + ws_close_reason = "ws_agent_cleanup" + ws_client_disconnected = False + bridge_output_stats: Optional[BridgeOutputStats] = None + bridge_log_context: Dict[str, Any] = {} + entire_call_recorder = None + readiness_task: asyncio.Task[Any] | None = None + try: + start_ctx = await recv_start_message(ws, logger=logger) + reservation = await READINESS_GATE.reserve_capacity() + if not reservation.allowed: + logger.warning( + "WS_AGENT_PRE_READY_STOP | reason=capacity_exceeded | active_connections=%s | max_connections=%s", + reservation.active_connections, + reservation.max_connections, + ) + await ws.send_text(json.dumps(build_capacity_stop_message(), ensure_ascii=False)) + try: + await close_websocket(ws, code=1013, reason="pre_ready:capacity_exceeded") + except Exception: + pass + return + capacity_lease = reservation.lease + + agent_name = str(start_ctx.data.get("agent") or "").strip().lower() or "conta" + readiness_task = asyncio.create_task( + READINESS_GATE.evaluate_resources(agent_name=agent_name, call_config=start_ctx.call_config), + name="evaluate_call_readiness", + ) + + bootstrap = build_bridge_session_bootstrap( + start_ctx=start_ctx, + app_env=APP_ENV, + livekit_room=os.getenv("LIVEKIT_ROOM", ""), + default_protocol=os.getenv("DEFAULT_PROTOCOL", ""), + token_factory=make_token, + logger=logger, + timeline_origin_unix_ms=call_started_unix_ms, + ) + call_id_ged = bootstrap.call_id_ged + call_config = bootstrap.call_config + remote_agent_context = bootstrap.remote_agent_context + room_name = bootstrap.room_name + identity = bootstrap.identity + token = bootstrap.token + protocol = bootstrap.protocol + session_id = str(bootstrap.dispatch_metadata.get("session_id") or "") + phone_number = bootstrap.phone_number + timeline = bootstrap.timeline + + logger.info( + "CALL_START | room=%s | protocol=%s | session_id=%s | call_id_ged=%s | identity=%s", + room_name, + protocol, + session_id or "-", + call_id_ged or "-", + identity, + ) + log_flow_event( + logger, + "call_start", + room=room_name, + protocol=protocol, + session_id=session_id, + call_id_ged=call_id_ged, + phone_number=phone_number, + bridge=identity, + ) + timeline.emit( + "call_start", + call_id_ged=call_id_ged, + bridge_identity=identity, + remote_agent=remote_agent_context.get("agent", ""), + agent_backend=resolve_agent_backend_name(call_config, os.getenv("AGENT_BACKEND", "remote_ws")), + stt_provider=( + resolve_stt_overrides(call_config).get("provider") + or os.getenv("STT_PROVIDER", "internal_http") + ), + tts_provider=( + resolve_tts_overrides(call_config).get("provider") + or os.getenv("TTS_PROVIDER", "elevenlabs") + ), + timeline_file=str(timeline.path), + ) + timeline.emit("readiness_started", mode="parallel_post_ready") + timeline.emit("bridge_start_received") + + + # READY imediato + await ws.send_text( + json.dumps( + { + "type": "ready", + "room": room_name, + "session_id": bootstrap.dispatch_metadata.get("session_id", ""), + "sample_rate": SAMPLE_RATE, + "channels": CHANNELS, + "frame_ms": FRAME_MS, + "bytes_per_frame": BYTES_PER_FRAME, + "debug_events_enabled": start_ctx.debug_events_enabled, + "stress_test": start_ctx.stress_test, + } + ) + ) + timeline.emit("ready_sent") + + now = time.monotonic() + activity = AudioActivity(last_agent_out=now) + + client_q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=1500) # ~30s + agent_q: asyncio.Queue[bytes] = asyncio.Queue(maxsize=600) # mais folga pra áudio do agente + + dbg = BridgeDebugState(base_t=time.monotonic(), timeline=timeline) + bridge_output_stats = BridgeOutputStats() + bridge_log_context = { + "room": room_name, + "protocol": protocol, + "session_id": session_id, + "call_id_ged": call_id_ged, + "phone_number": phone_number, + "bridge": identity, + } + lifecycle = RoomLifecycleState() + + def _publish_bridge_debug_event( + event: str, payload: Mapping[str, Any] + ) -> None: + if not start_ctx.debug_events_enabled: + return + + async def _send() -> None: + try: + await ws.send_text( + json.dumps( + { + "type": "debug_event", + "stress_test": True, + "event": event, + "data": dict(payload), + }, + ensure_ascii=False, + ) + ) + except Exception: + logger.debug("bridge debug event forwarding failed", exc_info=True) + + task = asyncio.create_task( + _send(), name=f"forward_{event.replace('.', '_')}" + ) + lifecycle.debug_forward_tasks.add(task) + task.add_done_callback(lifecycle.debug_forward_tasks.discard) + + input_backlog_config = audio_input_backlog_config_from_env(call_config) + input_latency_tracker = AudioInputLatencyTracker( + input_backlog_config, + timeline=timeline, + call_context=bridge_log_context, + debug_event_publisher=_publish_bridge_debug_event, + ) + input_backlog_config_payload = { + **bridge_log_context, + "shed_enabled": input_backlog_config.shed_enabled, + "shed_threshold_ms": input_backlog_config.shed_threshold_ms, + "shed_keep_ms": input_backlog_config.shed_keep_ms, + "latency_metrics_enabled": input_backlog_config.latency_metrics_enabled, + "latency_alert_ms": input_backlog_config.latency_alert_ms, + "latency_log_interval_s": input_backlog_config.latency_log_interval_s, + "livekit_source_queue_size_ms": input_backlog_config.livekit_source_queue_size_ms, + "livekit_source_clear_on_shed": input_backlog_config.livekit_source_clear_on_shed, + "config_source": input_backlog_config.config_source, + "call_config_overrides": list(input_backlog_config.call_config_overrides), + } + log_flow_event(logger, "audio_in_backlog_config", **input_backlog_config_payload) + timeline.emit("audio_in_backlog_config", **input_backlog_config_payload) + entire_call_recorder = create_entire_call_recorder_from_env( + session_id=session_id, + sample_rate=SAMPLE_RATE, + channels=CHANNELS, + sample_width=2, + frame_ms=FRAME_MS, + bytes_per_frame=BYTES_PER_FRAME, + logger_override=logger, + timeline=timeline, + metadata=bridge_log_context, + ) + first_agent_audio_sent = asyncio.Event() + mock_stop_after_first_audio_enabled = ( + os.getenv("MOCK_STOP_AFTER_FIRST_AUDIO_ENABLED", "0").strip().lower() + in {"1", "true", "yes", "on"} + ) + try: + mock_stop_after_first_audio_silence_s = max( + 0.0, + float(os.getenv("MOCK_STOP_AFTER_FIRST_AUDIO_SILENCE_S", "0.35")), + ) + except ValueError: + mock_stop_after_first_audio_silence_s = 0.35 + mock_stop_after_first_audio_reason = ( + os.getenv("MOCK_STOP_AFTER_FIRST_AUDIO_REASON", "DONE").strip() + or "DONE" + ) + + # gate: o bridge só aceita áudio do cliente depois de liberar a sessão + client_audio_enabled = asyncio.Event() # começa bloqueado + + # receiver sempre lendo (pra não estourar), mas só enfileira depois do gate + t_rx = asyncio.create_task( + ws_audio_receiver( + ws, + client_q, + dbg=dbg, + audio_enabled=client_audio_enabled, + recording_sink=entire_call_recorder, + input_backlog_config=input_backlog_config, + input_latency_tracker=input_latency_tracker, + ), + name="ws_audio_receiver", + ) + + room = rtc.Room() + agent_ready = asyncio.Event() + track_published = asyncio.Event() + track_subscribed = asyncio.Event() + + call_done = asyncio.Event() + agent_reconnect_enabled = _env_bool("AGENT_RECONNECT_ENABLED", True) + agent_reconnect_max_attempts = max(0, _env_int("AGENT_RECONNECT_MAX_ATTEMPTS", 1)) + agent_reconnect_timeout_s = max(0.1, _env_float("AGENT_RECONNECT_TIMEOUT_S", 10.0)) + agent_reconnect_poll_s = 0.05 + agent_reconnect_lock = asyncio.Lock() + + async def _dispatch_agent_with_timeline(*, reason: str, attempt: int = 0) -> None: + fields: Dict[str, Any] = {"agent_name": AGENT_NAME, "reason": reason} + if attempt: + fields["attempt"] = attempt + timeline.emit("dispatch_started", **fields) + log_flow_event( + logger, + "agent_dispatch", + agent_name=AGENT_NAME, + room=room_name, + reason=reason, + attempt=attempt, + ) + try: + await dispatch_agent(lk_url, room_name, bootstrap.dispatch_metadata) + except Exception: + timeline.emit("dispatch_failed", **fields) + raise + timeline.emit("dispatch_completed", **fields) + + async def _wait_for_agent_reconnect(previous_generation: int) -> bool: + while not call_done.is_set(): + if ( + lifecycle.agent_participant is not None + and lifecycle.agent_generation > previous_generation + ): + return True + await asyncio.sleep(agent_reconnect_poll_s) + return False + + def _stop_for_agent_disconnect(agent_identity: str) -> None: + if call_done.is_set(): + return + lifecycle.done_payload = { + "stage": "DONE", + "status": "stop_agent_runtime_unavailable", + "reason": "agent_disconnected", + "resource": "agent_runtime", + "failed_resources": ["agent_runtime"], + "phase": "in_session", + } + logger.error( + "AGENT_RECONNECT_EXHAUSTED | room=%s | protocol=%s | agent_identity=%s", + room_name, + protocol, + agent_identity or "-", + ) + timeline.emit( + "agent_reconnect_exhausted", + agent_identity=agent_identity or "", + max_attempts=agent_reconnect_max_attempts, + timeout_ms=round(agent_reconnect_timeout_s * 1000), + ) + call_done.set() + + async def _recover_agent_disconnect(agent_identity: str, generation: int) -> None: + if call_done.is_set(): + return + async with agent_reconnect_lock: + if call_done.is_set(): + return + if lifecycle.agent_participant is not None and lifecycle.agent_generation > generation: + return + if not agent_reconnect_enabled or agent_reconnect_max_attempts <= 0: + _stop_for_agent_disconnect(agent_identity) + return + + for attempt in range(1, agent_reconnect_max_attempts + 1): + if call_done.is_set(): + return + logger.warning( + "AGENT_RECONNECT_ATTEMPT | room=%s | protocol=%s | agent_identity=%s | attempt=%s/%s | timeout_s=%.3f", + room_name, + protocol, + agent_identity or "-", + attempt, + agent_reconnect_max_attempts, + agent_reconnect_timeout_s, + ) + timeline.emit( + "agent_reconnect_attempt", + agent_identity=agent_identity or "", + attempt=attempt, + max_attempts=agent_reconnect_max_attempts, + timeout_ms=round(agent_reconnect_timeout_s * 1000), + ) + try: + await _dispatch_agent_with_timeline(reason="agent_reconnect", attempt=attempt) + except Exception: + logger.exception( + "AGENT_RECONNECT_DISPATCH_FAIL | room=%s | protocol=%s | attempt=%s/%s", + room_name, + protocol, + attempt, + agent_reconnect_max_attempts, + ) + timeline.emit( + "agent_reconnect_dispatch_failed", + agent_identity=agent_identity or "", + attempt=attempt, + max_attempts=agent_reconnect_max_attempts, + ) + + try: + reconnected = await asyncio.wait_for( + _wait_for_agent_reconnect(generation), + timeout=agent_reconnect_timeout_s, + ) + except asyncio.TimeoutError: + reconnected = False + + if call_done.is_set(): + return + + if reconnected: + new_identity = str(getattr(lifecycle.agent_participant, "identity", "") or "") + logger.info( + "AGENT_RECONNECT_OK | room=%s | protocol=%s | old_agent_identity=%s | new_agent_identity=%s | attempt=%s", + room_name, + protocol, + agent_identity or "-", + new_identity or "-", + attempt, + ) + timeline.emit( + "agent_reconnect_completed", + old_agent_identity=agent_identity or "", + new_agent_identity=new_identity, + attempt=attempt, + ) + return + + timeline.emit( + "agent_reconnect_timeout", + agent_identity=agent_identity or "", + attempt=attempt, + max_attempts=agent_reconnect_max_attempts, + ) + + _stop_for_agent_disconnect(agent_identity) + + t_dispatch0 = time.monotonic() + register_room_lifecycle_handlers( + room=room, + logger=logger, + timeline=timeline, + room_name=room_name, + protocol=protocol, + dispatch_started_at=t_dispatch0, + agent_ready=agent_ready, + call_done=call_done, + state=lifecycle, + is_agent=is_agent, + on_agent_disconnect=_recover_agent_disconnect, + ws=ws, + debug_events_enabled=start_ctx.debug_events_enabled, + ) + t_done = asyncio.create_task( + watch_call_done( + call_done=call_done, + state=lifecycle, + ws=ws, + logger=logger, + room_name=room_name, + protocol=protocol, + timeline=timeline, + ), + name="watch_call_done", + ) + + async def _watch_parallel_readiness() -> None: + try: + readiness = await readiness_task + except asyncio.CancelledError: + raise + except Exception as exc: + logger.exception("WS_AGENT_READINESS_FAIL | agent=%s", agent_name) + timeline.emit("readiness_failed", error=type(exc).__name__) + if not call_done.is_set(): + lifecycle.done_payload = build_bridge_failed_stop_message()["data"] + call_done.set() + return + + if readiness.is_healthy(): + timeline.emit( + "readiness_ok", + cached=readiness.cached, + active_connections=readiness.active_connections, + max_connections=readiness.max_connections, + ) + return + + logger.warning( + "WS_AGENT_READINESS_FAIL | agent=%s | failed_resources=%s | report=%s", + agent_name, ",".join(readiness.failed_resources), json.dumps(readiness.as_dict(), ensure_ascii=False), + ) + timeline.emit("readiness_failed", failed_resources=readiness.failed_resources) + if not call_done.is_set(): + stop_message = readiness.build_stop_message() + stop_message["data"]["phase"] = "in_session" + lifecycle.done_payload = stop_message["data"] + call_done.set() + + t_readiness = asyncio.create_task(_watch_parallel_readiness(), name="watch_call_readiness") + + try: + source = rtc.AudioSource( + SAMPLE_RATE, + CHANNELS, + queue_size_ms=input_backlog_config.livekit_source_queue_size_ms, + ) + except TypeError: + source = rtc.AudioSource(SAMPLE_RATE, CHANNELS) + livekit_source_config_payload = { + **bridge_log_context, + "queue_size_ms": input_backlog_config.livekit_source_queue_size_ms, + "clear_on_shed": input_backlog_config.livekit_source_clear_on_shed, + } + log_flow_event(logger, "livekit_audio_source_config", **livekit_source_config_payload) + timeline.emit("livekit_audio_source_config", **livekit_source_config_payload) + + track = rtc.LocalAudioTrack.create_audio_track("ws-mic", source) + opts = rtc.TrackPublishOptions() + opts.source = rtc.TrackSource.SOURCE_MICROPHONE + + # dispatch do agent + t_dispatch = asyncio.create_task( + _dispatch_agent_with_timeline(reason="initial"), + name="dispatch_agent", + ) + + # conecta + publica track + espera subscribe + t_lk = asyncio.create_task( + connect_publish_livekit( + room=room, + lk_url=lk_url, + token=token, + track=track, + opts=opts, + track_published=track_published, + track_subscribed=track_subscribed, + dbg=dbg, + timeline=timeline, + ), + name="connect_publish_livekit", + ) + + # publish do áudio do cliente -> LK (só depois do gate) + t_in = asyncio.create_task( + publish_queue_to_livekit( + source, + client_q, + track_subscribed, + dbg=dbg, + audio_enabled=client_audio_enabled, + input_backlog_config=input_backlog_config, + input_latency_tracker=input_latency_tracker, + ), + name="publish_queue_to_livekit", + ) + + input_latency_tracker.mark_enabled(time.monotonic()) + await enable_client_audio( + logger=logger, + timeline=timeline, + client_audio_enabled=client_audio_enabled, + room_name=room_name, + protocol=protocol, + ) + t_control = asyncio.create_task( + notify_client_audio_enabled( + room=room, + logger=logger, + timeline=timeline, + room_name=room_name, + protocol=protocol, + track_published=track_published, + agent_ready=agent_ready, + lifecycle=lifecycle, + repeat=True, + ), + name="notify_client_audio_enabled", + ) + + ws_overrides = resolve_ws_overrides(call_config) + + # saída: agente -> cliente (via ws_out_loop) + t_out_loop = asyncio.create_task( + ws_out_loop( + ws, + agent_q, + frame_ms=FRAME_MS, + bytes_per_frame=BYTES_PER_FRAME, + first_agent_audio_sent=first_agent_audio_sent, + flow_logger=logger, + timeline=timeline, + call_context=bridge_log_context, + output_stats=bridge_output_stats, + output_gain=ws_overrides.get("output_gain"), + recording_sink=entire_call_recorder, + debug_event_publisher=_publish_bridge_debug_event, + ), + name="ws_out_loop", + ) + + t_agent_to_q = asyncio.create_task( + stream_agent_audio_when_ready( + agent_ready=agent_ready, + lifecycle=lifecycle, + agent_q=agent_q, + activity=activity, + timeline=timeline, + stream_agent_audio=( + lambda agent_q, agent_participant, activity, timeline=None: stream_agent_audio_to_queue( + agent_q, + agent_participant, + activity, + timeline=timeline, + call_context=bridge_log_context, + ) + ), + repeat=True, + ), + name="stream_agent_audio_when_ready", + ) + t_mock_stop = None + if mock_stop_after_first_audio_enabled: + logger.info( + "MOCK_STOP_AFTER_FIRST_AUDIO_ENABLED | room=%s | protocol=%s | silence_s=%.3f | reason=%s", + room_name, + protocol, + mock_stop_after_first_audio_silence_s, + mock_stop_after_first_audio_reason, + ) + timeline.emit( + "mock_stop_after_first_audio_enabled", + silence_ms=round(mock_stop_after_first_audio_silence_s * 1000), + reason=mock_stop_after_first_audio_reason, + ) + t_mock_stop = asyncio.create_task( + watch_mock_stop_after_first_audio( + ws=ws, + logger=logger, + timeline=timeline, + room_name=room_name, + protocol=protocol, + first_agent_audio_sent=first_agent_audio_sent, + activity=activity, + agent_q=agent_q, + silence_s=mock_stop_after_first_audio_silence_s, + reason=mock_stop_after_first_audio_reason, + ), + name="mock_stop_after_first_audio", + ) + + try: + tasks = {t_rx, t_out_loop, t_in, t_agent_to_q, t_lk, t_dispatch, t_done, t_readiness, t_control} + if t_mock_stop is not None: + tasks.add(t_mock_stop) + pending = set(tasks) + while pending: + done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED) + if t_done in done: + break + for d in done: + exc = d.exception() + if exc: + logger.error( + "WS_AGENT_TASK_FAIL | room=%s | protocol=%s | task=%s", + room_name, + protocol, + d.get_name(), + exc_info=exc, + ) + raise exc + + except WebSocketDisconnect as exc: + ws_client_disconnected = True + logger.info( + "WS_DISCONNECT | room=%s | protocol=%s | code=%s", + room_name, + protocol, + getattr(exc, "code", ""), + ) + timeline.emit("ws_disconnect") + except Exception as exc: + ws_close_code = 1011 + ws_close_reason = "bridge_failed" + logger.exception("WS_AGENT_FAIL | room=%s | protocol=%s", room_name, protocol) + timeline.emit("bridge_failed") + try: + await ws.send_text(json.dumps(build_bridge_failed_stop_message(), ensure_ascii=False)) + except Exception: + pass + finally: + for t in (t_rx, t_out_loop, t_in, t_agent_to_q, t_lk, t_dispatch, t_done, t_readiness, t_control, t_mock_stop): + try: + t.cancel() + except Exception: + pass + + try: + await room.disconnect() + except Exception: + pass + + if not ws_client_disconnected: + try: + await close_websocket(ws, code=ws_close_code, reason=ws_close_reason) + except Exception: + pass + + if entire_call_recorder is not None: + try: + await entire_call_recorder.finalize() + except Exception: + logger.exception( + "ENTIRE_CALL_RECORDING_FINALIZE_FAIL | room=%s | protocol=%s | session_id=%s", + room_name, + protocol, + session_id or "-", + ) + + dur = time.monotonic() - call_t0 + audio_to_tia_summary: Dict[str, Any] = {} + if bridge_output_stats is not None: + audio_to_tia_summary = { + "destination": "ws_client", + "ws_close_reason": ws_close_reason, + "ws_client_disconnected": ws_client_disconnected, + "duration_ms": round(dur * 1000), + "total_ws_frames": bridge_output_stats.total_ws_frames, + "agent_audio_frames": bridge_output_stats.agent_audio_frames, + "silence_frames": bridge_output_stats.silence_frames, + "agent_audio_bursts": bridge_output_stats.agent_audio_bursts, + "first_agent_audio_sent": bridge_output_stats.first_agent_audio_sent, + "queued_agent_frames": agent_q.qsize(), + } + log_flow_event( + logger, + "audio_to_tia_summary", + **bridge_log_context, + **audio_to_tia_summary, + ) + timeline.emit( + "agent_audio_to_tia_summary", + **bridge_log_context, + **audio_to_tia_summary, + ) + logger.info( + "CALL_END | room=%s | protocol=%s | session_id=%s | duration_s=%.1f", + room_name, + protocol, + session_id or "-", + dur, + ) + timeline.emit( + "call_end", + duration_ms=round(dur * 1000), + client_in_frames=dbg.in_frames, + bridge_out_frames=dbg.out_frames, + audio_to_tia_total_ws_frames=audio_to_tia_summary.get("total_ws_frames", 0), + audio_to_tia_agent_audio_frames=audio_to_tia_summary.get("agent_audio_frames", 0), + audio_to_tia_agent_audio_bursts=audio_to_tia_summary.get("agent_audio_bursts", 0), + audio_to_tia_first_agent_audio_sent=audio_to_tia_summary.get("first_agent_audio_sent", False), + ) + finally: + if capacity_lease is not None: + await capacity_lease.release() + if readiness_task is not None and not readiness_task.done(): + readiness_task.cancel() + + +@app.websocket("/ws/text") +async def ws_text(ws: WebSocket): + from app.services.text_pipeline import TextSessionPipeline + + await ws.accept() + session_id = str(uuid.uuid4()) + t0 = time.monotonic() + logger.info("TEXT_START | session_id=%s", session_id) + try: + start_ctx = await recv_start_message(ws, logger=logger) + pipeline = TextSessionPipeline( + ws, + start_ctx.payload, + session_id, + session_data=start_ctx.session_data, + intro=start_ctx.intro, + ) + await pipeline.run() + except WebSocketDisconnect: + logger.info("TEXT_END | session_id=%s | reason=disconnect | duration_s=%.1f", session_id, time.monotonic() - t0) + except Exception as e: + logger.exception("TEXT_FAIL | session_id=%s", session_id) + try: + await ws.send_text(json.dumps({"type": "error", "message": str(e)})) + except Exception: + pass + try: + await close_websocket(ws, code=1011, reason="text_pipeline_failed") + except Exception: + pass + + +@app.websocket("/ws/text_stream") +async def ws_text_stream(ws: WebSocket): + from app.services.text_pipeline_stream import TextSessionPipelineStream + + await ws.accept() + session_id = str(uuid.uuid4()) + t0 = time.monotonic() + logger.info("TEXT_STREAM_START | session_id=%s", session_id) + try: + start_ctx = await recv_start_message(ws, logger=logger) + pipeline = TextSessionPipelineStream( + ws, + start_ctx.payload, + session_id, + session_data=start_ctx.session_data, + intro=start_ctx.intro, + ) + await pipeline.run() + except WebSocketDisconnect: + logger.info( + "TEXT_STREAM_END | session_id=%s | reason=disconnect | duration_s=%.1f", + session_id, + time.monotonic() - t0, + ) + except Exception as e: + logger.exception("TEXT_STREAM_FAIL | session_id=%s", session_id) + try: + await ws.send_text(json.dumps({"type": "error", "message": str(e)})) + except Exception: + pass + try: + await close_websocket(ws, code=1011, reason="text_stream_pipeline_failed") + except Exception: + pass + +@app.get("/health") +def health(): + return {"status": "ok"} + + +@app.get("/health/resources") +async def health_resources(): + report = await READINESS_GATE.evaluate_default_resources() + status_code = 200 if report.is_healthy() else 503 + return JSONResponse(status_code=status_code, content=report.as_dict()) + + +@app.get("/health/services") +async def health_services(): + report = await READINESS_GATE.evaluate_services() + status_code = 200 if report.is_healthy() else 503 + return JSONResponse(status_code=status_code, content=report.as_dict()) + +if __name__ == "__main__": + import uvicorn + import argparse + + parser = argparse.ArgumentParser(description="LiveKit WebSocket Gateway (PCM16 16k <-> LiveKit)") + parser.add_argument("--port", type=int, default=8000) + parser.add_argument("--host", type=str, default="0.0.0.0") + parser.add_argument("--log-level", type=str, default="info") + parser.add_argument("--reload", action="store_true") + args = parser.parse_args() + + uvicorn.run( + app, + host=args.host, + port=args.port, + ws_ping_interval=float(os.getenv("UVICORN_WS_PING_INTERVAL_S", "20")), + ws_ping_timeout=float(os.getenv("UVICORN_WS_PING_TIMEOUT_S", "20")), + ws_max_size=50 * 1024 * 1024, + log_level="info" if args.log_level is None else args.log_level, + reload=args.reload, + ) diff --git a/src/app/ws_gateway/readiness.py b/src/app/ws_gateway/readiness.py new file mode 100644 index 0000000..86a3513 --- /dev/null +++ b/src/app/ws_gateway/readiness.py @@ -0,0 +1,944 @@ +from __future__ import annotations + +import asyncio +import json +import os +import time +from dataclasses import dataclass +from typing import Any, Dict, Mapping, Optional +from urllib.parse import urlsplit, urlunsplit + +import aiohttp +import httpx + +from app.common.call_config import ( + normalize_call_config, + resolve_agent_backend_name, + resolve_stt_overrides, + resolve_tts_overrides, +) +from app.livekit.adapters.azure_rest_tts import AzureRESTTTS +from app.livekit.adapters.xai_tts import OraclexAITTS +from app.livekit.azure_speech import ( + AZURE_SPEECH_TTS_SAMPLE_RATE, + resolve_azure_speech_tts_config, +) +from app.providers.stt_fake import FakeSTT +from app.providers.tts import FakeTTS as FakeProviderTTS + + +STOP_STATUS_BY_RESOURCE = { + "capacity": "stop_capacity_tia", + "agent_runtime": "stop_agent_runtime_unavailable", + "agent_backend": "stop_agent_backend_unavailable", + "stt": "stop_stt_unavailable", + "tts": "stop_tts_unavailable", +} +STOP_RESOURCE_PRECEDENCE = ( + "capacity", + "agent_runtime", + "agent_backend", + "stt", + "tts", +) +HEALTHY_CHECK_STATUSES = {"ok", "skipped"} +PROBE_TEXT = "teste de prontidao" +XAI_TTS_READINESS_MODE_ENV = "XAI_TTS_READINESS_MODE" +XAI_TTS_READINESS_MODES = {"connect", "synthesize"} +DEFAULT_FINAL_STOP_STATUS_BY_REASON = { + "resolved": "stop_resolvido_e_finalizado", + "unresolved": "stop_nao_resolvido", + "other_subject": "stop_outro_assunto", + "long_silence": "stop_silencio_longo", +} +DEFAULT_FINAL_STOP_REASON_BY_KIND = { + "resolved": "stage_done", + "unresolved": "nao_resolvido", + "other_subject": "outro_assunto", + "long_silence": "no_user_response", +} + + +def build_stop_message( + *, + status: str, + reason: str, + resource: str = "", + failed_resources: Optional[list[str]] = None, + phase: str = "", +) -> Dict[str, Any]: + data: Dict[str, Any] = { + "status": str(status or "").strip(), + "reason": str(reason or "").strip(), + } + if resource: + data["resource"] = str(resource).strip() + if failed_resources: + data["failed_resources"] = [str(item).strip() for item in failed_resources if str(item).strip()] + if phase: + data["phase"] = str(phase).strip() + return {"type": "stop", "data": data} + + +def _final_stop_status_from_env(key: str) -> str: + env_name = f"FINAL_STOP_STATUS_{key.upper()}" + return ( + os.getenv(env_name, DEFAULT_FINAL_STOP_STATUS_BY_REASON[key]) or DEFAULT_FINAL_STOP_STATUS_BY_REASON[key] + ).strip() + + +def _format_probe_error(exc: Exception, *, resource: str, timeout_s: float) -> str: + """Return a useful, secret-free error summary for readiness failures.""" + error_type = type(exc).__name__ + detail = _strip_text(str(exc)) + if isinstance(exc, TimeoutError): + return f"{resource} readiness timed out after {round(timeout_s * 1000)}ms ({error_type})" + if detail: + return f"{resource} readiness failed ({error_type}): {detail}" + return f"{resource} readiness failed ({error_type})" + + +def _final_stop_reason_from_env(key: str) -> str: + env_name = f"FINAL_STOP_REASON_{key.upper()}" + return ( + os.getenv(env_name, DEFAULT_FINAL_STOP_REASON_BY_KIND[key]) or DEFAULT_FINAL_STOP_REASON_BY_KIND[key] + ).strip() + + +def resolve_completed_stop_status(reason: str) -> str: + normalized_reason = str(reason or "").strip().lower() + for key in DEFAULT_FINAL_STOP_STATUS_BY_REASON: + configured_reason = _final_stop_reason_from_env(key).lower() + if normalized_reason and normalized_reason == configured_reason: + return _final_stop_status_from_env(key) + + default_key = ( + os.getenv("FINAL_STOP_DEFAULT_KIND", "resolved") or "resolved" + ).strip().lower() + if default_key not in DEFAULT_FINAL_STOP_STATUS_BY_REASON: + default_key = "resolved" + return _final_stop_status_from_env(default_key) + + +def build_completed_stop_message(reason: str) -> Dict[str, Any]: + resolved_reason = str(reason or "").strip() or _final_stop_reason_from_env("resolved") + return build_stop_message( + status=resolve_completed_stop_status(resolved_reason), + reason=resolved_reason, + phase="in_session", + ) + + +def build_capacity_stop_message() -> Dict[str, Any]: + return build_stop_message( + status=STOP_STATUS_BY_RESOURCE["capacity"], + reason="capacity_exceeded", + resource="capacity", + failed_resources=["capacity"], + phase="pre_ready", + ) + + +def build_bridge_failed_stop_message() -> Dict[str, Any]: + return build_stop_message( + status="stop_bridge_failed", + reason="bridge_failed", + resource="bridge", + phase="in_session", + ) + + +def _build_resource_stop_message(failed_resources: list[str]) -> Dict[str, Any]: + normalized = [name for name in STOP_RESOURCE_PRECEDENCE if name in failed_resources and name != "capacity"] + if not normalized: + raise ValueError("failed_resources must contain at least one non-capacity resource") + primary = normalized[0] + return build_stop_message( + status=STOP_STATUS_BY_RESOURCE[primary], + reason="resource_unhealthy", + resource=primary, + failed_resources=normalized, + phase="pre_ready", + ) + + +def _env_int(name: str, default: int) -> int: + raw = (os.getenv(name, "") or "").strip() + if not raw: + return default + try: + return int(raw) + except ValueError: + return default + + +def _env_float(name: str, default: float) -> float: + raw = (os.getenv(name, "") or "").strip() + if not raw: + return default + try: + return float(raw) + except ValueError: + return default + + +def _env_bool(name: str, default: bool = False) -> bool: + raw = (os.getenv(name, "") or "").strip().lower() + if not raw: + return default + return raw in {"1", "true", "yes", "on"} + + +def _env_bool_default_true(*names: str) -> bool: + for name in names: + raw = (os.getenv(name, "") or "").strip().lower() + if not raw: + continue + if raw in {"0", "false", "no", "off"}: + return False + if raw in {"1", "true", "yes", "on"}: + return True + return True + + +def _strip_text(value: Any) -> str: + return str(value or "").strip() + + +def _env_first(*names: str) -> str: + for name in names: + value = _strip_text(os.getenv(name)) + if value: + return value + return "" + + +def _agent_env_suffix(agent_name: str) -> str: + return "".join( + char if char.isalnum() else "_" + for char in _strip_text(agent_name).upper() + ).strip("_") + + +def _health_desc_value(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value.strip() + if isinstance(value, (int, float, bool)): + return str(value) + try: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + except TypeError: + return str(value).strip() + + +def _health_response_desc(response: httpx.Response) -> str: + try: + payload = response.json() + except ValueError: + payload = None + + if isinstance(payload, Mapping): + for key in ( + "http_cod_desc", + "hhtp_cod_desc", + "cod_desc", + "description", + "message", + "detail", + "error", + "reason", + ): + desc = _health_desc_value(payload.get(key)) + if desc: + return desc + elif payload is None: + text = _strip_text(response.text) + if text: + return text + + return _strip_text(getattr(response, "reason_phrase", "")) or ( + "OK" if 200 <= response.status_code < 300 else "FAIL" + ) + + +def _with_path(url: str, *, path: str) -> str: + parsed = urlsplit(_strip_text(url)) + if not parsed.scheme or not parsed.netloc: + return "" + return urlunsplit((parsed.scheme, parsed.netloc, path, "", "")).rstrip("/") + + +def _derive_http_health_url(url: str) -> str: + return _with_path(url, path="/health") + + +def _derive_websocket_health_url(url: str) -> str: + parsed = urlsplit(_strip_text(url)) + if not parsed.scheme or not parsed.netloc: + return "" + + scheme = parsed.scheme.lower() + if scheme == "ws": + http_scheme = "http" + elif scheme == "wss": + http_scheme = "https" + else: + return "" + + return urlunsplit((http_scheme, parsed.netloc, "/health", "", "")).rstrip("/") + + +def _remote_agent_tls_verify(agent_name: str) -> bool: + suffix = _agent_env_suffix(agent_name) + names: list[str] = [] + if suffix: + names.extend( + [ + f"REMOTE_AGENT_SSE_TLS_VERIFY_{suffix}", + f"REMOTE_AGENT_TLS_VERIFY_{suffix}", + f"REMOTE_AGENT_SSE_VERIFY_TLS_{suffix}", + f"REMOTE_AGENT_VERIFY_TLS_{suffix}", + ] + ) + names.extend( + [ + "REMOTE_AGENT_SSE_TLS_VERIFY", + "REMOTE_AGENT_TLS_VERIFY", + "REMOTE_AGENT_SSE_VERIFY_TLS", + "REMOTE_AGENT_VERIFY_TLS", + ] + ) + return _env_bool_default_true(*names) + + +@dataclass(slots=True) +class ResourceCheck: + status: str + message: str = "" + latency_ms: Optional[int] = None + url: str = "" + http_status_code: Optional[int] = None + http_status_desc: str = "" + + def clone(self) -> "ResourceCheck": + return ResourceCheck( + status=self.status, + message=self.message, + latency_ms=self.latency_ms, + url=self.url, + http_status_code=self.http_status_code, + http_status_desc=self.http_status_desc, + ) + + def is_healthy(self) -> bool: + return self.status in HEALTHY_CHECK_STATUSES + + def as_dict(self) -> Dict[str, Any]: + payload: Dict[str, Any] = {"status": self.status} + if self.message: + payload["message"] = self.message + if self.latency_ms is not None: + payload["latency_ms"] = self.latency_ms + if self.url: + payload["url"] = self.url + return payload + + +@dataclass(slots=True) +class ReadinessReport: + checks: Dict[str, ResourceCheck] + failed_resources: list[str] + active_connections: int + max_connections: int + cached: bool = False + + @property + def status(self) -> str: + return "ok" if not self.failed_resources else "fail" + + def is_healthy(self) -> bool: + return not self.failed_resources + + def clone(self, *, active_connections: Optional[int] = None, max_connections: Optional[int] = None, cached: Optional[bool] = None) -> "ReadinessReport": + return ReadinessReport( + checks={name: check.clone() for name, check in self.checks.items()}, + failed_resources=list(self.failed_resources), + active_connections=self.active_connections if active_connections is None else active_connections, + max_connections=self.max_connections if max_connections is None else max_connections, + cached=self.cached if cached is None else cached, + ) + + def as_dict(self) -> Dict[str, Any]: + return { + "status": self.status, + "cached": self.cached, + "active_connections": self.active_connections, + "max_connections": self.max_connections, + "failed_resources": list(self.failed_resources), + "checks": {name: check.as_dict() for name, check in self.checks.items()}, + } + + def build_stop_message(self) -> Dict[str, Any]: + return _build_resource_stop_message(self.failed_resources) + + +@dataclass(slots=True) +class ServicesHealthReport: + checks: Dict[str, Any] + healthy: bool + + @property + def status(self) -> str: + return "ok" if self.healthy else "fail" + + def is_healthy(self) -> bool: + return self.healthy + + def as_dict(self) -> Dict[str, Any]: + return { + "status": self.status, + "checks": self.checks, + } + + +def _service_health_payload(check: ResourceCheck) -> Dict[str, Any]: + fallback_status = 200 if check.is_healthy() else 500 + fallback_desc = "SUCCESS" if check.is_healthy() else "FAIL" + return { + "htp_cod_status": ( + check.http_status_code + if check.http_status_code is not None + else fallback_status + ), + "hhtp_cod_desc": check.http_status_desc or check.message or fallback_desc, + } + + +class CapacityLease: + def __init__(self, manager: "BridgeReadinessManager") -> None: + self._manager = manager + self._released = False + + async def release(self) -> None: + if self._released: + return + self._released = True + await self._manager._release_capacity() + + +@dataclass(slots=True) +class CapacityReservation: + allowed: bool + lease: Optional[CapacityLease] + active_connections: int + max_connections: int + + +class BridgeReadinessManager: + def __init__(self) -> None: + self._capacity_lock = asyncio.Lock() + self._active_connections = 0 + self._cache_lock = asyncio.Lock() + self._cache: Dict[str, tuple[float, ReadinessReport]] = {} + + def _max_connections(self) -> int: + return max(0, _env_int("TIA_WS_MAX_CONNECTIONS", 0)) + + def _ttl_s(self) -> float: + return max(0.0, _env_float("TIA_RESOURCE_HEALTH_TTL_S", 5.0)) + + def _timeout_s(self) -> float: + return max(0.1, _env_float("TIA_RESOURCE_HEALTH_TIMEOUT_S", 3.0)) + + async def reserve_capacity(self) -> CapacityReservation: + async with self._capacity_lock: + max_connections = self._max_connections() + if max_connections > 0 and self._active_connections >= max_connections: + return CapacityReservation( + allowed=False, + lease=None, + active_connections=self._active_connections, + max_connections=max_connections, + ) + + self._active_connections += 1 + return CapacityReservation( + allowed=True, + lease=CapacityLease(self), + active_connections=self._active_connections, + max_connections=max_connections, + ) + + async def _release_capacity(self) -> None: + async with self._capacity_lock: + if self._active_connections > 0: + self._active_connections -= 1 + + async def capacity_snapshot(self) -> tuple[int, int]: + async with self._capacity_lock: + return self._active_connections, self._max_connections() + + def _cache_key(self, *, agent_name: str, call_config: Mapping[str, Any] | None) -> str: + normalized = normalize_call_config(call_config) + backend_name = resolve_agent_backend_name(normalized, os.getenv("AGENT_BACKEND", "remote_ws")) + stt_provider = (resolve_stt_overrides(normalized).get("provider") or os.getenv("STT_PROVIDER", "internal_http")).strip().lower() + tts_provider = (resolve_tts_overrides(normalized).get("provider") or os.getenv("TTS_PROVIDER", "elevenlabs")).strip().lower() + return "|".join( + [ + _strip_text(agent_name).lower() or "conta", + _strip_text(backend_name).lower() or "remote_ws", + stt_provider or "internal_http", + tts_provider or "elevenlabs", + ] + ) + + async def _get_cached_report(self, key: str) -> Optional[ReadinessReport]: + ttl_s = self._ttl_s() + if ttl_s <= 0: + return None + + async with self._cache_lock: + cached = self._cache.get(key) + if cached is None: + return None + + stored_at, report = cached + if (time.monotonic() - stored_at) > ttl_s: + self._cache.pop(key, None) + return None + + active_connections, max_connections = await self.capacity_snapshot() + return report.clone( + active_connections=active_connections, + max_connections=max_connections, + cached=True, + ) + + async def _store_cached_report(self, key: str, report: ReadinessReport) -> None: + async with self._cache_lock: + self._cache[key] = ( + time.monotonic(), + report.clone(cached=False), + ) + + async def evaluate_resources( + self, + *, + agent_name: str, + call_config: Mapping[str, Any] | None, + ) -> ReadinessReport: + key = self._cache_key(agent_name=agent_name, call_config=call_config) + cached = await self._get_cached_report(key) + if cached is not None: + return cached + + normalized = normalize_call_config(call_config) + agent_runtime, agent_backend, stt, tts = await asyncio.gather( + self.probe_agent_runtime(), + self.probe_agent_backend( + agent_name=agent_name, + call_config=normalized, + ), + self.probe_stt(normalized), + self.probe_tts(normalized), + ) + checks = { + "agent_runtime": agent_runtime, + "agent_backend": agent_backend, + "stt": stt, + "tts": tts, + } + + failed_resources = [ + resource_name + for resource_name in STOP_RESOURCE_PRECEDENCE + if resource_name in checks and not checks[resource_name].is_healthy() + ] + + active_connections, max_connections = await self.capacity_snapshot() + report = ReadinessReport( + checks=checks, + failed_resources=failed_resources, + active_connections=active_connections, + max_connections=max_connections, + cached=False, + ) + await self._store_cached_report(key, report) + return report + + async def evaluate_default_resources(self) -> ReadinessReport: + return await self.evaluate_resources(agent_name="conta", call_config={}) + + async def evaluate_services(self) -> ServicesHealthReport: + ( + agent_runtime, + contas_backend, + oferta_backend, + sofya_stt, + xai_tts, + ) = await asyncio.gather( + self.probe_agent_runtime(), + self.probe_configured_agent_backend_service(agent_name="conta"), + self.probe_configured_agent_backend_service(agent_name="oferta"), + self.probe_configured_stt_service(), + self.probe_configured_xai_tts_service(), + ) + service_checks = [agent_runtime, contas_backend, oferta_backend, sofya_stt, xai_tts] + return ServicesHealthReport( + healthy=all(check.is_healthy() for check in service_checks), + checks={ + "agent_runtime": _service_health_payload(agent_runtime), + "agent_backend": { + "contas": _service_health_payload(contas_backend), + "oferta": _service_health_payload(oferta_backend), + }, + "stt": { + "sofya": _service_health_payload(sofya_stt), + }, + "tts": { + "xAI": _service_health_payload(xai_tts), + }, + }, + ) + + async def probe_configured_agent_backend_service(self, *, agent_name: str) -> ResourceCheck: + normalized_agent = { + "contas": "conta", + "ofertas": "oferta", + }.get(_strip_text(agent_name).lower(), _strip_text(agent_name).lower()) + + if normalized_agent == "conta": + health_url = _env_first( + "REMOTE_AGENT_HEALTH_URL_CONTA", + "REMOTE_AGENT_HEALTH_URL_CONTAS", + "REMOTE_AGENT_HEALTH_URL", + ) + if not health_url: + health_url = _derive_http_health_url( + _env_first("REMOTE_AGENT_SSE_URL_CONTA", "REMOTE_AGENT_SSE_URL_CONTAS") + ) + if not health_url: + health_url = _derive_websocket_health_url( + _env_first("REMOTE_AGENT_WS_URL_CONTA", "REMOTE_AGENT_WS_URL_CONTAS", "REMOTE_AGENT_WS_URL") + ) + elif normalized_agent == "oferta": + health_url = _env_first( + "REMOTE_AGENT_HEALTH_URL_OFERTA", + "REMOTE_AGENT_HEALTH_URL_OFERTAS", + ) + if not health_url: + health_url = _derive_http_health_url( + _env_first("REMOTE_AGENT_SSE_URL_OFERTA", "REMOTE_AGENT_SSE_URL_OFERTAS") + ) + if not health_url: + health_url = _derive_websocket_health_url( + _env_first("REMOTE_AGENT_WS_URL_OFERTA", "REMOTE_AGENT_WS_URL_OFERTAS") + ) + else: + return ResourceCheck(status="fail", message=f"unsupported service agent backend: {agent_name}") + + if not health_url: + return ResourceCheck( + status="fail", + message=f"{normalized_agent} backend health URL is not configured", + ) + + return await self._probe_http_health( + "agent_backend", + health_url, + verify=_remote_agent_tls_verify(normalized_agent), + ) + + async def probe_configured_stt_service(self) -> ResourceCheck: + health_url = _env_first("STT_HEALTH_URL") + if not health_url: + health_url = _derive_http_health_url(_env_first("STT_URL")) + + if not health_url: + return ResourceCheck(status="fail", message="STT health URL is not configured") + + return await self._probe_http_health("stt", health_url) + + async def probe_configured_xai_tts_service(self) -> ResourceCheck: + return await self.probe_tts({"tts": {"provider": "xai"}}) + + async def probe_agent_runtime(self) -> ResourceCheck: + url = f"http://127.0.0.1:{_env_int('AGENT_SERVER_PORT', 18081)}/" + return await self._probe_http_health("agent_runtime", url) + + async def probe_agent_backend( + self, + *, + agent_name: str, + call_config: Mapping[str, Any] | None, + ) -> ResourceCheck: + raw_agent = _strip_text(agent_name).lower() + normalized_agent = { + "contas": "conta", + "ofert": "oferta", + "ofertas": "oferta", + "cobra": "cobranca", + "cobrancas": "cobranca", + "cobrança": "cobranca", + "cobranças": "cobranca", + }.get(raw_agent, raw_agent or "conta") + + backend_name = resolve_agent_backend_name(call_config, os.getenv("AGENT_BACKEND", "remote_ws")).strip().lower() + if backend_name in {"remote_ws_fake", "fake_remote_ws", "ws_fake"}: + return ResourceCheck(status="ok", message="fake backend skips external health probe") + + if backend_name not in {"", "remote_ws", "ws", "websocket", "remote_sse", "sse"}: + return ResourceCheck(status="fail", message=f"unsupported agent backend: {backend_name or 'unknown'}") + + agent_health_envs = { + "conta": ("REMOTE_AGENT_HEALTH_URL_CONTA", "REMOTE_AGENT_HEALTH_URL_CONTAS"), + "oferta": ("REMOTE_AGENT_HEALTH_URL_OFERTA", "REMOTE_AGENT_HEALTH_URL_OFERTAS"), + "cobranca": ( + "REMOTE_AGENT_HEALTH_URL_COBRANCA", + "REMOTE_AGENT_HEALTH_URL_COBRANCAS", + "REMOTE_AGENT_HEALTH_URL_COBRA", + ), + }.get(normalized_agent, ()) + health_url = "" + for env_name in agent_health_envs: + health_url = _strip_text(os.getenv(env_name)) + if health_url: + break + + if normalized_agent == "oferta" and backend_name in {"remote_sse", "sse"} and not health_url: + return ResourceCheck( + status="skipped", + message="oferta backend health probe disabled until health URL is provided", + ) + + if not health_url: + health_url = _strip_text(os.getenv("REMOTE_AGENT_HEALTH_URL")) + + if not health_url: + if backend_name in {"remote_sse", "sse"}: + agent_sse_envs = { + "conta": ("REMOTE_AGENT_SSE_URL_CONTA", "REMOTE_AGENT_SSE_URL_CONTAS"), + "cobranca": ( + "REMOTE_AGENT_SSE_URL_COBRANCA", + "REMOTE_AGENT_SSE_URL_COBRANCAS", + "REMOTE_AGENT_SSE_URL_COBRA", + ), + }.get(normalized_agent, ()) + sse_url = "" + for env_name in agent_sse_envs: + sse_url = _strip_text(os.getenv(env_name)) + if sse_url: + break + health_url = _derive_http_health_url(sse_url) + else: + agent_ws_envs = { + "conta": ("REMOTE_AGENT_WS_URL_CONTA", "REMOTE_AGENT_WS_URL_CONTAS"), + "oferta": ("REMOTE_AGENT_WS_URL_OFERTA", "REMOTE_AGENT_WS_URL_OFERTAS"), + "cobranca": ( + "REMOTE_AGENT_WS_URL_COBRANCA", + "REMOTE_AGENT_WS_URL_COBRANCAS", + "REMOTE_AGENT_WS_URL_COBRA", + ), + }.get(normalized_agent, ()) + ws_url = "" + for env_name in (*agent_ws_envs, "REMOTE_AGENT_WS_URL"): + ws_url = _strip_text(os.getenv(env_name)) + if ws_url: + break + health_url = _derive_websocket_health_url(ws_url) + + if not health_url: + return ResourceCheck(status="fail", message="remote agent health URL is not configured") + + return await self._probe_http_health("agent_backend", health_url) + + async def probe_stt(self, call_config: Mapping[str, Any] | None) -> ResourceCheck: + if _env_bool("TIA_SKIP_STT_READINESS", False): + return ResourceCheck(status="skipped", message="STT readiness disabled by env") + + overrides = resolve_stt_overrides(call_config) + provider = (overrides.get("provider") or os.getenv("STT_PROVIDER", "internal_http")).strip().lower() + + if provider == "fake": + FakeSTT() + return ResourceCheck(status="ok", message="fake STT skips external health probe") + + if provider not in {"", "internal_http"}: + return ResourceCheck(status="fail", message=f"unsupported STT provider: {provider}") + + health_url = _strip_text(os.getenv("STT_HEALTH_URL")) + if not health_url: + health_url = _derive_http_health_url(os.getenv("STT_URL", "")) + + if not health_url: + return ResourceCheck(status="fail", message="STT health URL is not configured") + + return await self._probe_http_health("stt", health_url) + + async def probe_tts(self, call_config: Mapping[str, Any] | None) -> ResourceCheck: + overrides = resolve_tts_overrides(call_config) + provider = (overrides.get("provider") or os.getenv("TTS_PROVIDER", "elevenlabs")).strip().lower() + timeout_s = self._timeout_s() + started_at = time.monotonic() + + try: + if provider == "fake": + tts = FakeProviderTTS() + audio = tts.synthesize_pcm16k(PROBE_TEXT) + self._ensure_audio_bytes(audio) + return ResourceCheck(status="ok", latency_ms=round((time.monotonic() - started_at) * 1000)) + + if provider in {"", "elevenlabs"}: + from livekit.plugins import elevenlabs + + async with aiohttp.ClientSession() as http_session: + tts = elevenlabs.TTS( + model=overrides.get("model_id") or os.getenv("ELEVENLABS_MODEL_ID", ""), + voice_id=overrides.get("voice_id") or os.getenv("ELEVENLABS_VOICE_ID", ""), + api_key=os.getenv("ELEVENLABS_API_KEY", ""), + voice_settings=elevenlabs.VoiceSettings( + stability=0.35, + speed=1.02, + similarity_boost=0.75, + use_speaker_boost=True, + style=0.7, + ), + http_session=http_session, + language="pt", + ) + try: + audio = await asyncio.wait_for(self._collect_livekit_tts_bytes(tts), timeout=timeout_s) + finally: + await tts.aclose() + self._ensure_audio_bytes(audio) + return ResourceCheck(status="ok", latency_ms=round((time.monotonic() - started_at) * 1000)) + + if provider == "xai": + auth_method = _strip_text(os.getenv("XAI_TTS_AUTH_METHOD", "API_KEY")).upper() or "API_KEY" + xai_auth_options: dict[str, str] = {"auth_method": auth_method} + if auth_method == "API_KEY": + api_key = _strip_text(os.getenv("XAI_API_KEY")) + if not api_key: + return ResourceCheck(status="fail", message="missing xAI TTS config: XAI_API_KEY") + xai_auth_options["api_key"] = api_key + readiness_mode = ( + _strip_text(os.getenv(XAI_TTS_READINESS_MODE_ENV, "synthesize")).lower() + or "synthesize" + ) + if readiness_mode not in XAI_TTS_READINESS_MODES: + return ResourceCheck( + status="fail", + message=f"unsupported {XAI_TTS_READINESS_MODE_ENV}: {readiness_mode}", + ) + + async with aiohttp.ClientSession() as http_session: + tts = OraclexAITTS( + voice=overrides.get("voice_id") or os.getenv("XAI_TTS_VOICE", ""), + language=overrides.get("language") or os.getenv("XAI_TTS_LANGUAGE", "pt-BR"), + http_session=http_session, + **xai_auth_options, + ) + try: + if readiness_mode == "connect": + await tts.connect(timeout_s) + return ResourceCheck( + status="ok", + message="xAI TTS websocket connected", + latency_ms=round((time.monotonic() - started_at) * 1000), + ) + audio = await asyncio.wait_for(self._collect_livekit_tts_bytes(tts), timeout=timeout_s) + finally: + await tts.aclose() + self._ensure_audio_bytes(audio) + return ResourceCheck(status="ok", latency_ms=round((time.monotonic() - started_at) * 1000)) + + if provider == "azure": + azure_tts_config, missing_keys = resolve_azure_speech_tts_config(overrides) + if missing_keys: + return ResourceCheck( + status="fail", + message=f"missing Azure TTS config: {', '.join(missing_keys)}", + ) + + azure_impl = (os.getenv("AZURE_TTS_IMPLEMENTATION", "plugin") or "plugin").strip().lower() + if azure_impl == "rest": + tts = AzureRESTTTS( + sample_rate=AZURE_SPEECH_TTS_SAMPLE_RATE, + **azure_tts_config, + ) + try: + audio = await asyncio.wait_for(asyncio.to_thread(tts.synthesize_pcm, PROBE_TEXT), timeout=timeout_s) + finally: + await tts.aclose() + elif azure_impl == "plugin": + from livekit.plugins import azure as livekit_azure + + async with aiohttp.ClientSession() as http_session: + tts = livekit_azure.TTS( + sample_rate=AZURE_SPEECH_TTS_SAMPLE_RATE, + http_session=http_session, + **azure_tts_config, + ) + try: + audio = await asyncio.wait_for(self._collect_livekit_tts_bytes(tts), timeout=timeout_s) + finally: + await tts.aclose() + else: + return ResourceCheck(status="fail", message=f"unsupported AZURE_TTS_IMPLEMENTATION: {azure_impl}") + + self._ensure_audio_bytes(audio) + return ResourceCheck(status="ok", latency_ms=round((time.monotonic() - started_at) * 1000)) + + return ResourceCheck(status="fail", message=f"unsupported TTS provider: {provider}") + except Exception as exc: + return ResourceCheck( + status="fail", + message=_format_probe_error(exc, resource=f"{provider or 'default'} TTS", timeout_s=timeout_s), + latency_ms=round((time.monotonic() - started_at) * 1000), + ) + + async def _collect_livekit_tts_bytes(self, tts: Any) -> bytes: + frame = await tts.synthesize(PROBE_TEXT).collect() + return bytes(getattr(frame, "data", b"")) + + def _ensure_audio_bytes(self, audio: bytes) -> None: + if not audio: + raise RuntimeError("TTS returned empty audio") + + async def _probe_http_health(self, check_name: str, url: str, *, verify: bool = True) -> ResourceCheck: + started_at = time.monotonic() + timeout_s = self._timeout_s() + try: + async with httpx.AsyncClient(follow_redirects=True, verify=verify) as client: + response = await client.get(url, timeout=timeout_s) + latency_ms = round((time.monotonic() - started_at) * 1000) + http_status_desc = _health_response_desc(response) + if 200 <= response.status_code < 300: + return ResourceCheck( + status="ok", + latency_ms=latency_ms, + url=url, + http_status_code=response.status_code, + http_status_desc=http_status_desc, + ) + return ResourceCheck( + status="fail", + message=( + f"{check_name} returned status {response.status_code}: {http_status_desc}" + if http_status_desc + else f"{check_name} returned status {response.status_code}" + ), + latency_ms=latency_ms, + url=url, + http_status_code=response.status_code, + http_status_desc=http_status_desc, + ) + except Exception as exc: + return ResourceCheck( + status="fail", + message=_format_probe_error(exc, resource=check_name, timeout_s=timeout_s), + latency_ms=round((time.monotonic() - started_at) * 1000), + url=url, + ) diff --git a/src/app/ws_gateway/session_audio.py b/src/app/ws_gateway/session_audio.py new file mode 100644 index 0000000..2c78874 --- /dev/null +++ b/src/app/ws_gateway/session_audio.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import time +from typing import Any, Awaitable, Callable + +from app.utils.call_timeline import CallTimeline +from app.ws_gateway.readiness import build_completed_stop_message +from app.ws_gateway.session_lifecycle import RoomLifecycleState, close_websocket + + +async def enable_client_audio( + *, + logger: logging.Logger, + timeline: CallTimeline, + client_audio_enabled: asyncio.Event, + room_name: str, + protocol: str, +) -> None: + if client_audio_enabled.is_set(): + return + + client_audio_enabled.set() + logger.info( + "CLIENT_AUDIO_ENABLED | room=%s | protocol=%s", + room_name, + protocol, + ) + timeline.emit("client_audio_enabled") + + +async def notify_client_audio_enabled( + *, + room: Any, + logger: logging.Logger, + timeline: CallTimeline, + room_name: str, + protocol: str, + track_published: asyncio.Event, + agent_ready: asyncio.Event, + lifecycle: RoomLifecycleState, + repeat: bool = False, +) -> None: + await track_published.wait() + await agent_ready.wait() + + last_generation = -1 + while True: + await lifecycle.agent_connected.wait() + generation = lifecycle.agent_generation + if repeat and generation == last_generation: + await asyncio.sleep(0.05) + continue + + if lifecycle.agent_participant is None: + if not repeat: + return + await asyncio.sleep(0.05) + continue + + agent_ident = str(getattr(lifecycle.agent_participant, "identity", "") or "") + if not agent_ident: + if not repeat: + return + await asyncio.sleep(0.05) + continue + + payload = { + "type": "client_audio_enabled", + "room": room_name, + "protocol": protocol, + } + try: + await room.local_participant.publish_data( + json.dumps(payload, ensure_ascii=False), + reliable=True, + destination_identities=[agent_ident], + topic="bridge.control", + ) + logger.info("BRIDGE_CONTROL | sent client_audio_enabled | agent_identity=%s", agent_ident) + timeline.emit( + "bridge_control_sent", + control_type="client_audio_enabled", + agent_identity=agent_ident, + ) + last_generation = generation + except Exception: + logger.debug("BRIDGE_CONTROL send failed", exc_info=True) + + if not repeat: + return + + while lifecycle.agent_generation == generation and lifecycle.agent_connected.is_set(): + await asyncio.sleep(0.05) + + +async def stream_agent_audio_when_ready( + *, + agent_ready: asyncio.Event, + lifecycle: RoomLifecycleState, + agent_q: asyncio.Queue[bytes], + activity: "AudioActivity", + timeline: CallTimeline | None, + stream_agent_audio: Callable[..., Awaitable[None]], + repeat: bool = False, +) -> None: + await agent_ready.wait() + + while True: + await lifecycle.agent_connected.wait() + participant = lifecycle.agent_participant + generation = lifecycle.agent_generation + if participant is None: + if not repeat: + return + await asyncio.sleep(0.05) + continue + + await stream_agent_audio( + agent_q, + participant, + activity, + timeline=timeline, + ) + + if not repeat: + return + + while ( + lifecycle.agent_generation == generation + and lifecycle.agent_participant is participant + and lifecycle.agent_connected.is_set() + ): + await asyncio.sleep(0.05) + + +async def watch_mock_stop_after_first_audio( + *, + ws: Any, + logger: logging.Logger, + timeline: CallTimeline | None, + room_name: str, + protocol: str, + first_agent_audio_sent: asyncio.Event, + activity: "AudioActivity", + agent_q: asyncio.Queue[bytes], + silence_s: float, + reason: str, +) -> None: + await first_agent_audio_sent.wait() + + silence_s = max(0.0, float(silence_s)) + reason = str(reason or "").strip() or "DONE" + logger.info( + "MOCK_STOP_AFTER_FIRST_AUDIO_ARMED | room=%s | protocol=%s | silence_s=%.3f | reason=%s", + room_name, + protocol, + silence_s, + reason, + ) + if timeline is not None: + timeline.emit( + "mock_stop_after_first_audio_armed", + silence_ms=round(silence_s * 1000), + reason=reason, + ) + + poll_interval_s = min(0.1, max(0.02, silence_s / 4 if silence_s > 0 else 0.02)) + while True: + idle_for = time.monotonic() - activity.last_agent_out + if agent_q.empty() and idle_for >= silence_s: + break + await asyncio.sleep(poll_interval_s) + + stop_message = build_completed_stop_message(reason) + try: + await ws.send_text(json.dumps(stop_message, ensure_ascii=False)) + logger.info( + "MOCK_STOP_AFTER_FIRST_AUDIO_SENT | room=%s | protocol=%s | reason=%s", + room_name, + protocol, + reason, + ) + if timeline is not None: + timeline.emit("mock_stop_after_first_audio_sent", reason=reason) + except Exception: + logger.debug("MOCK_STOP_AFTER_FIRST_AUDIO send failed", exc_info=True) + return + + try: + await close_websocket(ws, code=1000, reason=f"mock_stop:{reason}") + except Exception: + logger.debug("MOCK_STOP_AFTER_FIRST_AUDIO close failed", exc_info=True) diff --git a/src/app/ws_gateway/session_bootstrap.py b/src/app/ws_gateway/session_bootstrap.py new file mode 100644 index 0000000..439b449 --- /dev/null +++ b/src/app/ws_gateway/session_bootstrap.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import logging +import time +import uuid +from dataclasses import dataclass +from typing import Any, Callable, Dict, Mapping + +from app.services.session_context import extract_protocol +from app.utils.call_timeline import CallTimeline +from app.utils.logging import structured_context_from_start_data +from app.ws_gateway.call_config import build_call_config +from app.ws_gateway.session_start import ( + StartSessionContext, + build_remote_agent_context, + should_agent_start_conversation, +) + + +@dataclass(frozen=True, slots=True) +class BridgeSessionBootstrap: + call_id_ged: str + room_name: str + identity: str + token: str + protocol: str + phone_number: str + call_config: Dict[str, Any] + remote_agent_context: Dict[str, Any] + dispatch_metadata: Dict[str, Any] + timeline: CallTimeline + + +def _pick_value(payload: Mapping[str, Any] | None, *keys: str) -> str: + if not isinstance(payload, Mapping): + return "" + + for key in keys: + value = payload.get(key) + if value not in (None, ""): + return str(value).strip() + + lowered = {str(key).lower(): value for key, value in payload.items()} + for key in keys: + value = lowered.get(str(key).lower()) + if value not in (None, ""): + return str(value).strip() + + return "" + + +def build_bridge_session_bootstrap( + *, + start_ctx: StartSessionContext, + app_env: str, + livekit_room: str, + default_protocol: str, + token_factory: Callable[[str, str], str], + logger: logging.Logger, + now_fn: Callable[[], float] = time.time, + uuid_factory: Callable[[], uuid.UUID] = uuid.uuid4, + timeline_origin_unix_ms: int | None = None, +) -> BridgeSessionBootstrap: + call_id_ged = str(start_ctx.data.get("callIdGed") or "")[:64] + call_config = build_call_config(start_ctx.call_config) + remote_agent_context = build_remote_agent_context( + data=start_ctx.data, + agent_data=start_ctx.agent_data, + ) + + base_room = (livekit_room or f"{app_env}-room").strip() + room_name = f"{base_room}-{uuid_factory().hex[:8]}" + identity = f"ws-bridge-{app_env}-{uuid_factory().hex[:8]}" + token = token_factory(identity, room_name) + + protocol = ( + extract_protocol(start_ctx.payload, start_ctx.session_data) + or (default_protocol or "").strip() + or f"WS-{call_id_ged or 'call'}-{int(now_fn())}" + ) + session_id = ( + _pick_value(start_ctx.data, "session_id", "sessionId") + or _pick_value(start_ctx.agent_data, "session_id", "sessionId") + or room_name + ) + phone_number = str( + start_ctx.session_data.get("gsm") + or start_ctx.session_data.get("msisdn") + or start_ctx.session_data.get("phone") + or remote_agent_context.get("GSM") + or "" + ).strip() + structured_context = structured_context_from_start_data(start_ctx.data) + + timeline = CallTimeline( + logger=logger, + component="bridge", + timeline_id=room_name, + protocol=protocol, + room=room_name, + session_id=session_id, + phone_number=phone_number, + origin_unix_ms=timeline_origin_unix_ms, + ) + + dispatch_metadata = { + "env": app_env, + "from": "ws-bridge", + "call_id_ged": call_id_ged, + "protocol": protocol, + "session_id": session_id, + "elegibility": True, + "session_data": start_ctx.session_data, + "intro": start_ctx.intro, + "nudge": start_ctx.nudge, + "agent_starts_conversation": bool( + start_ctx.agent_starts_conversation + or should_agent_start_conversation(start_ctx.data.get("agent")) + ), + "bridge_identity": identity, + "call_config": call_config, + "remote_agent": remote_agent_context, + "timeline_id": room_name, + "timeline_origin_ms": timeline.origin_unix_ms, + "structured_log_context": structured_context.as_metadata(), + "debug_events_enabled": start_ctx.debug_events_enabled, + } + + return BridgeSessionBootstrap( + call_id_ged=call_id_ged, + room_name=room_name, + identity=identity, + token=token, + protocol=protocol, + phone_number=phone_number, + call_config=call_config, + remote_agent_context=remote_agent_context, + dispatch_metadata=dispatch_metadata, + timeline=timeline, + ) diff --git a/src/app/ws_gateway/session_lifecycle.py b/src/app/ws_gateway/session_lifecycle.py new file mode 100644 index 0000000..5917e81 --- /dev/null +++ b/src/app/ws_gateway/session_lifecycle.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, Dict, Optional + +from app.utils.call_timeline import CallTimeline +from app.ws_gateway.readiness import build_completed_stop_message, build_stop_message + + +@dataclass(slots=True) +class RoomLifecycleState: + agent_participant: Optional[Any] = None + done_payload: Dict[str, Any] = field(default_factory=dict) + agent_connected: asyncio.Event = field(default_factory=asyncio.Event) + agent_generation: int = 0 + debug_forward_tasks: set[asyncio.Task[Any]] = field(default_factory=set) + + def __post_init__(self) -> None: + if self.agent_participant is not None: + self.agent_connected.set() + if self.agent_generation <= 0: + self.agent_generation = 1 + + +def _close_reason(reason: str, *, limit_bytes: int = 120) -> str: + normalized = " ".join(str(reason or "").strip().split()) + if not normalized: + return "" + + encoded = normalized.encode("utf-8") + if len(encoded) <= limit_bytes: + return normalized + return encoded[:limit_bytes].decode("utf-8", "ignore") + + +async def close_websocket(ws: Any, *, code: int = 1000, reason: str = "") -> None: + close_reason = _close_reason(reason) + try: + await ws.close(code=code, reason=close_reason) + except TypeError: + await ws.close() + + +def register_room_lifecycle_handlers( + *, + room: Any, + logger: logging.Logger, + timeline: CallTimeline, + room_name: str, + protocol: str, + dispatch_started_at: float, + agent_ready: asyncio.Event, + call_done: asyncio.Event, + state: RoomLifecycleState, + is_agent: Callable[[Any], bool], + on_agent_disconnect: Callable[[str, int], Awaitable[None]] | None = None, + ws: Any | None = None, + debug_events_enabled: bool = False, + monotonic_fn: Callable[[], float] = time.monotonic, +) -> None: + @room.on("data_received") + def _on_data_received(data_packet: Any) -> None: + try: + topic = getattr(data_packet, "topic", "") + if topic == "agent.debug": + if not debug_events_enabled or ws is None: + return + raw = data_packet.data + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8", "ignore") + message = json.loads(raw) + message["stress_test"] = True + + async def _forward_debug_event() -> None: + try: + await ws.send_text(json.dumps(message, ensure_ascii=False)) + except Exception: + logger.debug("debug event forwarding failed", exc_info=True) + + task = asyncio.create_task( + _forward_debug_event(), name="forward_agent_debug_event" + ) + state.debug_forward_tasks.add(task) + task.add_done_callback(state.debug_forward_tasks.discard) + return + if topic != "agent.stage": + return + + raw = data_packet.data + if isinstance(raw, (bytes, bytearray)): + raw = raw.decode("utf-8", "ignore") + + message = json.loads(raw) + if message.get("stage") == "DONE": + state.done_payload = message + timeline.emit( + "done_packet_received", + reason=message.get("reason", ""), + payload=message, + ) + call_done.set() + except Exception: + logger.exception("DATA_PACKET_FAIL | topic=%s", getattr(data_packet, "topic", "")) + + @room.on("participant_connected") + def _on_participant_connected(participant: Any) -> None: + try: + identity = str(getattr(participant, "identity", "") or "") + if is_agent(participant): + state.agent_participant = participant + state.agent_generation += 1 + state.agent_connected.set() + agent_ready.set() + logger.info( + "LK_AGENT_JOIN | room=%s | agent_identity=%s | dt_s=%.2f", + room_name, + identity or "-", + monotonic_fn() - dispatch_started_at, + ) + timeline.emit( + "agent_join", + agent_identity=identity or "", + dispatch_dt_ms=round((monotonic_fn() - dispatch_started_at) * 1000), + ) + else: + logger.debug("LK_PARTICIPANT_JOIN | room=%s | identity=%s", room_name, identity or "-") + except Exception: + logger.debug("participant_connected handler failed", exc_info=True) + + @room.on("participant_disconnected") + def _on_participant_disconnected(participant: Any) -> None: + try: + identity = str(getattr(participant, "identity", "") or "") + if is_agent(participant): + current_identity = str(getattr(state.agent_participant, "identity", "") or "") + cleared_current_agent = False + if state.agent_participant is participant or current_identity == identity: + state.agent_participant = None + state.agent_generation += 1 + state.agent_connected.clear() + cleared_current_agent = True + logger.info("LK_AGENT_LEAVE | room=%s | agent_identity=%s", room_name, identity or "-") + timeline.emit("agent_leave", agent_identity=identity or "") + if cleared_current_agent and not call_done.is_set() and on_agent_disconnect is not None: + generation = state.agent_generation + + async def _notify_disconnect() -> None: + try: + await on_agent_disconnect(identity, generation) + except Exception: + logger.exception( + "AGENT_DISCONNECT_HANDLER_FAIL | room=%s | protocol=%s | agent_identity=%s", + room_name, + protocol, + identity or "-", + ) + + asyncio.create_task(_notify_disconnect(), name="agent_disconnect_handler") + else: + logger.debug("LK_PARTICIPANT_LEAVE | room=%s | identity=%s", room_name, identity or "-") + except Exception: + logger.debug("participant_disconnected handler failed", exc_info=True) + + +async def watch_call_done( + *, + call_done: asyncio.Event, + state: RoomLifecycleState, + ws: Any, + logger: logging.Logger, + room_name: str, + protocol: str, + timeline: CallTimeline, +) -> None: + await call_done.wait() + + payload = state.done_payload or {} + reason = str(payload.get("reason") or "DONE") + logger.info("CALL_DONE | room=%s | protocol=%s | reason=%s", room_name, protocol, reason) + timeline.emit("call_done", reason=reason) + + if state.debug_forward_tasks: + await asyncio.gather(*tuple(state.debug_forward_tasks), return_exceptions=True) + + stop_status = str(payload.get("status") or "").strip() + if stop_status: + stop_message = build_stop_message( + status=stop_status, + reason=reason, + resource=str(payload.get("resource") or "").strip(), + failed_resources=list(payload.get("failed_resources") or []), + phase=str(payload.get("phase") or "in_session").strip(), + ) + else: + stop_message = build_completed_stop_message(reason) + + try: + await ws.send_text(json.dumps(stop_message, ensure_ascii=False)) + timeline.emit("stop_sent", reason=reason) + except Exception: + pass + + try: + await close_websocket(ws, code=1000, reason=f"call_done:{reason}") + except Exception: + pass diff --git a/src/app/ws_gateway/session_start.py b/src/app/ws_gateway/session_start.py new file mode 100644 index 0000000..e087cd0 --- /dev/null +++ b/src/app/ws_gateway/session_start.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import Any, Dict, Mapping + +from fastapi import WebSocket + +from app.common.call_config import is_fake_agent_stress_test, resolve_fake_agent_overrides + + +@dataclass(frozen=True, slots=True) +class StartSessionContext: + payload: Dict[str, Any] + data: Dict[str, Any] + agent_data: Dict[str, Any] + audio_format: Dict[str, Any] + call_config: Dict[str, Any] + session_data: Dict[str, Any] + intro: str + nudge: str + agent_starts_conversation: bool = False + stress_test: bool = False + debug_events_enabled: bool = False + + +def _pick_value(payload: Mapping[str, Any], *keys: str) -> str: + for key in keys: + value = payload.get(key) + if value not in (None, ""): + return str(value).strip() + + lowered = {str(key).lower(): value for key, value in payload.items()} + for key in keys: + value = lowered.get(str(key).lower()) + if value not in (None, ""): + return str(value).strip() + + return "" + + +def _normalize_agent_name(value: Any) -> str: + raw = str(value or "").strip().lower() + aliases = { + "conta": "conta", + "contas": "conta", + "ofert": "oferta", + "oferta": "oferta", + "ofertas": "oferta", + "cobra": "cobranca", + "cobranca": "cobranca", + "cobrancas": "cobranca", + } + return aliases.get(raw, raw) + + +def should_agent_start_conversation(agent_name: str) -> bool: + # O backend remoto é responsável pela saudação inicial em todos os agentes. + del agent_name + return True + + +def _mapping_or_error(payload: Mapping[str, Any], key: str) -> Dict[str, Any]: + value = payload.get(key) + if not isinstance(value, Mapping): + raise RuntimeError(f"Campo obrigatório inválido: {key}") + return dict(value) + + +def _optional_mapping(payload: Mapping[str, Any], key: str) -> Dict[str, Any]: + value = payload.get(key) + if value in (None, ""): + return {} + if not isinstance(value, Mapping): + raise RuntimeError(f"Campo opcional inválido: {key}") + return dict(value) + + +def _required_value(payload: Mapping[str, Any], *keys: str) -> str: + value = _pick_value(payload, *keys) + if not value: + joined = ", ".join(keys) + raise RuntimeError(f"Campo obrigatório ausente: {joined}") + return value + + +def parse_start_payload(payload: Mapping[str, Any]) -> StartSessionContext: + if payload.get("type") != "start": + raise RuntimeError( + f"Primeira mensagem esperada type='start', veio: {payload.get('type')}" + ) + + start_payload = dict(payload) + data = _mapping_or_error(start_payload, "data") + agent_data = _optional_mapping(data, "agentData") + audio_format = _optional_mapping(start_payload, "audioFormat") + call_config = _optional_mapping(start_payload, "callConfig") + try: + resolve_fake_agent_overrides(call_config) + stress_test = is_fake_agent_stress_test(call_config) + except ValueError as exc: + raise RuntimeError(f"callConfig invalido: {exc}") from exc + # Structured diagnostics may contain high-volume, implementation-specific + # data. Forward them to the WS client only for an explicitly scripted fake + # agent call; client-provided debug flags alone must not enable forwarding. + debug_events_enabled = stress_test + + data["agent"] = _normalize_agent_name(_required_value(data, "agent")) + _required_value(data, "ani") + _required_value(data, "gsm") + _required_value(data, "routerCallKeyDay") + _required_value(data, "routerCallKey") + _required_value(data, "callIdGed") + session_id = _required_value(data, "session_id", "sessionId") + data["session_id"] = session_id + + if data["agent"] == "conta": + _required_value( + agent_data, + "idFatura", + "current_invoice_number", + "currentInvoiceNumber", + ) + elif data["agent"] == "oferta": + data["protocolo"] = _required_value( + data, + "protocolo", + "protocolNumber", + "protocol", + ) + + session_data = dict(data) + if agent_data: + session_data["agentData"] = dict(agent_data) + if audio_format: + session_data["audioFormat"] = dict(audio_format) + if session_data.get("gsm"): + session_data["msisdn"] = str(session_data["gsm"]).strip() + session_data["phone"] = str(session_data["gsm"]).strip() + + start_payload["data"] = dict(data) + agent_starts_conversation = should_agent_start_conversation(data["agent"]) + intro = "" + nudge = "Alô, você ainda está aí?" + + return StartSessionContext( + payload=start_payload, + data=data, + agent_data=agent_data, + audio_format=audio_format, + call_config=call_config, + session_data=session_data, + intro=intro, + nudge=nudge, + agent_starts_conversation=agent_starts_conversation, + stress_test=stress_test, + debug_events_enabled=debug_events_enabled, + ) + + +def parse_transferencia_session_id_payload(payload: Mapping[str, Any]) -> str: + if payload.get("type") != "transferencia_session_id": + raise RuntimeError( + "Mensagem esperada type='transferencia_session_id', " + f"veio: {payload.get('type')}" + ) + + data = _mapping_or_error(payload, "data") + return _required_value(data, "session_id", "sessionId") + + +def _json_for_log(payload: Any) -> str: + try: + return json.dumps(payload, ensure_ascii=False, indent=2, default=str) + except TypeError: + return str(payload) + + +def _redact_fake_responses(payload: Mapping[str, Any]) -> Dict[str, Any]: + sanitized = dict(payload) + call_config = sanitized.get("callConfig") + if not isinstance(call_config, Mapping): + return sanitized + safe_call_config = dict(call_config) + agent_fake = safe_call_config.get("agentFake") + if isinstance(agent_fake, Mapping): + safe_agent_fake = dict(agent_fake) + raw = safe_agent_fake.pop("responses", None) + if raw not in (None, ""): + lengths = [len(item.strip()) for item in str(raw).split(";")] + safe_agent_fake["responseCount"] = len(lengths) + safe_agent_fake["responseLengths"] = lengths + safe_call_config["agentFake"] = safe_agent_fake + sanitized["callConfig"] = safe_call_config + return sanitized + + +def _log_start_request(logger: logging.Logger | None, payload: Mapping[str, Any]) -> None: + if logger is None: + return + + logger.info( + "WS_START_REQUEST\npayload=%s\ncall_config=%s", + _json_for_log(_redact_fake_responses(payload)), + _json_for_log(_redact_fake_responses(payload).get("callConfig") or {}), + ) + + +async def recv_start_message( + ws: WebSocket, + logger: logging.Logger | None = None, +) -> StartSessionContext: + pending_session_id = "" + + while True: + raw_text = await ws.receive_text() + payload = json.loads(raw_text) + + if payload.get("type") == "transferencia_session_id": + pending_session_id = parse_transferencia_session_id_payload(payload) + continue + + if payload.get("type") == "start" and pending_session_id: + start_payload = dict(payload) + raw_data = start_payload.get("data") + if isinstance(raw_data, Mapping): + data = dict(raw_data) + if not _pick_value(data, "session_id", "sessionId"): + data["session_id"] = pending_session_id + start_payload["data"] = data + payload = start_payload + + _log_start_request(logger, payload) + return parse_start_payload(payload) + + + +def build_remote_agent_context( + *, + data: Mapping[str, Any], + agent_data: Mapping[str, Any], +) -> Dict[str, Any]: + agent_name = _normalize_agent_name(_pick_value(data, "agent")) + + context = { + "agent": agent_name, + "RouterCallKeyDay": _pick_value(data, "routerCallKeyDay"), + "RouterCallKey": _pick_value(data, "routerCallKey"), + "ANI": _pick_value(data, "ani"), + "GSM": _pick_value(data, "gsm"), + "msisdn": _pick_value(data, "gsm"), + "callIdGed": _pick_value(data, "callIdGed"), + } + + explicit_session_id = ( + _pick_value(data, "session_id", "sessionId") + or _pick_value(agent_data, "session_id", "sessionId") + ) + if explicit_session_id: + context["session_id"] = explicit_session_id + context["sessionId"] = explicit_session_id + + explicit_message_id = ( + _pick_value(data, "message_id", "messageId") + or _pick_value(agent_data, "message_id", "messageId") + ) + if explicit_message_id: + context["message_id"] = explicit_message_id + + explicit_protocol_id = ( + _pick_value(data, "protocol_id", "protocolId") + or _pick_value(agent_data, "protocol_id", "protocolId") + ) + protocol_number = ( + explicit_protocol_id + or _pick_value(data, "protocolo", "protocolNumber", "protocol") + or _pick_value(agent_data, "protocolo", "protocolNumber", "protocol") + ) + if protocol_number: + context["protocolo"] = protocol_number + context["protocolNumber"] = protocol_number + if explicit_protocol_id: + context["protocol_id"] = explicit_protocol_id + + channel_id = ( + _pick_value(data, "channelId", "channel_id") + or _pick_value(agent_data, "channelId", "channel_id") + ) + if channel_id: + context["channelId"] = channel_id + + asset_id = ( + _pick_value(data, "assetId", "asset_id") + or _pick_value(agent_data, "assetId", "asset_id") + ) + if asset_id: + context["assetId"] = asset_id + + if agent_name == "conta": + invoice_number = ( + _pick_value( + agent_data, + "current_invoice_number", + "currentInvoiceNumber", + "idFatura", + ) + or _pick_value( + data, + "current_invoice_number", + "currentInvoiceNumber", + "idFatura", + ) + ) + if invoice_number: + context["ID_FATURA"] = invoice_number + context["current_invoice_number"] = invoice_number + + channel = ( + _pick_value(agent_data, "channel", "Channel", "canal") + or _pick_value(data, "channel", "Channel", "canal") + ) + if channel: + context["channel"] = channel + + return context diff --git a/src/app/ws_gateway/voice_client.html b/src/app/ws_gateway/voice_client.html new file mode 100644 index 0000000..55dfaee --- /dev/null +++ b/src/app/ws_gateway/voice_client.html @@ -0,0 +1,1750 @@ + + + + + + TIM TIA Voice Client + + + +
+
+
+
+ +
+
TIM | TIA Voice Console
+
Imagine as possibilidades
+
+
+

Client

+

+ Console de homologação para capturar o microfone, enviar PCM16 ao bridge em + /ws/agent, acompanhar os eventos da sessão e reproduzir o áudio de resposta no + navegador com uma apresentação alinhada à marca TIM. +

+
+
+ +
+
+
+
+

Configuração da Chamada

+

+ Dados de sessão, roteamento e providers usados na homologação da jornada de voz. +

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +

+ Os campos abaixo mudam conforme o agente remoto selecionado. +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ Observacoes +

+ O contrato de `start` usa `type`, `data`, `audioFormat` e `callConfig`. Para homologacao local sem + agent remoto de pe, use `remote_ws_fake` com `fake` em STT e TTS. Quando o agent de contas estiver + publicado via SSE, selecione `remote_sse`. Os campos adicionais aparecem conforme o agent remoto. +

+
+
+ +
+
+
+

Controle e Telemetria

+

+ Disparo da chamada, status em tempo real e eventos trocados com o bridge. +

+
+
+
+ + + +
+
+
idle
+
mic off
+
audio off
+
+
+
+ Sua voz + +
microfone em espera
+
+
+ Resposta + +
saida em espera
+
+
+ +

Log da Sessão

+
+
+
+
+ + + + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/adapters/__init__.py b/tests/adapters/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/tests/adapters/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/adapters/test_audio_gain.py b/tests/adapters/test_audio_gain.py new file mode 100644 index 0000000..27256f9 --- /dev/null +++ b/tests/adapters/test_audio_gain.py @@ -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=" 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 diff --git a/tests/adapters/test_azure_rest_tts.py b/tests/adapters/test_azure_rest_tts.py new file mode 100644 index 0000000..a97e94a --- /dev/null +++ b/tests/adapters/test_azure_rest_tts.py @@ -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() diff --git a/tests/adapters/test_fake_tts.py b/tests/adapters/test_fake_tts.py new file mode 100644 index 0000000..7c3cb1e --- /dev/null +++ b/tests/adapters/test_fake_tts.py @@ -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() diff --git a/tests/adapters/test_xai_tts.py b/tests/adapters/test_xai_tts.py new file mode 100644 index 0000000..e7ddf4e --- /dev/null +++ b/tests/adapters/test_xai_tts.py @@ -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() diff --git a/tests/config/__init__.py b/tests/config/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/config/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/config/test_azure_speech.py b/tests/config/test_azure_speech.py new file mode 100644 index 0000000..e080cc5 --- /dev/null +++ b/tests/config/test_azure_speech.py @@ -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() diff --git a/tests/config/test_call_config.py b/tests/config/test_call_config.py new file mode 100644 index 0000000..3208ebb --- /dev/null +++ b/tests/config/test_call_config.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..bfa9e99 --- /dev/null +++ b/tests/conftest.py @@ -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)) diff --git a/tests/livekit/__init__.py b/tests/livekit/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/livekit/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/livekit/test_agent_finalization.py b/tests/livekit/test_agent_finalization.py new file mode 100644 index 0000000..f203ef5 --- /dev/null +++ b/tests/livekit/test_agent_finalization.py @@ -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 diff --git a/tests/livekit/test_bridge_gateway.py b/tests/livekit/test_bridge_gateway.py new file mode 100644 index 0000000..10528ef --- /dev/null +++ b/tests/livekit/test_bridge_gateway.py @@ -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()) diff --git a/tests/livekit/test_compat.py b/tests/livekit/test_compat.py new file mode 100644 index 0000000..b8b82a4 --- /dev/null +++ b/tests/livekit/test_compat.py @@ -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 diff --git a/tests/livekit/test_fake_remote_ws_adapter.py b/tests/livekit/test_fake_remote_ws_adapter.py new file mode 100644 index 0000000..f57ff13 --- /dev/null +++ b/tests/livekit/test_fake_remote_ws_adapter.py @@ -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() diff --git a/tests/livekit/test_initial_greeting_audio_cache.py b/tests/livekit/test_initial_greeting_audio_cache.py new file mode 100644 index 0000000..f2bca9f --- /dev/null +++ b/tests/livekit/test_initial_greeting_audio_cache.py @@ -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) diff --git a/tests/livekit/test_remote_agent_sse_adapter.py b/tests/livekit/test_remote_agent_sse_adapter.py new file mode 100644 index 0000000..5cf7da3 --- /dev/null +++ b/tests/livekit/test_remote_agent_sse_adapter.py @@ -0,0 +1,1129 @@ +from __future__ import annotations + +import json +import os +import unittest +import uuid +from unittest import mock + +import httpx + +from app.livekit.adapters.agent_backend import BackendReply +from app.livekit.adapters.backend_factory import build_agent_backend +from app.livekit.adapters.remote_agent_sse_adapter import RemoteAgentSSEAdapter + + +def _assert_uuid(testcase: unittest.TestCase, value: object) -> str: + text = str(value or "") + testcase.assertEqual(str(uuid.UUID(text)), text) + return text + + +def _sse_event(event_name: str, data: object) -> list[str]: + return [ + f"event: {event_name}", + f"data: {json.dumps(data)}", + "", + ] + + +class _FakeStreamResponse: + def __init__(self, lines, *, method: str, url: str) -> None: + self._lines = list(lines) + self.request = httpx.Request(method, url) + self.status_code = 200 + self.closed = False + + def raise_for_status(self) -> None: + return None + + async def aiter_lines(self): + for line in self._lines: + yield line + + async def aclose(self) -> None: + self.closed = True + + +class _FakeStreamContextManager: + def __init__(self, response: _FakeStreamResponse) -> None: + self._response = response + + async def __aenter__(self) -> _FakeStreamResponse: + return self._response + + async def __aexit__(self, exc_type, exc, tb) -> bool: + await self._response.aclose() + return False + + +class _FakeAsyncClientFactory: + def __init__(self, responses) -> None: + self._responses = [list(response) for response in responses] + self.stream_calls: list[dict] = [] + self.client_kwargs: list[dict] = [] + + def build(self, *args, **kwargs): + self.client_kwargs.append(dict(kwargs)) + return _FakeAsyncClient(self) + + +class _FakeAsyncClient: + def __init__(self, factory: _FakeAsyncClientFactory) -> None: + self._factory = factory + self.closed = False + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> bool: + await self.aclose() + return False + + def stream(self, method: str, url: str, **kwargs): + if not self._factory._responses: + raise AssertionError("No fake SSE response configured") + + lines = self._factory._responses.pop(0) + self._factory.stream_calls.append( + { + "method": method, + "url": url, + "params": dict(kwargs.get("params") or {}), + "headers": dict(kwargs.get("headers") or {}), + "json": kwargs.get("json"), + } + ) + return _FakeStreamContextManager( + _FakeStreamResponse(lines, method=method, url=url) + ) + + async def aclose(self) -> None: + self.closed = True + + +class _FakeTimeline: + def __init__(self) -> None: + self.events: list[tuple[str, dict]] = [] + + def emit(self, event: str, **fields) -> None: + self.events.append((event, dict(fields))) + + +class RemoteAgentSSEAdapterTests(unittest.IsolatedAsyncioTestCase): + async def test_prepare_captures_session_and_post_reads_own_short_stream(self) -> None: + factory = _FakeAsyncClientFactory( + responses=[ + [ + *_sse_event( + "ready", + { + "message": "Posso verificar sua fatura.", + "actions": ["chat", "ping"], + "session_id": "sessao-123", + "speech_id": "11111111-1111-4111-8111-111111111111", + "is_interruptible": False, + "metadata": {"wait_timeout_seconds": 30}, + }, + ), + *_sse_event("prefetch_done", {"session_id": "sessao-123"}), + ], + [ + *_sse_event( + "ready", + { + "message": "Processando.", + "actions": ["chat", "ping"], + "session_id": "sessao-123", + }, + ), + *_sse_event( + "result", + { + "type": "result", + "action": "chat", + "result": { + "type": "final", + "content": "A variacao entre a fatura atual e a anterior pode ocorrer por alguns motivos comuns.", + "speech_id": "7f3a0000-0000-4000-8000-000000000000", + "is_interruptible": True, + "tool_calls": [], + }, + }, + ), + ], + ] + ) + + with mock.patch( + "app.livekit.adapters.remote_agent_sse_adapter.httpx.AsyncClient", + side_effect=factory.build, + ): + adapter = RemoteAgentSSEAdapter( + intro="oi", + url="http://agent.internal/agent/sse", + request_context={ + "agent": "conta", + "RouterCallKeyDay": "20260329", + "RouterCallKey": "0005", + "ANI": "3133338888", + "GSM": "551199993339999", + "ID_FATURA": "fat-555", + "callIdGed": "GED-555", + "protocol_id": "PRT-555", + "sessionId": "call-session-555", + }, + ) + await adapter.prepare(True, "PRT-555") + + ready_reply = await adapter.run("") + ready_message_id = _assert_uuid(self, ready_reply.metadata["message_id"]) + self.assertEqual( + ready_reply, + BackendReply( + stage="PRESENTATION", + text="Posso verificar sua fatura.", + done=False, + export_payload=None, + metadata={ + "message_id": ready_message_id, + "wait_timeout_seconds": 30, + "speech_id": "11111111-1111-4111-8111-111111111111", + "is_interruptible": False, + "agent_message_type": "ready", + "expects_user_response": True, + "drop_user_input_while_speaking": True, + }, + ), + ) + self.assertEqual(len(factory.stream_calls), 1) + + get_call = factory.stream_calls[0] + self.assertEqual(get_call["method"], "GET") + self.assertEqual(get_call["url"], "http://agent.internal/agent/sse") + self.assertEqual( + get_call["params"], + { + "msisdn": "551199993339999", + "invoice_id": "fat-555", + "ani": "3133338888", + "protocol_id": "PRT-555", + "session_id": "call-session-555", + "message_id": ready_message_id, + "channelId": "ura", + "uraCallId": "GED-555", + }, + ) + + await adapter.set_processing_interruption( + listened_text="pode repetir", + skipped=False, + speech_id="11111111-1111-4111-8111-111111111111", + ) + await adapter.inject_idle_nudge("Alo, voce esta ai?") + + reply = await adapter.run("teste") + reply_message_id = _assert_uuid(self, reply.metadata["message_id"]) + self.assertNotEqual(reply_message_id, ready_message_id) + self.assertEqual( + reply, + BackendReply( + stage="PRESENTATION", + text="A variacao entre a fatura atual e a anterior pode ocorrer por alguns motivos comuns.", + done=False, + export_payload={ + "type": "final", + "content": "A variacao entre a fatura atual e a anterior pode ocorrer por alguns motivos comuns.", + "speech_id": "7f3a0000-0000-4000-8000-000000000000", + "is_interruptible": True, + "tool_calls": [], + }, + metadata={ + "message_id": reply_message_id, + "speech_id": "7f3a0000-0000-4000-8000-000000000000", + "is_interruptible": True, + "agent_message_type": "result", + "agent_result_type": "final", + "expects_user_response": True, + "drop_user_input_while_speaking": False, + }, + ), + ) + + post_call = factory.stream_calls[1] + self.assertEqual(post_call["method"], "POST") + self.assertEqual(post_call["url"], "http://agent.internal/agent/sse") + self.assertEqual( + post_call["params"], + { + "session_id": "sessao-123", + "ani": "3133338888", + "protocol_id": "PRT-555", + "message_id": reply_message_id, + "channelId": "ura", + "uraCallId": "GED-555", + }, + ) + self.assertNotEqual(post_call["params"]["message_id"], post_call["params"]["session_id"]) + self.assertEqual(adapter._remote_session_id, "sessao-123") + self.assertEqual(post_call["json"]["action"], "chat") + self.assertEqual(post_call["json"]["payload"]["message"], "teste") + self.assertEqual( + post_call["json"]["payload"]["message_id"], + reply_message_id, + ) + self.assertNotIn("msisdn", post_call["json"]["payload"]) + self.assertNotIn("invoice_id", post_call["json"]["payload"]) + self.assertEqual( + post_call["json"]["payload"]["processing_interruption"], + { + "speech_id": "11111111-1111-4111-8111-111111111111", + "heard_text": "pode repetir", + }, + ) + self.assertNotIn("speech_interruption", post_call["json"]["payload"]) + self.assertNotIn("interruption", post_call["json"]["payload"]) + self.assertEqual( + post_call["json"]["payload"]["events"], + [{"type": "idle_nudge", "text": "Alo, voce esta ai?"}], + ) + self.assertFalse(adapter.supports_server_push()) + + async def test_conta_feedback_is_buffered_and_run_waits_for_top_level_final(self) -> None: + feedback_payload = { + "type": "feedback", + "text": "Ainda estou consultando sua fatura.", + } + factory = _FakeAsyncClientFactory( + responses=[ + [ + *_sse_event( + "ready", + { + "message": "Posso verificar sua fatura.", + "actions": ["chat", "ping"], + "session_id": "sessao-125", + }, + ), + *_sse_event("prefetch_done", {"session_id": "sessao-125"}), + ], + [ + *_sse_event("message", feedback_payload), + *_sse_event( + "message", + { + "type": "final", + "content": "Encontrei os detalhes da sua fatura.", + }, + ), + ], + ] + ) + + with mock.patch( + "app.livekit.adapters.remote_agent_sse_adapter.httpx.AsyncClient", + side_effect=factory.build, + ): + adapter = RemoteAgentSSEAdapter( + intro="oi", + url="http://agent.internal/agent/sse", + request_context={ + "agent": "conta", + "GSM": "551199993339999", + "ID_FATURA": "fat-557", + }, + ) + await adapter.prepare(True, "PRT-557") + await adapter.run("") + + reply = await adapter.run("teste") + feedback_reply = await adapter.wait_for_server_push() + reply_message_id = _assert_uuid(self, reply.metadata["message_id"]) + + self.assertEqual( + reply, + BackendReply( + stage="PRESENTATION", + text="Encontrei os detalhes da sua fatura.", + done=False, + export_payload=None, + metadata={ + "message_id": reply_message_id, + "agent_message_type": "final", + "expects_user_response": True, + "drop_user_input_while_speaking": False, + }, + ), + ) + self.assertEqual( + feedback_reply, + BackendReply( + stage="PRESENTATION", + text="Ainda estou consultando sua fatura.", + done=False, + export_payload=None, + metadata={ + "message_id": reply_message_id, + "agent_message_type": "feedback", + "expects_user_response": False, + "drop_user_input_while_speaking": True, + "is_interruptible": False, + "event": "feedback", + "payload": feedback_payload, + }, + ), + ) + + async def test_run_marks_expected_agent_result_type_as_done(self) -> None: + factory = _FakeAsyncClientFactory( + responses=[ + [ + *_sse_event( + "ready", + { + "message": "Posso verificar sua fatura.", + "actions": ["chat", "ping"], + "session_id": "sessao-124", + }, + ), + *_sse_event("prefetch_done", {"session_id": "sessao-124"}), + ], + [ + *_sse_event( + "result", + { + "type": "result", + "action": "chat", + "result": { + "type": "erro_no_match", + "content": "Atendimento encerrado por falta de correspondencia.", + "tool_calls": [], + }, + }, + ), + ], + ] + ) + + with mock.patch( + "app.livekit.adapters.remote_agent_sse_adapter.httpx.AsyncClient", + side_effect=factory.build, + ): + adapter = RemoteAgentSSEAdapter( + intro="oi", + url="http://agent.internal/agent/sse", + request_context={ + "agent": "conta", + "GSM": "551199993339999", + "ID_FATURA": "fat-556", + }, + ) + await adapter.prepare(True, "PRT-556") + await adapter.run("") + + reply = await adapter.run("teste") + reply_message_id = _assert_uuid(self, reply.metadata["message_id"]) + + self.assertEqual( + reply, + BackendReply( + stage="DONE", + text="Atendimento encerrado por falta de correspondencia.", + done=True, + export_payload={ + "type": "erro_no_match", + "content": "Atendimento encerrado por falta de correspondencia.", + "tool_calls": [], + }, + metadata={ + "message_id": reply_message_id, + "agent_message_type": "result", + "agent_result_type": "erro_no_match", + "expects_user_response": False, + "drop_user_input_while_speaking": True, + "is_interruptible": False, + }, + ), + ) + + async def test_conta_result_feedback_is_buffered_and_run_waits_for_final(self) -> None: + feedback_result = { + "type": "feedback", + "content": "Perfeito, seguiremos com o cancelamento. Aguarde um instante.", + "speech_id": "332a02d1-031d-4d08-b14a-f896fe630fbc", + "is_interruptible": True, + "tool_calls": [], + "metadata": {"stage": "feedback"}, + } + feedback_payload = { + "type": "result", + "action": "chat", + "result": feedback_result, + } + factory = _FakeAsyncClientFactory( + responses=[ + [ + *_sse_event( + "ready", + { + "message": "Posso verificar sua fatura.", + "actions": ["chat", "ping"], + "session_id": "sessao-126", + }, + ), + *_sse_event("prefetch_done", {"session_id": "sessao-126"}), + ], + [ + *_sse_event( + "result", + feedback_payload, + ), + *_sse_event( + "result", + { + "type": "result", + "action": "chat", + "result": { + "type": "final", + "content": "Cancelamento concluido.", + "tool_calls": [], + }, + }, + ), + ], + ] + ) + + with mock.patch( + "app.livekit.adapters.remote_agent_sse_adapter.httpx.AsyncClient", + side_effect=factory.build, + ): + adapter = RemoteAgentSSEAdapter( + intro="oi", + url="http://agent.internal/agent/sse", + request_context={ + "agent": "conta", + "GSM": "551199993339999", + "ID_FATURA": "fat-558", + }, + ) + await adapter.prepare(True, "PRT-558") + await adapter.run("") + + reply = await adapter.run("correto") + feedback_reply = await adapter.wait_for_server_push() + reply_message_id = _assert_uuid(self, reply.metadata["message_id"]) + + self.assertEqual( + reply, + BackendReply( + stage="PRESENTATION", + text="Cancelamento concluido.", + done=False, + export_payload={ + "type": "final", + "content": "Cancelamento concluido.", + "tool_calls": [], + }, + metadata={ + "message_id": reply_message_id, + "agent_message_type": "result", + "agent_result_type": "final", + "expects_user_response": True, + "drop_user_input_while_speaking": False, + }, + ), + ) + self.assertEqual( + feedback_reply, + BackendReply( + stage="PRESENTATION", + text="Perfeito, seguiremos com o cancelamento. Aguarde um instante.", + done=False, + export_payload=None, + metadata={ + "message_id": reply_message_id, + "speech_id": "332a02d1-031d-4d08-b14a-f896fe630fbc", + "is_interruptible": False, + "agent_message_type": "result", + "agent_result_type": "feedback", + "expects_user_response": False, + "drop_user_input_while_speaking": True, + "event": "feedback", + "payload": feedback_payload, + }, + ), + ) + + async def test_prepare_stops_after_ready_even_if_stream_has_late_error(self) -> None: + factory = _FakeAsyncClientFactory( + responses=[ + [ + *_sse_event( + "ready", + { + "message": "Posso verificar sua fatura.", + "actions": ["chat"], + "session_id": "sessao-999", + }, + ), + *_sse_event("error", {"error": "late stream error after ready"}), + ] + ] + ) + + with mock.patch( + "app.livekit.adapters.remote_agent_sse_adapter.httpx.AsyncClient", + side_effect=factory.build, + ): + adapter = RemoteAgentSSEAdapter( + intro="oi", + url="http://agent.internal/agent/sse", + request_context={ + "agent": "conta", + "GSM": "5511999900000", + "ID_FATURA": "fat-999", + }, + ) + + await adapter.prepare(True, "ignored-protocol") + ready_reply = await adapter.run("") + ready_message_id = _assert_uuid(self, ready_reply.metadata["message_id"]) + + self.assertEqual( + ready_reply, + BackendReply( + stage="PRESENTATION", + text="Posso verificar sua fatura.", + done=False, + export_payload=None, + metadata={ + "message_id": ready_message_id, + "agent_message_type": "ready", + "expects_user_response": True, + "drop_user_input_while_speaking": True, + "is_interruptible": False, + }, + ), + ) + self.assertEqual(adapter._remote_session_id, "sessao-999") + self.assertEqual(len(factory.stream_calls), 1) + self.assertEqual(factory.stream_calls[0]["method"], "GET") + self.assertEqual( + factory.stream_calls[0]["params"], + { + "msisdn": "5511999900000", + "invoice_id": "fat-999", + "message_id": ready_message_id, + "channelId": "ura", + }, + ) + + async def test_run_accumulates_progress_chunks_until_done(self) -> None: + factory = _FakeAsyncClientFactory( + responses=[ + [ + *_sse_event( + "ready", + { + "message": "Posso ajudar.", + "actions": ["chat"], + "session_id": "PRT-777", + }, + ) + ], + [ + *_sse_event("progress", {"delta": "Oferta "}), + *_sse_event("progress", {"delta": "liberada"}), + *_sse_event("result", {"stage": "DONE"}), + ], + ] + ) + + with mock.patch( + "app.livekit.adapters.remote_agent_sse_adapter.httpx.AsyncClient", + side_effect=factory.build, + ): + adapter = RemoteAgentSSEAdapter( + intro="oi", + url="http://agent.internal/agent/sse", + request_context={ + "agent": "conta", + "GSM": "5511999900000", + }, + ) + await adapter.prepare(True, "PRT-777") + await adapter.run("") + + reply = await adapter.run("teste") + reply_message_id = _assert_uuid(self, reply.metadata["message_id"]) + self.assertEqual( + reply, + BackendReply( + stage="DONE", + text="Oferta liberada", + done=True, + export_payload={"stage": "DONE", "type": "result"}, + metadata={ + "message_id": reply_message_id, + "agent_message_type": "result", + "agent_result_type": "result", + "expects_user_response": False, + "drop_user_input_while_speaking": True, + "is_interruptible": False, + }, + ), + ) + + async def test_end_service_once_skips_end_action_when_backend_does_not_advertise_it(self) -> None: + factory = _FakeAsyncClientFactory( + responses=[ + [ + *_sse_event( + "ready", + { + "message": "Posso verificar sua fatura.", + "actions": ["chat", "ping"], + "session_id": "PRT-559", + }, + ) + ] + ] + ) + + with mock.patch( + "app.livekit.adapters.remote_agent_sse_adapter.httpx.AsyncClient", + side_effect=factory.build, + ): + adapter = RemoteAgentSSEAdapter( + intro="oi", + url="http://agent.internal/agent/sse", + request_context={ + "agent": "conta", + "GSM": "5511999900003", + }, + ) + await adapter.prepare(True, "PRT-559") + ready_reply = await adapter.run("") + self.assertEqual(ready_reply.text, "Posso verificar sua fatura.") + + end_reply = await adapter.end_service_once() + self.assertEqual( + end_reply, + BackendReply( + stage="DONE", + text="", + done=True, + export_payload=[], + ), + ) + self.assertEqual(len(factory.stream_calls), 1) + + async def test_oferta_initial_turn_posts_execute_and_streams_sse_events(self) -> None: + factory = _FakeAsyncClientFactory( + responses=[ + [ + *_sse_event( + "schedule_message", + { + "scheduledMessage": { + "timeInSeconds": 5, + "message": "Aguarde, estou consultando sua oferta.", + } + }, + ), + *_sse_event( + "message", + { + "response": "Tenho uma oferta disponivel para voce.", + "sessionId": "remote-session-001", + "additionalInformations": {"foo": "bar"}, + }, + ), + *_sse_event( + "done", + { + "status": "completed", + "additionalInformations": {"service_status": "RESOLVED"}, + }, + ), + ], + ] + ) + + with mock.patch( + "app.livekit.adapters.remote_agent_sse_adapter.httpx.AsyncClient", + side_effect=factory.build, + ): + adapter = RemoteAgentSSEAdapter( + intro="oi", + url="http://agent.internal/agent/execute", + request_context={ + "agent": "oferta", + "RouterCallKeyDay": "20260329", + "RouterCallKey": "0005", + "ANI": "3133338888", + "GSM": "551199993339999", + "callIdGed": "GED-555", + "protocolo": "PRT-555", + "assetId": "asset-555", + "channelId": "ura", + }, + ) + await adapter.prepare(True, "PRT-555") + + first_reply = await adapter.run("") + message_id = _assert_uuid(self, first_reply.metadata["message_id"]) + self.assertEqual( + first_reply, + BackendReply( + stage="PRESENTATION", + text="Aguarde, estou consultando sua oferta.", + done=False, + export_payload=None, + metadata={ + "message_id": message_id, + "event": "schedule_message", + "scheduledMessage": { + "timeInSeconds": 5, + "message": "Aguarde, estou consultando sua oferta.", + }, + "payload": { + "scheduledMessage": { + "timeInSeconds": 5, + "message": "Aguarde, estou consultando sua oferta.", + }, + "type": "schedule_message", + }, + }, + ), + ) + + message_reply = await adapter.wait_for_server_push() + self.assertEqual( + message_reply, + BackendReply( + stage="PRESENTATION", + text="Tenho uma oferta disponivel para voce.", + done=False, + export_payload=None, + metadata={ + "message_id": message_id, + "event": "message", + "payload": { + "response": "Tenho uma oferta disponivel para voce.", + "sessionId": "remote-session-001", + "additionalInformations": {"foo": "bar"}, + "type": "message", + }, + "sessionId": "remote-session-001", + "additionalInformations": {"foo": "bar"}, + }, + ), + ) + + done_reply = await adapter.wait_for_server_push() + self.assertEqual(done_reply.stage, "DONE") + self.assertTrue(done_reply.done) + self.assertEqual(done_reply.export_payload["type"], "resolvido") + self.assertEqual(adapter._remote_session_id, "remote-session-001") + + self.assertEqual(len(factory.stream_calls), 1) + post_call = factory.stream_calls[0] + self.assertEqual(post_call["method"], "POST") + self.assertEqual(post_call["url"], "http://agent.internal/agent/execute") + self.assertEqual(post_call["params"], {}) + self.assertEqual(post_call["headers"]["Channel-id"], "ura") + self.assertEqual(post_call["json"]["message"], "inicio_atendimento") + self.assertEqual(post_call["json"]["messageId"], message_id) + self.assertEqual( + post_call["json"]["context"], + { + "protocolNumber": "PRT-555", + "protocolo": "PRT-555", + "gsm": "551199993339999", + "uraId": "GED-555", + "callIdGed": "GED-555", + "ani": "3133338888", + "routerCallKey": "0005", + "routerCallKeyDay": "20260329", + "agent": "oferta", + "assetId": "asset-555", + }, + ) + self.assertNotIn("sessionId", post_call["json"]["context"]) + + async def test_oferta_forwards_only_explicit_session_id(self) -> None: + factory = _FakeAsyncClientFactory( + responses=[ + [ + *_sse_event( + "done", + { + "status": "completed", + "additionalInformations": {"service_status": "UNRESOLVED"}, + }, + ) + ], + ] + ) + + with mock.patch( + "app.livekit.adapters.remote_agent_sse_adapter.httpx.AsyncClient", + side_effect=factory.build, + ): + adapter = RemoteAgentSSEAdapter( + intro="oi", + url="http://agent.internal/agent/execute", + request_context={ + "agent": "oferta", + "GSM": "551199993339999", + "callIdGed": "GED-556", + "protocolo": "PRT-556", + "sessionId": "session-556", + }, + ) + await adapter.prepare(True, "PRT-556") + reply = await adapter.run("quero oferta") + + self.assertEqual(reply.stage, "PRESENTATION") + self.assertFalse(reply.done) + self.assertEqual(reply.export_payload["type"], "unresolved") + self.assertEqual( + factory.stream_calls[0]["json"]["context"]["sessionId"], + "session-556", + ) + self.assertEqual(factory.stream_calls[0]["json"]["message"], "quero oferta") + + async def test_oferta_ignores_removed_test_session_id_env(self) -> None: + factory = _FakeAsyncClientFactory( + responses=[ + [ + *_sse_event( + "done", + { + "status": "completed", + "additionalInformations": {"service_status": "UNRESOLVED"}, + }, + ) + ], + ] + ) + + with mock.patch.dict( + os.environ, + {"REMOTE_AGENT_OFERTA_TEST_SESSION_ID": "dev-test-session"}, + ), mock.patch( + "app.livekit.adapters.remote_agent_sse_adapter.httpx.AsyncClient", + side_effect=factory.build, + ): + adapter = RemoteAgentSSEAdapter( + intro="oi", + url="http://agent.internal/agent/execute", + request_context={ + "agent": "oferta", + "GSM": "551199993339999", + "callIdGed": "GED-556", + "protocolo": "PRT-556", + }, + ) + await adapter.prepare(True, "PRT-556") + await adapter.run("quero oferta") + + self.assertNotIn("sessionId", factory.stream_calls[0]["json"]["context"]) + + async def test_oferta_can_disable_tls_verification_by_env(self) -> None: + factory = _FakeAsyncClientFactory( + responses=[ + [ + *_sse_event( + "done", + { + "status": "completed", + "additionalInformations": {"service_status": "UNRESOLVED"}, + }, + ) + ], + ] + ) + + with mock.patch.dict( + os.environ, + {"REMOTE_AGENT_SSE_TLS_VERIFY_OFERTA": "0"}, + ), mock.patch( + "app.livekit.adapters.remote_agent_sse_adapter.httpx.AsyncClient", + side_effect=factory.build, + ): + adapter = RemoteAgentSSEAdapter( + intro="oi", + url="http://agent.internal/agent/execute", + request_context={ + "agent": "oferta", + "GSM": "551199993339999", + "callIdGed": "GED-556", + "protocolo": "PRT-556", + }, + ) + await adapter.prepare(True, "PRT-556") + await adapter.run("quero oferta") + + self.assertEqual(factory.client_kwargs[0]["verify"], False) + + async def test_oferta_timeline_records_sse_event_without_emit_collision(self) -> None: + factory = _FakeAsyncClientFactory( + responses=[ + [ + *_sse_event( + "schedule_message", + { + "scheduledMessage": { + "timeInSeconds": 5, + "message": "Processando sua solicitação.", + } + }, + ), + *_sse_event( + "done", + { + "status": "completed", + "additionalInformations": {"service_status": "UNRESOLVED"}, + }, + ), + ], + ] + ) + timeline = _FakeTimeline() + + with mock.patch( + "app.livekit.adapters.remote_agent_sse_adapter.httpx.AsyncClient", + side_effect=factory.build, + ): + adapter = RemoteAgentSSEAdapter( + intro="oi", + url="http://agent.internal/agent/execute", + request_context={ + "agent": "oferta", + "GSM": "551199993339999", + "callIdGed": "GED-556", + "protocolo": "PRT-556", + }, + timeline=timeline, + ) + await adapter.prepare(True, "PRT-556") + await adapter.run("") + + response_events = [ + fields + for event_name, fields in timeline.events + if event_name == "remote_agent_response" + ] + self.assertEqual(response_events[0]["sse_event"], "schedule_message") + self.assertNotIn("event", response_events[0]) + + async def test_oferta_done_maps_supported_service_statuses_and_transfer(self) -> None: + cases = [ + ("RESOLVED", "completed", "resolvido", "DONE", True), + ("UNRESOLVED", "completed", "unresolved", "PRESENTATION", False), + ("RESOLVED_WITH_NEW_REQUEST", "completed", "resolved_with_new_request", "PRESENTATION", False), + ("", "completed", "completed", "PRESENTATION", False), + ("RESOLVED", "transferred", "transferred", "PRESENTATION", False), + ] + + for service_status, status, expected_result_type, expected_stage, expected_done in cases: + with self.subTest(service_status=service_status, status=status): + factory = _FakeAsyncClientFactory( + responses=[ + [ + *_sse_event( + "done", + { + "status": status, + "additionalInformations": { + "service_status": service_status, + "changeAgentId": "ATH-001", + "context": "handover", + }, + }, + ) + ], + ] + ) + + with mock.patch( + "app.livekit.adapters.remote_agent_sse_adapter.httpx.AsyncClient", + side_effect=factory.build, + ): + adapter = RemoteAgentSSEAdapter( + intro="oi", + url="http://agent.internal/agent/execute", + request_context={ + "agent": "oferta", + "GSM": "551199993339999", + "callIdGed": "GED-557", + "protocolo": "PRT-557", + }, + ) + await adapter.prepare(True, "PRT-557") + reply = await adapter.run("teste") + + self.assertEqual(reply.stage, expected_stage) + self.assertEqual(reply.done, expected_done) + self.assertEqual(reply.export_payload["type"], expected_result_type) + self.assertEqual(reply.export_payload["status"], status) + self.assertEqual( + reply.export_payload["additionalInformations"]["changeAgentId"], + "ATH-001", + ) + + async def test_idle_nudge_keeps_only_the_last_phrase_before_the_turn(self) -> None: + adapter = RemoteAgentSSEAdapter( + intro="", + url="http://agent.internal/agent/sse", + request_context={"agent": "conta"}, + ) + + # metadata.wait_retry_messages manda tres frases distintas por chamada; + # sem a colapsagem elas viram tres falas do agente no historico remoto. + for retry_text in ( + "Voce ainda esta na linha?", + "Sigo por aqui.", + "Vou confirmar mais uma vez, senao vou precisar encerrar o atendimento.", + ): + await adapter.inject_idle_nudge(retry_text) + + self.assertEqual( + adapter._pending_events, + [ + { + "type": "idle_nudge", + "text": "Vou confirmar mais uma vez, senao vou precisar encerrar o atendimento.", + } + ], + ) + + payload = adapter._build_turn_payload("estou na linha", message_id="MSG-1") + self.assertEqual(len(payload["payload"]["events"]), 1) + + +class BackendFactoryTests(unittest.TestCase): + def test_selects_remote_sse_backend(self) -> None: + with mock.patch.dict( + os.environ, + { + "AGENT_BACKEND": "remote_sse", + "REMOTE_AGENT_SSE_URL_CONTA": "http://agent.internal/conta/sse", + }, + clear=False, + ): + backend = build_agent_backend( + intro="oi", + remote_agent_context={"agent": "conta"}, + streaming=False, + ) + + self.assertIsInstance(backend, RemoteAgentSSEAdapter) + self.assertEqual(backend._url, "http://agent.internal/conta/sse") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/livekit/test_remote_agent_ws_adapter.py b/tests/livekit/test_remote_agent_ws_adapter.py new file mode 100644 index 0000000..981fc93 --- /dev/null +++ b/tests/livekit/test_remote_agent_ws_adapter.py @@ -0,0 +1,1078 @@ +from __future__ import annotations + +import asyncio +import json +import os +import sys +import types +import unittest +import uuid +from unittest import mock +from urllib.parse import parse_qs, urlsplit + +from app.livekit.adapters.agent_backend import BackendReply +from app.livekit.adapters.backend_factory import build_agent_backend +from app.livekit.adapters.fake_remote_ws_adapter import FakeRemoteWSAdapter +from app.livekit.adapters.remote_agent_ws_adapter import RemoteAgentWSAdapter + + +def _assert_uuid(testcase: unittest.TestCase, value: object) -> str: + text = str(value or "") + testcase.assertEqual(str(uuid.UUID(text)), text) + return text + + +class _FakeWebSocket: + def __init__(self, responses, send_responses=None) -> None: + self._responses = list(responses) + self._send_responses = [list(batch) for batch in (send_responses or [])] + self._ready = asyncio.Event() + if self._responses: + self._ready.set() + self.sent_messages = [] + + async def send(self, message: str) -> None: + self.sent_messages.append(message) + if self._send_responses: + self._responses.extend(self._send_responses.pop(0)) + self._ready.set() + + async def recv(self): + while not self._responses: + self._ready.clear() + await self._ready.wait() + return self._responses.pop(0) + + +class _FakeConnect: + def __init__(self, websocket: _FakeWebSocket) -> None: + self._websocket = websocket + + async def __aenter__(self) -> _FakeWebSocket: + return self._websocket + + async def __aexit__(self, exc_type, exc, tb) -> bool: + return False + + +class _FakeWebsocketsModule(types.SimpleNamespace): + def __init__(self, sessions) -> None: + super().__init__() + self._sessions = [] + for spec in sessions: + if isinstance(spec, dict): + initial = spec.get("initial") or [] + send_responses = spec.get("after_sends") or [] + self._sessions.append(_FakeWebSocket(initial, send_responses)) + else: + self._sessions.append(_FakeWebSocket(spec)) + self.calls = [] + + def connect(self, url: str, **kwargs): + if not self._sessions: + raise AssertionError("No fake websocket session configured") + websocket = self._sessions.pop(0) + self.calls.append( + { + "url": url, + "kwargs": kwargs, + "websocket": websocket, + } + ) + return _FakeConnect(websocket) + + +class RemoteAgentWSAdapterTests(unittest.IsolatedAsyncioTestCase): + async def test_run_sends_turn_payload_and_parses_response_for_non_conta_agents(self) -> None: + fake_ws_module = _FakeWebsocketsModule( + sessions=[ + [ + json.dumps( + { + "type": "final", + "stage": "ARGUMENTATION", + "text": "Tenho uma oferta para voce.", + } + ) + ], + [ + json.dumps( + { + "type": "final", + "stage": "FORMALIZATION", + "text": "Continuando sem interrupcao.", + } + ) + ], + ] + ) + + with mock.patch.dict(sys.modules, {"websockets": fake_ws_module}): + adapter = RemoteAgentWSAdapter( + intro="oi", + url="ws://agent.internal/turn", + url_by_agent={ + "oferta": "ws://agent.internal/oferta", + "cobranca": "ws://agent.internal/cobranca", + }, + request_context={ + "agent": "oferta", + "RouterCallKeyDay": "20260329", + "RouterCallKey": "0001", + "ANI": "3133334444", + "GSM": "31999999999", + "callIdGed": "GED-123", + }, + ) + await adapter.prepare(True, "PRT-123") + await adapter.set_interruption( + True, + listened_text="pode repetir", + skipped=False, + speech_id="7f3a0000-0000-4000-8000-000000000000", + ) + await adapter.inject_idle_nudge("Alo, voce esta ai?") + + reply = await adapter.run({"text": "quero detalhes"}) + self.assertEqual( + reply, + BackendReply( + stage="ARGUMENTATION", + text="Tenho uma oferta para voce.", + done=False, + export_payload=None, + ), + ) + + first_payload = json.loads(fake_ws_module.calls[0]["websocket"].sent_messages[0]) + self.assertEqual(fake_ws_module.calls[0]["url"], "ws://agent.internal/oferta") + self.assertIn("timestamp", first_payload) + self.assertEqual(first_payload["agent"], "oferta") + self.assertEqual(first_payload["RouterCallKeyDay"], "20260329") + self.assertEqual(first_payload["RouterCallKey"], "0001") + self.assertEqual(first_payload["ANI"], "3133334444") + self.assertEqual(first_payload["GSM"], "31999999999") + self.assertEqual(first_payload["callIdGed"], "GED-123") + self.assertEqual(first_payload["protocol"], "PRT-123") + self.assertEqual(first_payload["text"], "quero detalhes") + self.assertEqual( + first_payload["speech_interruption"], + { + "speech_id": "7f3a0000-0000-4000-8000-000000000000", + "heard_text": "pode repetir", + }, + ) + self.assertNotIn("processing_interruption", first_payload) + self.assertEqual( + first_payload["events"], + [{"type": "idle_nudge", "text": "Alo, voce esta ai?"}], + ) + + reply_2 = await adapter.run({"text": "segue"}) + self.assertEqual( + reply_2, + BackendReply( + stage="FORMALIZATION", + text="Continuando sem interrupcao.", + done=False, + export_payload=None, + ), + ) + + second_payload = json.loads(fake_ws_module.calls[1]["websocket"].sent_messages[0]) + self.assertNotIn("interruption", second_payload) + self.assertNotIn("speech_interruption", second_payload) + self.assertNotIn("events", second_payload) + + async def test_conta_prepare_buffers_ready_and_reuses_socket_for_proactive_results(self) -> None: + fake_ws_module = _FakeWebsocketsModule( + sessions=[ + { + "initial": [ + json.dumps( + { + "type": "ready", + "message": "Entendi que voce precisa de mais detalhes sobre os valores da sua fatura.", + "actions": ["chat", "ping"], + "speech_id": "11111111-1111-4111-8111-111111111111", + "is_interruptible": False, + "metadata": {"wait_timeout_seconds": 30}, + } + ) + ], + "after_sends": [ + [ + json.dumps( + { + "type": "proactive_result", + "action": "explicar_fatura", + "result": { + "type": "final", + "content": "A variacao entre a fatura atual e a anterior pode ocorrer por alguns motivos comuns.", + "speech_id": "7f3a0000-0000-4000-8000-000000000000", + "is_interruptible": True, + "tool_calls": [], + }, + } + ), + json.dumps( + { + "type": "proactive_result", + "action": "explicar_fatura", + "result": { + "type": "final", + "content": "Esses sao os principais fatores que podem gerar a diferenca na fatura.", + "tool_calls": [], + }, + } + ), + ] + ], + } + ] + ) + + with mock.patch.dict(sys.modules, {"websockets": fake_ws_module}): + adapter = RemoteAgentWSAdapter( + intro="oi", + url="ws://agent.internal/conta", + request_context={ + "agent": "conta", + "RouterCallKeyDay": "20260329", + "RouterCallKey": "0005", + "ANI": "3133338888", + "GSM": "551199993339999", + "ID_FATURA": "fat-555", + "callIdGed": "GED-555", + "protocol_id": "PRT-555", + "sessionId": "call-session-555", + }, + ) + await adapter.prepare(True, "PRT-555") + + ready_reply = await adapter.run("") + connection_message_id = _assert_uuid(self, ready_reply.metadata["message_id"]) + + self.assertEqual( + ready_reply, + BackendReply( + stage="PRESENTATION", + text="Entendi que voce precisa de mais detalhes sobre os valores da sua fatura.", + done=False, + export_payload=None, + metadata={ + "message_id": connection_message_id, + "wait_timeout_seconds": 30, + "speech_id": "11111111-1111-4111-8111-111111111111", + "is_interruptible": False, + "agent_message_type": "ready", + "expects_user_response": True, + "drop_user_input_while_speaking": True, + }, + ), + ) + + ws = fake_ws_module.calls[0]["websocket"] + self.assertEqual(ws.sent_messages, []) + + await adapter.set_interruption( + True, + listened_text="pode repetir", + skipped=False, + speech_id="11111111-1111-4111-8111-111111111111", + ) + await adapter.inject_idle_nudge("Alo, voce esta ai?") + + reply = await adapter.run("teste") + self.assertEqual(reply.metadata["message_id"], connection_message_id) + + self.assertEqual( + reply, + BackendReply( + stage="PRESENTATION", + text="A variacao entre a fatura atual e a anterior pode ocorrer por alguns motivos comuns.", + done=False, + export_payload={ + "type": "final", + "content": "A variacao entre a fatura atual e a anterior pode ocorrer por alguns motivos comuns.", + "speech_id": "7f3a0000-0000-4000-8000-000000000000", + "is_interruptible": True, + "tool_calls": [], + }, + metadata={ + "message_id": connection_message_id, + "speech_id": "7f3a0000-0000-4000-8000-000000000000", + "is_interruptible": True, + "agent_message_type": "proactive_result", + "agent_result_type": "final", + "expects_user_response": True, + "drop_user_input_while_speaking": False, + }, + ), + ) + + first_payload = json.loads(ws.sent_messages[0]) + parsed_url = urlsplit(fake_ws_module.calls[0]["url"]) + self.assertEqual( + f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}", + "ws://agent.internal/conta", + ) + self.assertEqual( + parse_qs(parsed_url.query), + { + "msisdn": ["551199993339999"], + "current_invoice_number": ["fat-555"], + "ani": ["3133338888"], + "protocol_id": ["PRT-555"], + "session_id": ["call-session-555"], + "message_id": [connection_message_id], + "channelId": ["ura"], + "uraCallId": ["GED-555"], + }, + ) + self.assertEqual(first_payload["action"], "chat") + self.assertEqual(first_payload["payload"]["message"], "teste") + self.assertEqual(first_payload["payload"]["channel"], "SUPERVISOR") + self.assertEqual(first_payload["payload"]["msisdn"], "551199993339999") + self.assertEqual(first_payload["payload"]["current_invoice_number"], "fat-555") + self.assertEqual( + first_payload["payload"]["speech_interruption"], + { + "speech_id": "11111111-1111-4111-8111-111111111111", + "heard_text": "pode repetir", + }, + ) + self.assertNotIn("processing_interruption", first_payload["payload"]) + self.assertNotIn("interruption", first_payload["payload"]) + self.assertEqual( + first_payload["payload"]["events"], + [{"type": "idle_nudge", "text": "Alo, voce esta ai?"}], + ) + + reply_2 = await adapter.wait_for_server_push() + self.assertEqual( + reply_2, + BackendReply( + stage="PRESENTATION", + text="Esses sao os principais fatores que podem gerar a diferenca na fatura.", + done=False, + export_payload={ + "type": "final", + "content": "Esses sao os principais fatores que podem gerar a diferenca na fatura.", + "tool_calls": [], + }, + metadata={ + "message_id": connection_message_id, + "agent_message_type": "proactive_result", + "agent_result_type": "final", + "expects_user_response": True, + "drop_user_input_while_speaking": False, + }, + ), + ) + self.assertEqual(len(ws.sent_messages), 1) + + async def test_conta_run_parses_top_level_final_content(self) -> None: + fake_ws_module = _FakeWebsocketsModule( + sessions=[ + { + "initial": [ + json.dumps( + { + "type": "ready", + "message": "Posso verificar sua fatura.", + "actions": ["chat", "ping"], + } + ) + ], + "after_sends": [ + [ + json.dumps( + { + "type": "final", + "content": "Infelizmente **não encontrei** ", + } + ) + ] + ], + } + ] + ) + + with mock.patch.dict(sys.modules, {"websockets": fake_ws_module}): + adapter = RemoteAgentWSAdapter( + intro="oi", + url="ws://agent.internal/conta", + request_context={ + "agent": "conta", + "RouterCallKeyDay": "20260329", + "RouterCallKey": "0006", + "ANI": "3133330000", + "GSM": "5511999900000", + "callIdGed": "GED-556", + }, + ) + await adapter.prepare(True, "PRT-556") + + ready_reply = await adapter.run("") + self.assertEqual(ready_reply.text, "Posso verificar sua fatura.") + + reply = await adapter.run("teste") + connection_message_id = _assert_uuid(self, reply.metadata["message_id"]) + + self.assertEqual( + reply, + BackendReply( + stage="PRESENTATION", + text="Infelizmente **não encontrei**", + done=False, + export_payload=None, + metadata={ + "message_id": connection_message_id, + "agent_message_type": "final", + "expects_user_response": True, + "drop_user_input_while_speaking": False, + }, + ), + ) + + async def test_conta_feedback_is_buffered_and_run_waits_for_top_level_final(self) -> None: + feedback_payload = { + "type": "feedback", + "text": "Ainda estou consultando sua fatura.", + } + fake_ws_module = _FakeWebsocketsModule( + sessions=[ + { + "initial": [ + json.dumps( + { + "type": "ready", + "message": "Posso verificar sua fatura.", + "actions": ["chat", "ping"], + } + ) + ], + "after_sends": [ + [ + json.dumps(feedback_payload), + json.dumps( + { + "type": "final", + "content": "Encontrei os detalhes da sua fatura.", + } + ), + ] + ], + } + ] + ) + + with mock.patch.dict(sys.modules, {"websockets": fake_ws_module}): + adapter = RemoteAgentWSAdapter( + intro="oi", + url="ws://agent.internal/conta", + request_context={ + "agent": "conta", + "GSM": "5511999900000", + "callIdGed": "GED-556", + }, + ) + await adapter.prepare(True, "PRT-556") + + ready_reply = await adapter.run("") + self.assertEqual(ready_reply.text, "Posso verificar sua fatura.") + + reply = await adapter.run("teste") + feedback_reply = await adapter.wait_for_server_push() + connection_message_id = _assert_uuid(self, reply.metadata["message_id"]) + + self.assertEqual( + reply, + BackendReply( + stage="PRESENTATION", + text="Encontrei os detalhes da sua fatura.", + done=False, + export_payload=None, + metadata={ + "message_id": connection_message_id, + "agent_message_type": "final", + "expects_user_response": True, + "drop_user_input_while_speaking": False, + }, + ), + ) + self.assertEqual( + feedback_reply, + BackendReply( + stage="PRESENTATION", + text="Ainda estou consultando sua fatura.", + done=False, + export_payload=None, + metadata={ + "message_id": connection_message_id, + "agent_message_type": "feedback", + "expects_user_response": False, + "drop_user_input_while_speaking": True, + "is_interruptible": False, + "event": "feedback", + "payload": feedback_payload, + }, + ), + ) + + async def test_conta_run_marks_expected_agent_result_type_as_done(self) -> None: + fake_ws_module = _FakeWebsocketsModule( + sessions=[ + { + "initial": [ + json.dumps( + { + "type": "ready", + "message": "Posso verificar sua fatura.", + "actions": ["chat", "ping"], + } + ) + ], + "after_sends": [ + [ + json.dumps( + { + "type": "result", + "action": "chat", + "result": { + "type": "resolvido", + "content": "Atendimento encerrado com sucesso.", + "tool_calls": [ + { + "id": "call-final", + "name": "finalizar_atendimento", + "args": { + "status": "resolvido", + "summary": "Resumo da conversa.", + }, + "result": { + "success": True, + "status": "resolvido", + "summary": "Encerramento realizado pelo agente.", + }, + } + ], + }, + } + ) + ] + ], + } + ] + ) + + with mock.patch.dict(sys.modules, {"websockets": fake_ws_module}): + adapter = RemoteAgentWSAdapter( + intro="oi", + url="ws://agent.internal/conta", + request_context={ + "agent": "conta", + "GSM": "5511999900000", + "callIdGed": "GED-560", + }, + ) + await adapter.prepare(True, "PRT-560") + + ready_reply = await adapter.run("") + self.assertEqual(ready_reply.text, "Posso verificar sua fatura.") + + reply = await adapter.run("pode encerrar") + connection_message_id = _assert_uuid(self, reply.metadata["message_id"]) + + self.assertEqual( + reply, + BackendReply( + stage="DONE", + text="Atendimento encerrado com sucesso.", + done=True, + export_payload={ + "type": "resolvido", + "content": "Atendimento encerrado com sucesso.", + "tool_calls": [ + { + "id": "call-final", + "name": "finalizar_atendimento", + "args": { + "status": "resolvido", + "summary": "Resumo da conversa.", + }, + "result": { + "success": True, + "status": "resolvido", + "summary": "Encerramento realizado pelo agente.", + }, + } + ], + }, + metadata={ + "message_id": connection_message_id, + "agent_message_type": "result", + "agent_result_type": "resolvido", + "expects_user_response": False, + "drop_user_input_while_speaking": True, + "is_interruptible": False, + }, + ), + ) + + async def test_conta_result_feedback_is_buffered_and_run_waits_for_final(self) -> None: + feedback_result = { + "type": "feedback", + "content": "Perfeito, seguiremos com o cancelamento. Aguarde um instante.", + "speech_id": "332a02d1-031d-4d08-b14a-f896fe630fbc", + "is_interruptible": True, + "tool_calls": [], + "metadata": {"stage": "feedback"}, + } + feedback_payload = { + "type": "result", + "action": "chat", + "result": feedback_result, + } + fake_ws_module = _FakeWebsocketsModule( + sessions=[ + { + "initial": [ + json.dumps( + { + "type": "ready", + "message": "Posso verificar sua fatura.", + "actions": ["chat", "ping"], + } + ) + ], + "after_sends": [ + [ + json.dumps(feedback_payload), + json.dumps( + { + "type": "result", + "action": "chat", + "result": { + "type": "final", + "content": "Cancelamento concluido.", + "tool_calls": [], + }, + } + ), + ] + ], + } + ] + ) + + with mock.patch.dict(sys.modules, {"websockets": fake_ws_module}): + adapter = RemoteAgentWSAdapter( + intro="oi", + url="ws://agent.internal/conta", + request_context={ + "agent": "conta", + "GSM": "5511999900000", + "callIdGed": "GED-561", + }, + ) + await adapter.prepare(True, "PRT-561") + + ready_reply = await adapter.run("") + self.assertEqual(ready_reply.text, "Posso verificar sua fatura.") + + reply = await adapter.run("correto") + feedback_reply = await adapter.wait_for_server_push() + connection_message_id = _assert_uuid(self, reply.metadata["message_id"]) + + self.assertEqual( + reply, + BackendReply( + stage="PRESENTATION", + text="Cancelamento concluido.", + done=False, + export_payload={ + "type": "final", + "content": "Cancelamento concluido.", + "tool_calls": [], + }, + metadata={ + "message_id": connection_message_id, + "agent_message_type": "result", + "agent_result_type": "final", + "expects_user_response": True, + "drop_user_input_while_speaking": False, + }, + ), + ) + self.assertEqual( + feedback_reply, + BackendReply( + stage="PRESENTATION", + text="Perfeito, seguiremos com o cancelamento. Aguarde um instante.", + done=False, + export_payload=None, + metadata={ + "message_id": connection_message_id, + "speech_id": "332a02d1-031d-4d08-b14a-f896fe630fbc", + "is_interruptible": False, + "agent_message_type": "result", + "agent_result_type": "feedback", + "expects_user_response": False, + "drop_user_input_while_speaking": True, + "event": "feedback", + "payload": feedback_payload, + }, + ), + ) + + async def test_conta_wait_for_server_push_receives_proactive_result_without_new_run(self) -> None: + fake_ws_module = _FakeWebsocketsModule( + sessions=[ + [ + json.dumps( + { + "type": "ready", + "message": "Posso verificar sua fatura.", + "actions": ["chat", "ping"], + } + ), + json.dumps( + { + "type": "proactive_result", + "action": "explicar_fatura", + "result": { + "type": "final", + "content": "A variacao entre a fatura atual e a anterior pode ocorrer por alguns motivos comuns.", + "tool_calls": [], + }, + } + ), + ] + ] + ) + + with mock.patch.dict(sys.modules, {"websockets": fake_ws_module}): + adapter = RemoteAgentWSAdapter( + intro="oi", + url="ws://agent.internal/conta", + request_context={ + "agent": "conta", + "RouterCallKeyDay": "20260329", + "RouterCallKey": "0007", + "ANI": "3133330001", + "GSM": "5511999900001", + "callIdGed": "GED-557", + }, + ) + await adapter.prepare(True, "PRT-557") + + ready_reply = await adapter.run("") + self.assertEqual(ready_reply.text, "Posso verificar sua fatura.") + + push_reply = await adapter.wait_for_server_push() + connection_message_id = _assert_uuid(self, push_reply.metadata["message_id"]) + self.assertEqual( + push_reply, + BackendReply( + stage="PRESENTATION", + text="A variacao entre a fatura atual e a anterior pode ocorrer por alguns motivos comuns.", + done=False, + export_payload={ + "type": "final", + "content": "A variacao entre a fatura atual e a anterior pode ocorrer por alguns motivos comuns.", + "tool_calls": [], + }, + metadata={ + "message_id": connection_message_id, + "agent_message_type": "proactive_result", + "agent_result_type": "final", + "expects_user_response": True, + "drop_user_input_while_speaking": False, + }, + ), + ) + + async def test_conta_can_send_new_chat_after_idle_period_without_killing_reader(self) -> None: + fake_ws_module = _FakeWebsocketsModule( + sessions=[ + { + "initial": [ + json.dumps( + { + "type": "ready", + "message": "Posso verificar sua fatura.", + "actions": ["chat", "ping"], + } + ) + ], + "after_sends": [ + [ + json.dumps( + { + "type": "proactive_result", + "action": "explicar_fatura", + "result": { + "type": "final", + "content": "Resumo inicial da fatura.", + "tool_calls": [], + }, + } + ) + ], + [ + json.dumps( + { + "type": "final", + "content": "Os servicos ativos sao Netflix e Deezer.", + } + ) + ], + ], + } + ] + ) + + with mock.patch.dict(sys.modules, {"websockets": fake_ws_module}): + adapter = RemoteAgentWSAdapter( + intro="oi", + url="ws://agent.internal/conta", + request_context={ + "agent": "conta", + "RouterCallKeyDay": "20260329", + "RouterCallKey": "0008", + "ANI": "3133330002", + "GSM": "5511999900002", + "callIdGed": "GED-558", + }, + read_timeout_s=0.1, + ) + await adapter.prepare(True, "PRT-558") + + ready_reply = await adapter.run("") + self.assertEqual(ready_reply.text, "Posso verificar sua fatura.") + + first_reply = await adapter.run("quero detalhes") + self.assertEqual(first_reply.text, "Resumo inicial da fatura.") + + await asyncio.sleep(0.12) + + second_reply = await adapter.run("Quais servicos eu tenho?") + self.assertEqual(second_reply.text, "Os servicos ativos sao Netflix e Deezer.") + + async def test_conta_end_service_once_skips_end_action_when_backend_does_not_advertise_it(self) -> None: + fake_ws_module = _FakeWebsocketsModule( + sessions=[ + [ + json.dumps( + { + "type": "ready", + "message": "Posso verificar sua fatura.", + "actions": ["chat", "ping"], + } + ) + ] + ] + ) + + with mock.patch.dict(sys.modules, {"websockets": fake_ws_module}): + adapter = RemoteAgentWSAdapter( + intro="oi", + url="ws://agent.internal/conta", + request_context={ + "agent": "conta", + "RouterCallKeyDay": "20260329", + "RouterCallKey": "0009", + "ANI": "3133330003", + "GSM": "5511999900003", + "callIdGed": "GED-559", + }, + ) + await adapter.prepare(True, "PRT-559") + + ready_reply = await adapter.run("") + self.assertEqual(ready_reply.text, "Posso verificar sua fatura.") + + end_reply = await adapter.end_service_once() + self.assertEqual( + end_reply, + BackendReply( + stage="DONE", + text="", + done=True, + export_payload=[], + ), + ) + + ws = fake_ws_module.calls[0]["websocket"] + self.assertEqual(ws.sent_messages, []) + + async def test_run_accumulates_streamed_chunks_until_done(self) -> None: + fake_ws_module = _FakeWebsocketsModule( + sessions=[ + [ + json.dumps({"type": "delta", "delta": "Oferta "}), + json.dumps({"type": "delta", "delta": "liberada"}), + json.dumps({"type": "done", "stage": "DONE"}), + ] + ] + ) + + with mock.patch.dict(sys.modules, {"websockets": fake_ws_module}): + adapter = RemoteAgentWSAdapter( + intro="oi", + url="ws://agent.internal/turn", + url_by_agent={"cobranca": "ws://agent.internal/cobranca"}, + request_context={ + "agent": "cobra", + "RouterCallKeyDay": "20260329", + "RouterCallKey": "0002", + "ANI": "3133335555", + "GSM": "31988887777", + "callIdGed": "GED-456", + }, + ) + await adapter.prepare(True, "PRT-456") + + reply = await adapter.run("teste") + + self.assertEqual( + reply, + BackendReply( + stage="DONE", + text="Oferta liberada", + done=True, + export_payload=None, + ), + ) + payload = json.loads(fake_ws_module.calls[0]["websocket"].sent_messages[0]) + self.assertEqual(fake_ws_module.calls[0]["url"], "ws://agent.internal/cobranca") + self.assertEqual(payload["agent"], "cobranca") + self.assertEqual(payload["callIdGed"], "GED-456") + self.assertNotIn("ID_FATURA", payload) + + async def test_run_uses_default_stage_when_remote_does_not_return_stage(self) -> None: + fake_ws_module = _FakeWebsocketsModule( + sessions=[ + [ + json.dumps({"type": "final", "text": "Resposta sem stage."}), + ] + ] + ) + + with mock.patch.dict(sys.modules, {"websockets": fake_ws_module}): + adapter = RemoteAgentWSAdapter( + intro="oi", + url="ws://agent.internal/turn", + default_stage="presentation", + request_context={ + "agent": "ofert", + "RouterCallKeyDay": "20260329", + "RouterCallKey": "0003", + "ANI": "3133336666", + "GSM": "31977776666", + "callIdGed": "GED-777", + }, + ) + await adapter.prepare(True, "PRT-777") + + reply = await adapter.run("teste") + + self.assertEqual( + reply, + BackendReply( + stage="PRESENTATION", + text="Resposta sem stage.", + done=False, + export_payload=None, + ), + ) + + async def test_end_service_once_returns_remote_result_and_is_idempotent(self) -> None: + fake_ws_module = _FakeWebsocketsModule( + sessions=[ + [ + json.dumps( + { + "type": "done", + "result": [{"success_purchase": 1, "protocol": "PRT-999"}], + } + ) + ] + ] + ) + + with mock.patch.dict(sys.modules, {"websockets": fake_ws_module}): + adapter = RemoteAgentWSAdapter( + intro="oi", + url="ws://agent.internal/end", + request_context={ + "agent": "oferta", + "RouterCallKeyDay": "20260329", + "RouterCallKey": "0004", + "ANI": "3133337777", + "GSM": "31966665555", + "callIdGed": "GED-999", + }, + ) + await adapter.prepare(True, "PRT-999") + + output_1 = await adapter.end_service_once() + output_2 = await adapter.end_service_once() + + self.assertEqual( + output_1, + BackendReply( + stage="DONE", + text="", + done=True, + export_payload=[{"success_purchase": 1, "protocol": "PRT-999"}], + ), + ) + self.assertEqual(output_2, output_1) + self.assertEqual(len(fake_ws_module.calls), 1) + + +class BackendFactoryTests(unittest.TestCase): + def test_selects_remote_ws_backend(self) -> None: + with mock.patch.dict( + os.environ, + { + "AGENT_BACKEND": "remote_ws", + "REMOTE_AGENT_WS_URL": "ws://agent.internal/turn", + "REMOTE_AGENT_WS_URL_CONTA": "ws://agent.internal/conta", + }, + clear=False, + ): + backend = build_agent_backend( + intro="oi", + remote_agent_context={"agent": "conta"}, + streaming=False, + ) + + self.assertIsInstance(backend, RemoteAgentWSAdapter) + self.assertEqual(backend._url, "ws://agent.internal/turn") + + def test_selects_fake_remote_ws_backend(self) -> None: + with mock.patch.dict( + os.environ, + { + "REMOTE_AGENT_WS_URL": "ws://agent.internal/real", + "REMOTE_AGENT_WS_FAKE_URL": "ws://127.0.0.1:8000/fake-agent/ws", + }, + clear=False, + ): + backend = build_agent_backend( + intro="oi", + backend_name="remote_ws_fake", + remote_agent_context={"agent": "conta"}, + streaming=False, + ) + + self.assertIsInstance(backend, FakeRemoteWSAdapter) + self.assertEqual(backend._backend_label, "remote_ws_fake") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/livekit/test_runtime.py b/tests/livekit/test_runtime.py new file mode 100644 index 0000000..5cd6dd6 --- /dev/null +++ b/tests/livekit/test_runtime.py @@ -0,0 +1,4802 @@ +from __future__ import annotations + +import asyncio +import importlib +import json +import os +import sys +import time +import types +import unittest +import uuid +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any +from unittest import mock +from types import SimpleNamespace + + +def _install_fake_livekit() -> None: + try: + importlib.import_module("livekit.agents") + return + except ImportError: + pass + + livekit_module = types.ModuleType("livekit") + agents_module = types.ModuleType("livekit.agents") + + class UserInputTranscribedEvent: + def __init__(self, transcript: str = "", is_final: bool = True) -> None: + self.transcript = transcript + self.is_final = is_final + + class _AudioInputOptions: + def __init__(self, **kwargs) -> None: + self.kwargs = kwargs + + class _AudioOutputOptions: + def __init__(self, **kwargs) -> None: + self.kwargs = kwargs + + class _RoomOptions: + def __init__(self, **kwargs) -> None: + self.kwargs = kwargs + + agents_module.UserInputTranscribedEvent = UserInputTranscribedEvent + agents_module.room_io = SimpleNamespace( + AudioInputOptions=_AudioInputOptions, + AudioOutputOptions=_AudioOutputOptions, + RoomOptions=_RoomOptions, + ) + livekit_module.agents = agents_module + + sys.modules["livekit"] = livekit_module + sys.modules["livekit.agents"] = agents_module + + +_install_fake_livekit() + +from app.livekit.policies.finalization_policy import FinalizationPolicy +from app.livekit.policies.idle_policy import IdlePolicy, IdlePolicyConfig +from app.livekit.policies.interrupt_policy import InterruptPolicy +from app.livekit.adapters.speech_service import SpeechService +from app.livekit.adapters.agent_backend import BackendReply +from app.livekit.runtime.call_runtime import ( + CallRuntime, + InflightBackendWaitNoticeResult, + InflightBackendWaitTimedOut, +) +from app.livekit.runtime.command_executor import RuntimeCommandExecutor +from app.livekit.runtime.commands import ( + EndServiceOnce, + StartSpeech, + ExportSession, + ExtractSpokenText, + InjectIdleNudge, + InterruptSpeech, + NotifyBridgeDone, + NotifyBridgeStop, + RunPipelineInput, + SetPendingInterrupt, + SetPipelineInterruption, + SetPipelineProcessingInterruption, + StartSession, +) +from app.livekit.runtime.scheduler import TimerScheduler +from app.livekit.runtime.state import RuntimeConfig +from app.livekit.vad_dynamic_threshold import ( + DynamicVADThresholdConfig, + DynamicVADThresholdController, +) +from app.providers.stt_internal_livekit import stt_text_with_single_word_threshold +from app.utils.logging import StructuredLogContext +from app.utils.turn_ids import ( + peek_started_turn_message_id, + register_started_turn_message_id, + register_transcribed_turn, + reset_turn_message_sequence, +) + +WAIT_LONG_AUDIO_PATH = str( + Path(__file__).resolve().parents[2] + / "src" + / "app" + / "livekit" + / "assets" + / "comfort" + / "long" + / "01.wav" +) +DEFAULT_WAIT_TEXT = "Um momento, ainda estou consultando para te ajudar." + + +def _expected_wait_text_for_audio(path: str | Path, fallback: str = DEFAULT_WAIT_TEXT) -> str: + text_path = Path(path).with_suffix(".txt") + if not text_path.is_file(): + return fallback + + for encoding in ("utf-8-sig", "utf-8", "latin-1"): + try: + text = " ".join(text_path.read_text(encoding=encoding).split()) + except UnicodeDecodeError: + continue + except OSError: + return fallback + return text or fallback + + return fallback + + +class _NullLogger: + def info(self, *args, **kwargs) -> None: + return None + + def debug(self, *args, **kwargs) -> None: + return None + + def warning(self, *args, **kwargs) -> None: + return None + + def exception(self, *args, **kwargs) -> None: + return None + + +class _AsyncTestCase(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self) -> None: + asyncio.get_running_loop().set_debug(False) + + +class _FakeAgent: + def __init__(self) -> None: + self._ready = asyncio.Event() + self._ready.set() + self._run_lock = asyncio.Lock() + self.pipeline = object() + self.pending_interrupts: list[tuple[str, bool, str]] = [] + self._consumed_interrupt = (None, "", False) + self.end_calls = 0 + + async def set_pending_interrupt( + self, + *, + listened_text: str = "", + skipped: bool = False, + speech_id: str = "", + ) -> None: + self.pending_interrupts.append((listened_text, skipped, speech_id)) + + async def consume_pending_interrupt(self): + return self._consumed_interrupt + + async def end_service_once(self): + self.end_calls += 1 + return BackendReply(stage="DONE", done=True, export_payload=[{"status": "ok"}]) + + +class _FakePushPipeline: + def __init__(self, replies) -> None: + self._replies = asyncio.Queue() + for reply in replies: + self._replies.put_nowait(reply) + + def supports_server_push(self) -> bool: + return True + + async def wait_for_server_push(self): + if self._replies.empty(): + return None + return await self._replies.get() + + +class _FakeInflightPushPipeline: + def __init__(self) -> None: + self._replies = asyncio.Queue() + self._closed = asyncio.Event() + + def supports_inflight_backend_push(self) -> bool: + return True + + def supports_server_push(self) -> bool: + return False + + async def push(self, reply) -> None: + await self._replies.put(reply) + + def close(self) -> None: + self._closed.set() + + async def wait_for_server_push(self): + if not self._replies.empty(): + return await self._replies.get() + if self._closed.is_set(): + return None + + get_task = asyncio.create_task(self._replies.get()) + close_task = asyncio.create_task(self._closed.wait()) + done, _pending = await asyncio.wait( + {get_task, close_task}, + return_when=asyncio.FIRST_COMPLETED, + ) + if get_task in done: + close_task.cancel() + await asyncio.gather(close_task, return_exceptions=True) + return get_task.result() + + get_task.cancel() + await asyncio.gather(get_task, return_exceptions=True) + return None + + +class _FakeAgentInput: + def __init__(self) -> None: + self.audio_enabled = True + self.calls = [] + + def set_audio_enabled(self, enabled: bool) -> None: + self.audio_enabled = bool(enabled) + self.calls.append(bool(enabled)) + + +class _FakeSession: + def __init__(self) -> None: + self.handlers = {} + self.start_calls = [] + self.input = _FakeAgentInput() + + def on(self, event_name: str): + def _decorator(fn): + self.handlers[event_name] = fn + return fn + + return _decorator + + async def start(self, **kwargs) -> None: + self.start_calls.append(kwargs) + + +class _FakeRoom: + def __init__(self) -> None: + self.name = "room-test" + self.remote_participants = {} + 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 = [] + + def emit(self, event: str, **fields) -> None: + self.events.append((event, fields)) + + +class _FakeContext: + def __init__(self) -> None: + self.room = _FakeRoom() + self.shutdown_callbacks = [] + + def add_shutdown_callback(self, callback) -> None: + self.shutdown_callbacks.append(callback) + + +class _FakeCommandExecutor: + def __init__(self) -> None: + self.commands = [] + self.pipeline_result = BackendReply(stage="PRESENTATION", text="resposta") + self.end_reply = BackendReply(stage="DONE", done=True, export_payload=[{"status": "ok"}]) + self.spoken_text = "trecho falado" + self.speech_handle = SimpleNamespace( + id="speech-test", + interrupted=False, + ) + self.speech_handles = None + self.run_pipeline_side_effect = None + self.on_start_session: Any = None + self.wait_for_playout_delay_s = 0.0 + self.wait_for_playout_delays_s = [] + self.wait_for_playout_exceptions = [] + self.wait_for_playout_callbacks = [] + + async def execute(self, command): + self.commands.append(command) + if isinstance(command, RunPipelineInput): + if self.run_pipeline_side_effect is not None: + await self.run_pipeline_side_effect() + return self.pipeline_result + if isinstance(command, EndServiceOnce): + return self.end_reply + if isinstance(command, ExportSession): + return None + if isinstance(command, NotifyBridgeDone): + return None + if isinstance(command, NotifyBridgeStop): + return None + if isinstance(command, SetPendingInterrupt): + return None + if command.__class__.__name__ == "StartSpeech": + if self.speech_handles: + return self.speech_handles.pop(0) + return self.speech_handle + if command.__class__.__name__ == "WaitForSpeechPlayout": + if self.wait_for_playout_callbacks: + callback = self.wait_for_playout_callbacks.pop(0) + if callback is not None: + result = callback(command) + if asyncio.iscoroutine(result): + await result + if self.wait_for_playout_exceptions: + exc = self.wait_for_playout_exceptions.pop(0) + if exc is not None: + raise exc + delay_s = ( + self.wait_for_playout_delays_s.pop(0) + if self.wait_for_playout_delays_s + else self.wait_for_playout_delay_s + ) + if delay_s > 0: + await asyncio.sleep(delay_s) + return None + if isinstance(command, InterruptSpeech): + setattr(command.handle, "interrupted", True) + return None + if command.__class__.__name__ == "SetPipelineInterruption": + return None + if command.__class__.__name__ == "InjectIdleNudge": + return None + if command.__class__.__name__ == "StartSession": + if self.on_start_session is not None: + self.on_start_session(command) + return None + raise AssertionError(f"Unsupported command in fake executor: {command!r}") + + def execute_now(self, command): + self.commands.append(command) + if isinstance(command, ExtractSpokenText): + return self.spoken_text + raise AssertionError(f"Unsupported synchronous command: {command!r}") + + +class _FakeVADThresholdController: + def __init__(self) -> None: + self.calls = [] + self.active = False + + def activate_agent_wait_timeout_retry(self, *, attempt: int, reason: str) -> bool: + if self.active: + return False + self.calls.append(("activate", attempt, reason)) + self.active = True + return True + + def restore(self, *, reason: str) -> bool: + if not self.active: + return False + self.calls.append(("restore", reason)) + self.active = False + return True + + +class _FakeVAD: + def __init__(self) -> None: + self.update_options_calls = [] + + def update_options(self, **kwargs) -> None: + self.update_options_calls.append(kwargs) + + +class _AwaitableSpeechHandle: + def __init__(self) -> None: + self.awaited = False + + async def wait_for_playout(self) -> None: + return None + + def interrupt(self, *, force: bool = False) -> None: + return None + + def __await__(self): + async def _wait(): + self.awaited = True + return self + + return _wait().__await__() + + +class SpeechServiceTests(_AsyncTestCase): + async def test_start_returns_livekit_speech_handle_without_waiting_for_playout(self) -> None: + handle = _AwaitableSpeechHandle() + session = SimpleNamespace( + say=lambda *args, **kwargs: handle, + ) + service = SpeechService(session) + + result = await service.start( + "texto", + allow_interruptions=True, + add_to_chat_ctx=True, + ) + + self.assertIs(result, handle) + self.assertFalse(handle.awaited) + + async def test_start_passes_provided_audio_to_livekit_session(self) -> None: + handle = _AwaitableSpeechHandle() + calls = [] + audio = object() + session = SimpleNamespace( + say=lambda *args, **kwargs: calls.append((args, kwargs)) or handle, + ) + service = SpeechService(session) + + result = await service.start( + "texto", + allow_interruptions=True, + add_to_chat_ctx=False, + audio=audio, + ) + + self.assertIs(result, handle) + self.assertIs(calls[0][1]["audio"], audio) + self.assertFalse(calls[0][1]["add_to_chat_ctx"]) + + +class DynamicVADThresholdControllerTests(unittest.TestCase): + def test_activate_and_restore_updates_vad_options(self) -> None: + vad = _FakeVAD() + controller = DynamicVADThresholdController( + vad, + config=DynamicVADThresholdConfig( + enabled=True, + baseline_activation_threshold=0.30, + baseline_deactivation_threshold=0.15, + retry_activation_threshold=0.20, + retry_deactivation_threshold=0.05, + ), + ) + + self.assertEqual( + controller.current_thresholds(), + { + "mode": "baseline", + "activation_threshold": 0.30, + "deactivation_threshold": 0.15, + }, + ) + self.assertTrue( + controller.activate_agent_wait_timeout_retry( + attempt=1, + reason="agent_wait_timeout_retry", + ) + ) + self.assertEqual( + controller.current_thresholds(), + { + "mode": "agent_wait_timeout_retry", + "activation_threshold": 0.20, + "deactivation_threshold": 0.05, + }, + ) + self.assertTrue(controller.restore(reason="user_final")) + self.assertEqual( + controller.current_thresholds(), + { + "mode": "baseline", + "activation_threshold": 0.30, + "deactivation_threshold": 0.15, + }, + ) + + self.assertEqual( + vad.update_options_calls, + [ + { + "activation_threshold": 0.20, + "deactivation_threshold": 0.05, + }, + { + "activation_threshold": 0.30, + "deactivation_threshold": 0.15, + }, + ], + ) + + +class InterruptPolicyTests(unittest.TestCase): + def test_allow_stage_and_backchannel(self) -> None: + policy = InterruptPolicy() + self.assertTrue(policy.allow_stage("ARGUMENTATION")) + self.assertFalse(policy.allow_stage("FORMALIZATION")) + self.assertFalse(policy.allow_stage("IDLE_NUDGE")) + self.assertTrue(policy.allow_stage("AGENT_WAIT_TIMEOUT_RETRY")) + self.assertTrue(policy.allow_stage("unknown-stage")) + self.assertTrue(policy.should_ignore_backchannel(speaking=True, text="uhum")) + self.assertFalse(policy.should_ignore_backchannel(speaking=False, text="uhum")) + + +class IdlePolicyTests(unittest.TestCase): + def test_idle_policy_decisions(self) -> None: + policy = IdlePolicy( + IdlePolicyConfig( + enabled=True, + delay_s=10.0, + join_delay_s=15.0, + close_delay_s=20.0, + max_tries=3, + end_reason="no_user_response", + ) + ) + + self.assertTrue(policy.should_arm_nudge(delay_s=10.0, nudge_text="alo")) + self.assertFalse(policy.should_arm_nudge(delay_s=0.0, nudge_text="alo")) + self.assertTrue( + policy.should_fire_nudge( + token_is_current=True, + seq_matches=True, + speaking=False, + gap_active=False, + finalized=False, + current_stage="PRESENTATION", + ) + ) + self.assertFalse( + policy.should_fire_nudge( + token_is_current=True, + seq_matches=True, + speaking=False, + gap_active=False, + finalized=False, + current_stage="DONE", + ) + ) + self.assertTrue(policy.should_arm_close_after_nudge(stage="IDLE_NUDGE", nudge_count=3)) + + def test_idle_policy_is_disabled_by_default(self) -> None: + policy = IdlePolicy( + IdlePolicyConfig( + enabled=False, + delay_s=10.0, + join_delay_s=15.0, + close_delay_s=20.0, + max_tries=3, + end_reason="no_user_response", + ) + ) + + self.assertFalse(policy.should_arm_nudge(delay_s=10.0, nudge_text="alo")) + self.assertFalse(policy.should_arm_close_before_fire(nudge_count=3)) + self.assertFalse(policy.should_arm_close_after_nudge(stage="IDLE_NUDGE", nudge_count=3)) + + +class FinalizationPolicyTests(unittest.TestCase): + def test_finalization_decisions(self) -> None: + policy = FinalizationPolicy(call_end_grace_s=2.0) + self.assertTrue(policy.should_skip(finalized=True)) + self.assertFalse(policy.should_skip(finalized=False)) + self.assertTrue(policy.should_finalize_room_empty(remote_participants=0, finalized=False)) + self.assertFalse(policy.should_finalize_room_empty(remote_participants=1, finalized=False)) + self.assertTrue(policy.is_done_stage("DONE")) + self.assertFalse(policy.is_done_stage("PRESENTATION")) + + +class TimerSchedulerTests(_AsyncTestCase): + async def test_arm_updates_token_and_cancel_stops_task(self) -> None: + created_tasks = [] + logger = _NullLogger() + + def _create_task_logged(coro, *, name: str): + task = asyncio.create_task(coro, name=name) + created_tasks.append(task) + return task + + scheduler = TimerScheduler(create_task_logged=_create_task_logged, logger=logger) + gate = asyncio.Event() + + async def _wait_forever(): + await gate.wait() + + token1 = scheduler.arm("idle", task_name="idle_1", coro=_wait_forever()) + token2 = scheduler.arm("idle", task_name="idle_2", coro=_wait_forever()) + + self.assertFalse(scheduler.is_current("idle", token1)) + self.assertTrue(scheduler.is_current("idle", token2)) + + scheduler.cancel("idle", reason="test", log_name="IDLE_TIMER_CANCEL") + await asyncio.sleep(0) + + self.assertTrue(created_tasks[-1].cancelled()) + gate.set() + await asyncio.gather(*created_tasks, return_exceptions=True) + + +class RuntimeCommandExecutorTests(_AsyncTestCase): + async def test_routes_commands_to_underlying_services(self) -> None: + agent = _FakeAgent() + bridge_gateway = SimpleNamespace(notify_stage_done=self._make_async_noop()) + bridge_gateway.notify_stop = self._make_async_noop() + export_service = SimpleNamespace(export_session=self._make_async_noop()) + session = _FakeSession() + speech_service = SimpleNamespace( + start=self._make_async_return("speech-handle"), + wait_for_playout=self._make_async_noop(), + extract_spoken_text=lambda handle: "spoken", + ) + agent.pipeline = SimpleNamespace( + inject_idle_nudge=self._make_async_noop(), + set_interruption=self._make_async_noop(), + run=self._make_async_return(BackendReply(stage="DONE", text="encerrando", done=True)), + ) + + executor = RuntimeCommandExecutor( + agent=agent, + bridge_gateway=bridge_gateway, + export_service=export_service, + session=session, + speech_service=speech_service, + ) + + result = await executor.execute(RunPipelineInput("payload")) + self.assertEqual(result, BackendReply(stage="DONE", text="encerrando", done=True)) + self.assertEqual(executor.execute_now(ExtractSpokenText(object())), "spoken") + + @staticmethod + def _make_async_noop(): + async def _noop(*args, **kwargs): + return None + + return _noop + + @staticmethod + def _make_async_return(value): + async def _return(*args, **kwargs): + return value + + return _return + + +class CallRuntimeTests(_AsyncTestCase): + def setUp(self) -> None: + self.created_tasks = [] + + def _create_task_logged(self, coro, *, name: str): + task = asyncio.create_task(coro, name=name) + self.created_tasks.append(task) + return task + + async def _drain_tasks(self) -> None: + while True: + pending = [task for task in self.created_tasks if not task.done()] + if not pending: + return + await asyncio.gather(*pending) + + async def _cancel_pending_tasks(self) -> None: + pending = [task for task in self.created_tasks if not task.done()] + for task in pending: + task.cancel() + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + def test_inflight_backend_wait_message_id_uses_uuid_when_original_is_missing(self) -> None: + generated = uuid.UUID("12345678-1234-4234-9234-123456789abc") + + with mock.patch("app.livekit.runtime.call_runtime.uuid.uuid4", return_value=generated): + message_id = CallRuntime._inflight_backend_wait_message_id(attempt=1) + + self.assertEqual( + message_id, + "12345678-1234-4234-9234-123456789abc_conforto_12345678123442349234123456789abc", + ) + + def test_feedback_message_id_keeps_the_agent_speech_id(self) -> None: + message_id = CallRuntime._feedback_message_id( + message_id="turno-1", + speech_id="speech-feedback-1", + ) + + self.assertEqual(message_id, "turno-1_feedback_speech-feedback-1") + self.assertEqual(CallRuntime._base_message_id(message_id), "turno-1") + + def test_auxiliary_message_ids_are_distinct_and_keep_the_base(self) -> None: + generated = uuid.UUID("12345678-1234-4234-9234-123456789abc") + with mock.patch("app.livekit.runtime.call_runtime.uuid.uuid4", return_value=generated): + interruption = CallRuntime._interruption_comfort_message_id(message_id="turno-1") + tts_error = CallRuntime._tts_error_message_id(message_id="turno-1") + + self.assertEqual(CallRuntime._idle_nudge_message_id(message_id="turno-1", attempt=2), "turno-1_idle_nudge_2") + self.assertEqual(interruption, "turno-1_interruption_confort_12345678123442349234123456789abc") + self.assertEqual(tts_error, "turno-1_tts_error_12345678123442349234123456789abc") + self.assertEqual(CallRuntime._base_message_id(interruption), "turno-1") + self.assertEqual(CallRuntime._base_message_id(tts_error), "turno-1") + self.assertTrue( + CallRuntime._transfer_message_id(message_id="turno-1").startswith( + "turno-1_transferencia_" + ) + ) + self.assertTrue( + CallRuntime._resource_error_message_id( + message_id="turno-1", + terminal=True, + tipo_evento="envio msg", + ).startswith("turno-1_erro_terminal_envio_") + ) + + async def test_backend_feedback_uses_a_suffixed_message_id(self) -> None: + runtime, _agent, _executor = self._make_runtime() + runtime._ctx.room.remote_participants = {"user-1": object()} + + with mock.patch.object(runtime, "say_stage", new_callable=mock.AsyncMock) as say_stage: + await runtime._speak_backend_reply( + BackendReply( + stage="PRESENTATION", + text="Ainda estou consultando.", + metadata={ + "message_id": "turno-feedback", + "speech_id": "speech-feedback-1", + "agent_message_type": "feedback", + }, + ), + source="backend_push", + add_to_chat_ctx=True, + ) + + self.assertEqual( + say_stage.await_args.kwargs["message_id"], + "turno-feedback_feedback_speech-feedback-1", + ) + self.assertEqual(say_stage.await_args.kwargs["speech_id"], "speech-feedback-1") + + def _make_runtime( + self, + *, + agent_starts_conversation: bool = False, + push_replies=None, + runtime_config_overrides=None, + idle_policy_overrides=None, + vad_threshold_controller=None, + ): + agent = _FakeAgent() + if push_replies is not None: + agent.pipeline = _FakePushPipeline(push_replies) + session = _FakeSession() + ctx = _FakeContext() + executor = _FakeCommandExecutor() + config_kwargs = { + "call_end_grace_s": 0.0, + "final_grace_s": 0.0, + "idle_nudge_delay_s": 0.0, + "idle_nudge_join_delay_s": 0.0, + "idle_nudge_close_delay_s": 0.0, + "idle_nudge_max_tries": 0, + "idle_nudge_end_reason": "no_user_response", + } + if runtime_config_overrides: + config_kwargs.update(runtime_config_overrides) + idle_policy_kwargs = { + "enabled": False, + "delay_s": 0.0, + "join_delay_s": 0.0, + "close_delay_s": 0.0, + "max_tries": 0, + "end_reason": "no_user_response", + } + if idle_policy_overrides: + idle_policy_kwargs.update(idle_policy_overrides) + runtime = CallRuntime( + ctx=ctx, + session=session, + agent=agent, + command_executor=executor, + call_logger=_NullLogger(), + protocol="PRT-1", + session_id="S-1", + bridge_identity="bridge-1", + agent_starts_conversation=agent_starts_conversation, + nudge_text="alo", + call_t0=0.0, + extract_text_from_transcript=lambda transcript: transcript, + interrupt_policy=InterruptPolicy(), + idle_policy=IdlePolicy(IdlePolicyConfig(**idle_policy_kwargs)), + finalization_policy=FinalizationPolicy(call_end_grace_s=0.0), + create_task_logged=self._create_task_logged, + config=RuntimeConfig(**config_kwargs), + logger=_NullLogger(), + vad_threshold_controller=vad_threshold_controller, + ) + return runtime, agent, executor + + def test_inflight_backend_wait_uses_short_audio_then_long_audio(self) -> None: + with TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) + short_dir = base_dir / "short" + long_dir = base_dir / "long" + short_dir.mkdir() + long_dir.mkdir() + short_audio = short_dir / "curto.wav" + long_audio = long_dir / "longo.wav" + short_audio.write_bytes(b"short") + long_audio.write_bytes(b"long") + + runtime, _agent, _executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_short_audio_dir": str(short_dir), + "inflight_backend_wait_long_audio_dir": str(long_dir), + } + ) + + self.assertEqual( + runtime._inflight_backend_wait_audio_path(attempt=1), + short_audio, + ) + self.assertEqual( + runtime._inflight_backend_wait_audio_path(attempt=2), + long_audio, + ) + + def test_inflight_backend_wait_prewarms_audio_duration_cache(self) -> None: + with TemporaryDirectory() as tmp_dir: + short_dir = Path(tmp_dir) / "short" + short_dir.mkdir() + short_audio = short_dir / "curto.wav" + short_audio.write_bytes(b"fake-wav") + + with mock.patch( + "app.livekit.runtime.call_runtime.wav_duration_ms", + return_value=321, + ) as duration_ms: + runtime, _agent, _executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_short_audio_dir": str(short_dir), + } + ) + + duration_ms.assert_called_once_with(str(short_audio)) + with mock.patch( + "app.livekit.runtime.call_runtime.wav_duration_ms", + side_effect=AssertionError("duration should come from cache"), + ): + self.assertEqual( + runtime._inflight_backend_wait_audio_duration_ms(short_audio), + 321, + ) + + def test_inflight_backend_wait_keeps_legacy_long_audio_path_fallback(self) -> None: + runtime, _agent, _executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + + self.assertEqual( + runtime._inflight_backend_wait_audio_path(), + Path(WAIT_LONG_AUDIO_PATH), + ) + + async def test_idle_nudge_does_not_activate_retry_vad_threshold(self) -> None: + controller = _FakeVADThresholdController() + runtime, _agent, executor = self._make_runtime( + idle_policy_overrides={ + "enabled": True, + "delay_s": 10.0, + "join_delay_s": 10.0, + "close_delay_s": 10.0, + "max_tries": 2, + "end_reason": "no_user_response", + }, + vad_threshold_controller=controller, + ) + + try: + runtime.arm_idle_timer(reason="test", delay_s=0.001) + for _ in range(50): + if any(cmd.__class__.__name__ == "StartSpeech" for cmd in executor.commands): + break + await asyncio.sleep(0.001) + + self.assertNotIn(("activate", 1, "idle_nudge_fire"), controller.calls) + start_speech = next( + cmd for cmd in executor.commands if cmd.__class__.__name__ == "StartSpeech" + ) + self.assertEqual(start_speech.text, "alo") + self.assertFalse(start_speech.allow_interruptions) + + runtime.on_user_input_transcribed(SimpleNamespace(transcript="sim", is_final=True)) + self.assertNotIn(("restore", "user_final"), controller.calls) + finally: + await self._cancel_pending_tasks() + + async def test_idle_nudge_uses_a_suffixed_message_id(self) -> None: + runtime, _agent, _executor = self._make_runtime( + idle_policy_overrides={ + "enabled": True, + "delay_s": 10.0, + "join_delay_s": 10.0, + "close_delay_s": 10.0, + "max_tries": 2, + "end_reason": "no_user_response", + } + ) + runtime._last_agent_message_id = "turno-idle" + + try: + with mock.patch.object(runtime, "say_stage", new_callable=mock.AsyncMock) as say_stage: + runtime.arm_idle_timer(reason="test", delay_s=0.001) + for _ in range(50): + if say_stage.await_count: + break + await asyncio.sleep(0.001) + + say_stage.assert_awaited_once() + self.assertEqual( + say_stage.await_args.kwargs["message_id"], + "turno-idle_idle_nudge_1", + ) + finally: + await self._cancel_pending_tasks() + + async def test_on_user_input_transcribed_stashes_when_stage_is_not_interruptible(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.current_stage = "FORMALIZATION" + runtime.speaking.set() + + event = SimpleNamespace(transcript="quero continuar", is_final=True) + with mock.patch("app.livekit.runtime.call_runtime.log_flow_event") as flow_log: + runtime.on_user_input_transcribed(event) + await self._drain_tasks() + + self.assertIsNotNone(runtime.state.pending_user_final) + self.assertEqual(runtime.state.pending_user_final.text, "quero continuar") + self.assertFalse(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + self.assertTrue( + any( + call.args[1] == "interrupt_ignored" + and call.kwargs["reason"] == "stage_not_interruptible" + for call in flow_log.call_args_list + ) + ) + + async def test_on_user_input_transcribed_runs_when_argumentation_is_speaking(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.current_stage = "ARGUMENTATION" + runtime.state.current_speech.allow_interruptions = True + runtime.speaking.set() + + event = SimpleNamespace(transcript="quero continuar", is_final=True) + runtime.on_user_input_transcribed(event) + await self._drain_tasks() + + self.assertIsNone(runtime.state.pending_user_final) + self.assertTrue(any(isinstance(cmd, SetPendingInterrupt) for cmd in executor.commands)) + self.assertTrue(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + + async def test_short_vad_utterance_with_text_is_discarded_while_r1_is_processing(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.user_final_seq = 1 + runtime.state.deferred_interruption.backend_in_flight = True + register_transcribed_turn( + runtime._structured_log_context, + message_id="short-utterance", + transcription="sim", + text="sim", + ) + runtime.note_vad_speech_end(999) + runtime.on_user_input_transcribed(SimpleNamespace(transcript="sim", is_final=True)) + await self._drain_tasks() + + self.assertEqual(runtime.state.user_final_seq, 1) + self.assertEqual(runtime.state.deferred_interruption.long_turns, []) + self.assertFalse(runtime.state.deferred_interruption.special_comfort_sent) + self.assertFalse(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + + async def test_first_long_vad_plays_special_before_empty_stt(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.user_final_seq = 1 + runtime.state.deferred_interruption.backend_in_flight = True + with mock.patch.object(runtime, "_play_deferred_interruption_comfort") as comfort: + runtime.note_vad_speech_end(1000) + comfort.assert_called_once_with() + runtime.on_user_input_transcribed(SimpleNamespace(transcript="", is_final=True)) + await self._drain_tasks() + + self.assertEqual(runtime.state.user_final_seq, 1) + self.assertEqual(runtime.state.deferred_interruption.long_turns, []) + self.assertTrue(runtime.state.deferred_interruption.special_comfort_sent) + self.assertFalse(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + + async def test_special_processing_comfort_starts_protected(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.deferred_interruption.special_comfort_sent = True + + runtime._play_deferred_interruption_comfort() + await self._drain_tasks() + + speech = next(cmd for cmd in executor.commands if isinstance(cmd, StartSpeech)) + self.assertEqual(speech.text, "Um instante") + self.assertFalse(speech.allow_interruptions) + + async def test_short_backend_wait_comfort_starts_protected(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._ctx.room.remote_participants = {"user-1": object()} + + with mock.patch.object( + runtime, + "_select_inflight_backend_wait_audio", + return_value=(Path(WAIT_LONG_AUDIO_PATH), "short"), + ): + played = await runtime._play_inflight_backend_wait_notice_audio( + user_seq=None, + attempt=1, + ) + + self.assertTrue(played) + speech = next(cmd for cmd in executor.commands if isinstance(cmd, StartSpeech)) + self.assertFalse(speech.allow_interruptions) + + async def test_short_comfort_is_not_rearmed_by_its_originating_user_final(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.speaking.set() + runtime.state.current_speech.stage = "AGENT_BACKEND_WAIT" + runtime.state.current_speech.handle = executor.speech_handle + runtime.state.current_speech.allow_interruptions = False + runtime.state.current_speech.rearm_interruptions_on_next_user_speech = True + executor.speech_handle.allow_interruptions = False + + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="fala original", is_final=True) + ) + await self._drain_tasks() + + self.assertFalse(runtime.state.current_speech.allow_interruptions) + self.assertFalse(executor.speech_handle.allow_interruptions) + self.assertTrue( + runtime.state.current_speech.rearm_interruptions_on_next_user_speech + ) + + async def test_short_comfort_is_rearmed_on_new_user_speech(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.speaking.set() + runtime.state.current_speech.stage = "INTERRUPTION_COMFORT" + runtime.state.current_speech.handle = executor.speech_handle + runtime.state.current_speech.allow_interruptions = False + runtime.state.current_speech.rearm_interruptions_on_next_user_speech = True + executor.speech_handle.allow_interruptions = False + + runtime._rearm_current_speech_interruptions_on_new_user_speech() + + self.assertTrue(runtime.state.current_speech.allow_interruptions) + self.assertFalse( + runtime.state.current_speech.rearm_interruptions_on_next_user_speech + ) + self.assertTrue(executor.speech_handle.allow_interruptions) + + async def test_feedback_compat_mode_drops_processing_speech_without_replay(self) -> None: + runtime, _agent, executor = self._make_runtime( + runtime_config_overrides={"deferred_interruption_enabled": False} + ) + runtime.state.user_final_seq = 1 + runtime.state.deferred_interruption.backend_in_flight = True + register_transcribed_turn( + runtime._structured_log_context, + message_id="processing-drop", + transcription="quero interromper", + text="quero interromper", + ) + + with ( + mock.patch.object(runtime, "_play_deferred_interruption_comfort") as special, + mock.patch.object(runtime, "_play_deferred_short_comfort") as short, + mock.patch("app.livekit.runtime.call_runtime.log_flow_event") as flow_log, + ): + runtime.note_vad_speech_end(2500) + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="quero interromper", is_final=True) + ) + await self._drain_tasks() + + special.assert_not_called() + short.assert_not_called() + self.assertEqual(runtime.state.user_final_seq, 1) + self.assertEqual(runtime.state.deferred_interruption.long_turns, []) + self.assertEqual(len(runtime.state.deferred_interruption.pending_vad_utterances), 0) + self.assertFalse(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + self.assertTrue( + any( + call.args[1] == "backend_processing_vad_discarded" + and call.kwargs["mode"] == "feedback_compat" + for call in flow_log.call_args_list + ) + ) + self.assertTrue( + any( + call.args[1] == "user_input_dropped" + and call.kwargs["reason"] == "backend_processing_feedback_compat" + and call.kwargs["agent_message_type"] == "feedback" + for call in flow_log.call_args_list + ) + ) + + async def test_vad_end_outside_backend_window_is_not_deferred(self) -> None: + runtime, _agent, _executor = self._make_runtime() + runtime.state.deferred_interruption.backend_in_flight = False + runtime.speaking.set() + + with mock.patch.object(runtime, "_play_deferred_interruption_comfort") as comfort: + runtime.note_vad_speech_end(4000) + + comfort.assert_not_called() + self.assertEqual(len(runtime.state.deferred_interruption.pending_vad_utterances), 0) + self.assertFalse(runtime.state.deferred_interruption.special_comfort_sent) + + async def test_barge_in_during_agent_playout_still_runs_a_new_pipeline(self) -> None: + runtime, agent, executor = self._make_runtime() + runtime.state.current_stage = "ARGUMENTATION" + runtime.state.current_speech.allow_interruptions = True + runtime.speaking.set() + # O playout segura o run_lock, mas a janela do ciclo diferido ja fechou: + # a fala precisa voltar a ser barge-in comum. + await agent._run_lock.acquire() + try: + runtime.note_vad_speech_end(4000) + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="muda de assunto", is_final=True) + ) + await asyncio.sleep(0) + finally: + agent._run_lock.release() + await self._drain_tasks() + + self.assertEqual(runtime.state.deferred_interruption.long_turns, []) + self.assertTrue(any(isinstance(cmd, SetPendingInterrupt) for cmd in executor.commands)) + self.assertTrue(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + + async def test_long_vad_transcripts_are_concatenated_once_and_r2_blocks_replay(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.user_final_seq = 1 + runtime.state.deferred_interruption.backend_in_flight = True + with ( + mock.patch.object(runtime, "_play_deferred_interruption_comfort") as comfort, + mock.patch.object(runtime, "_play_deferred_short_comfort") as short_comfort, + mock.patch.object(runtime, "run_pipeline", new_callable=mock.AsyncMock) as run_pipeline, + ): + runtime.note_vad_speech_end(1200) + runtime.note_vad_speech_end(300) + runtime.note_vad_speech_end(1400) + register_transcribed_turn( + runtime._structured_log_context, + message_id="long-1", + transcription="primeira fala longa", + text="primeira fala longa", + ) + register_transcribed_turn( + runtime._structured_log_context, + message_id="short-1", + transcription="fala curta", + text="fala curta", + ) + register_transcribed_turn( + runtime._structured_log_context, + message_id="long-2", + transcription="segunda fala longa", + text="segunda fala longa", + ) + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="primeira fala longa", is_final=True) + ) + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="fala curta", is_final=True) + ) + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="segunda fala longa", is_final=True) + ) + + comfort.assert_called_once_with() + short_comfort.assert_called_once_with(source="repeated_interruption") + self.assertEqual( + [turn.text for turn in runtime.state.deferred_interruption.long_turns], + ["primeira fala longa", "segunda fala longa"], + ) + dispatched = await runtime._dispatch_deferred_interruption( + BackendReply(stage="PRESENTATION", text="resposta R1") + ) + await self._drain_tasks() + + self.assertTrue(dispatched) + self.assertTrue(runtime.state.deferred_interruption.replay_in_flight) + run_pipeline.assert_awaited_once_with( + "primeira fala longa. segunda fala longa", + "primeira fala longa. segunda fala longa", + is_deferred_replay=True, + user_seq=3, + message_id="long-2", + inflight_initial_notice_consumed=True, + ) + + runtime.note_vad_speech_end(1500) + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="nao deve gerar R3", is_final=True) + ) + self.assertEqual(runtime.state.user_final_seq, 3) + self.assertEqual(runtime.state.deferred_interruption.long_turns, []) + self.assertFalse(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + + + async def test_vad_ended_before_r1_is_promoted_when_stt_finishes_during_r1(self) -> None: + runtime, _agent, executor = self._make_runtime() + backend_started = asyncio.Event() + release_backend = asyncio.Event() + runtime.state.user_final_seq = 0 + + async def _wait_for_backend() -> None: + backend_started.set() + await release_backend.wait() + + executor.run_pipeline_side_effect = _wait_for_backend + with ( + mock.patch.object(runtime, "_play_deferred_interruption_comfort"), + mock.patch.object( + runtime, + "_dispatch_deferred_interruption", + new_callable=mock.AsyncMock, + return_value=True, + ) as dispatch, + ): + # O segundo fim de fala acontece antes de R1, mas seu STT chega + # somente enquanto R1 está em voo. + runtime.note_vad_speech_end(1500) + runtime.note_vad_speech_end(1500) + register_transcribed_turn( + runtime._structured_log_context, + message_id="r1-turn", + transcription="primeiro turno", + text="primeiro turno", + ) + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="primeiro turno", is_final=True) + ) + await backend_started.wait() + + register_transcribed_turn( + runtime._structured_log_context, + message_id="late-turn", + transcription="fala que terminou antes de R1", + text="fala que terminou antes de R1", + ) + runtime.on_user_input_transcribed( + SimpleNamespace( + transcript="fala que terminou antes de R1", is_final=True + ) + ) + release_backend.set() + # O task criado pelo callback pode precisar de um ciclo extra para + # concluir a espera e tentar o dispatch. + await self._drain_tasks() + + dispatch.assert_awaited_once() + self.assertEqual(len(runtime.state.deferred_interruption.pre_backend_vad_utterances), 0) + + async def test_r1_waits_for_pending_long_stt_before_dispatching(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.user_final_seq = 1 + backend_started = asyncio.Event() + release_backend = asyncio.Event() + + async def _wait_for_backend() -> None: + backend_started.set() + await release_backend.wait() + + executor.run_pipeline_side_effect = _wait_for_backend + with ( + mock.patch.object(runtime, "_play_deferred_interruption_comfort"), + mock.patch.object(runtime, "_dispatch_deferred_interruption", new_callable=mock.AsyncMock, return_value=True) as dispatch, + ): + task = asyncio.create_task(runtime.run_pipeline("original", "original", user_seq=1)) + await backend_started.wait() + runtime.note_vad_speech_end(1200) + release_backend.set() + await asyncio.sleep(0) + + self.assertFalse(task.done()) + self.assertFalse(any(isinstance(cmd, StartSpeech) for cmd in executor.commands)) + register_transcribed_turn( + runtime._structured_log_context, + message_id="long-wait", + transcription="fala longa", + text="fala longa", + ) + runtime.on_user_input_transcribed(SimpleNamespace(transcript="fala longa", is_final=True)) + await task + + dispatch.assert_awaited_once() + + async def test_r2_discards_input_until_its_playout_finishes(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + runtime.state.deferred_interruption.replay_in_flight = True + executor.pipeline_result = BackendReply(stage="PRESENTATION", text="resposta R2") + played = [] + + def _during_r2_playout(_command) -> None: + played.append(True) + self.assertTrue(runtime.state.deferred_interruption.replay_in_flight) + runtime.note_vad_speech_end(1500) + runtime.on_user_input_transcribed(SimpleNamespace(transcript="fala ignorada", is_final=True)) + + executor.wait_for_playout_callbacks.append(_during_r2_playout) + with mock.patch.object(runtime, "_play_deferred_short_comfort") as short_comfort: + await runtime.run_pipeline("texto R2", "texto R2", user_seq=1, is_deferred_replay=True) + + self.assertTrue(played, "o playout de R2 precisa acontecer de verdade") + short_comfort.assert_not_called() + self.assertFalse(runtime.state.deferred_interruption.replay_in_flight) + self.assertEqual(runtime.state.user_final_seq, 1) + self.assertEqual(runtime.state.deferred_interruption.long_turns, []) + + async def test_r2_reply_is_not_interruptible(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.current_stage = "ARGUMENTATION" + runtime.state.user_final_seq = 1 + runtime.state.deferred_interruption.replay_in_flight = True + executor.pipeline_result = BackendReply(stage="ARGUMENTATION", text="resposta R2") + + await runtime.run_pipeline("texto R2", "texto R2", user_seq=1, is_deferred_replay=True) + + speech_commands = [cmd for cmd in executor.commands if isinstance(cmd, StartSpeech)] + self.assertEqual([cmd.text for cmd in speech_commands], ["resposta R2"]) + # Descartar a fala no runtime nao basta: com allow_interruptions=True o + # barge-in do proprio LiveKit corta o playout e R2 sai pela metade. + self.assertFalse(speech_commands[0].allow_interruptions) + + async def test_r2_logs_and_ignores_short_voice_without_playing_comfort(self) -> None: + runtime, _agent, _executor = self._make_runtime() + runtime.state.deferred_interruption.replay_in_flight = True + + with ( + mock.patch.object(runtime, "_play_deferred_short_comfort") as short_comfort, + mock.patch("app.livekit.runtime.call_runtime.log_flow_event") as flow_log, + ): + runtime.note_vad_speech_end(999) + + short_comfort.assert_not_called() + self.assertTrue( + any( + call.args[1] == "deferred_replay_vad_discarded" + and call.kwargs["speech_duration_ms"] == 999 + and not call.kwargs["short_comfort_scheduled"] + for call in flow_log.call_args_list + ) + ) + + async def test_normal_reply_stays_interruptible_outside_the_replay(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.current_stage = "ARGUMENTATION" + runtime.state.user_final_seq = 1 + executor.pipeline_result = BackendReply(stage="ARGUMENTATION", text="resposta normal") + + await runtime.run_pipeline("texto", "texto", user_seq=1) + + speech_commands = [cmd for cmd in executor.commands if isinstance(cmd, StartSpeech)] + self.assertEqual([cmd.text for cmd in speech_commands], ["resposta normal"]) + self.assertTrue(speech_commands[0].allow_interruptions) + + async def test_r1_waits_for_the_user_turn_to_end_before_deciding(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.user_final_seq = 1 + backend_started = asyncio.Event() + release_backend = asyncio.Event() + + async def _wait_for_backend() -> None: + backend_started.set() + await release_backend.wait() + + executor.run_pipeline_side_effect = _wait_for_backend + with ( + mock.patch.object(runtime, "_play_deferred_interruption_comfort"), + mock.patch.object( + runtime, + "_dispatch_deferred_interruption", + new_callable=mock.AsyncMock, + return_value=True, + ) as dispatch, + ): + task = asyncio.create_task(runtime.run_pipeline("original", "original", user_seq=1)) + await backend_started.wait() + # R1 volta com o cliente no meio da fala: nem o VAD fechou, nem ha + # STT pendente ainda. + runtime._user_not_speaking.clear() + release_backend.set() + await asyncio.sleep(0) + + self.assertFalse(task.done()) + self.assertFalse(any(isinstance(cmd, StartSpeech) for cmd in executor.commands)) + + runtime.note_vad_speech_end(1300) + runtime._user_not_speaking.set() + register_transcribed_turn( + runtime._structured_log_context, + message_id="late-long", + transcription="fala que comecou antes de R1 voltar", + text="fala que comecou antes de R1 voltar", + ) + runtime.on_user_input_transcribed( + SimpleNamespace( + transcript="fala que comecou antes de R1 voltar", is_final=True + ) + ) + await task + + dispatch.assert_awaited_once() + self.assertFalse(any(isinstance(cmd, StartSpeech) for cmd in executor.commands)) + + async def test_r1_gives_up_waiting_when_the_stt_final_never_arrives(self) -> None: + runtime, _agent, executor = self._make_runtime( + runtime_config_overrides={ + "deferred_interruption_stt_settle_timeout_s": 0.01, + } + ) + runtime.state.user_final_seq = 1 + backend_started = asyncio.Event() + release_backend = asyncio.Event() + + async def _wait_for_backend() -> None: + backend_started.set() + await release_backend.wait() + + executor.run_pipeline_side_effect = _wait_for_backend + executor.pipeline_result = BackendReply(stage="PRESENTATION", text="resposta R1") + with ( + mock.patch.object(runtime, "_play_deferred_interruption_comfort"), + mock.patch.object( + runtime, "_speak_backend_reply", new_callable=mock.AsyncMock + ) as speak, + ): + task = asyncio.create_task(runtime.run_pipeline("original", "original", user_seq=1)) + await backend_started.wait() + runtime.note_vad_speech_end(1200) + release_backend.set() + # Sem teto na espera esta chamada nunca retornaria. + await asyncio.wait_for(task, timeout=5) + + deferred = runtime.state.deferred_interruption + self.assertEqual(deferred.pending_long_stt_finals, 0) + self.assertEqual(len(deferred.pending_vad_utterances), 0) + self.assertFalse(deferred.backend_in_flight) + # A resposta de R1 e liberada em vez de ficar presa a um final que nao vem. + speak.assert_awaited_once() + + async def test_special_comfort_flag_is_cleared_when_r1_answers_empty(self) -> None: + runtime, _agent, _executor = self._make_runtime() + runtime.state.user_final_seq = 1 + backend_started = asyncio.Event() + release_backend = asyncio.Event() + + async def _wait_for_backend() -> None: + backend_started.set() + await release_backend.wait() + + _executor.run_pipeline_side_effect = _wait_for_backend + _executor.pipeline_result = BackendReply(stage="PRESENTATION", text="") + with mock.patch.object(runtime, "_play_deferred_interruption_comfort"): + task = asyncio.create_task(runtime.run_pipeline("original", "original", user_seq=1)) + await backend_started.wait() + runtime.note_vad_speech_end(1200) + runtime.on_user_input_transcribed(SimpleNamespace(transcript="", is_final=True)) + release_backend.set() + await task + + # Sem a limpeza aqui o conforto especial nunca mais tocaria na ligacao. + self.assertFalse(runtime.state.deferred_interruption.special_comfort_sent) + + async def test_empty_final_is_counted_once_when_provider_and_livekit_both_report(self) -> None: + runtime, _agent, _executor = self._make_runtime() + # Em raw_json o transcript cru nao e vazio, so o texto extraido dele e. + runtime._extract_text_from_transcript = lambda _transcript: "" + deferred = runtime.state.deferred_interruption + deferred.backend_in_flight = True + runtime.note_vad_speech_end(300) + runtime.note_vad_speech_end(1500) + + with mock.patch.object(runtime, "_play_deferred_interruption_comfort"): + # Provider avisa o final vazio da fala curta... + runtime.handle_empty_stt_final(source="stt_provider") + # ...e o LiveKit repassa o mesmo resultado como evento. + runtime.on_user_input_transcribed( + SimpleNamespace(transcript='{"data":{"text":""}}', is_final=True) + ) + await self._drain_tasks() + + # A fala longa continua na fila, aguardando o proprio final. + self.assertEqual(len(deferred.pending_vad_utterances), 1) + self.assertEqual(deferred.pending_long_stt_finals, 1) + + async def test_vad_utterance_without_final_does_not_leak_into_the_next_turn(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.user_final_seq = 1 + backend_started = asyncio.Event() + release_backend = asyncio.Event() + + async def _wait_for_backend() -> None: + backend_started.set() + await release_backend.wait() + + executor.run_pipeline_side_effect = _wait_for_backend + with ( + mock.patch.object(runtime, "_play_deferred_interruption_comfort"), + mock.patch.object(runtime, "_speak_backend_reply", new_callable=mock.AsyncMock), + ): + task = asyncio.create_task(runtime.run_pipeline("original", "original", user_seq=1)) + await backend_started.wait() + # Ruido curto que o STT nunca finaliza. + runtime.note_vad_speech_end(200) + release_backend.set() + await asyncio.wait_for(task, timeout=5) + + deferred = runtime.state.deferred_interruption + self.assertEqual(len(deferred.pending_vad_utterances), 0) + + # No turno seguinte a fala do cliente nao pode ser consumida pela sobra. + deferred.backend_in_flight = True + with mock.patch.object(runtime, "_play_deferred_interruption_comfort"): + runtime.note_vad_speech_end(1500) + register_transcribed_turn( + runtime._structured_log_context, + message_id="turno-seguinte", + transcription="agora eu quero falar", + text="agora eu quero falar", + ) + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="agora eu quero falar", is_final=True) + ) + await self._drain_tasks() + + self.assertEqual( + [turn.text for turn in deferred.long_turns], ["agora eu quero falar"] + ) + + async def test_protected_speech_drop_wins_over_the_deferred_cycle(self) -> None: + runtime, _agent, _executor = self._make_runtime() + runtime.state.user_final_seq = 1 + deferred = runtime.state.deferred_interruption + deferred.backend_in_flight = True + runtime._mark_drop_next_user_final( + {"reason": "protected_speech", "stage": "PRESENTATION"} + ) + + with mock.patch.object(runtime, "_play_deferred_interruption_comfort"): + runtime.note_vad_speech_end(1500) + register_transcribed_turn( + runtime._structured_log_context, + message_id="protegida", + transcription="fala descartada", + text="fala descartada", + ) + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="fala descartada", is_final=True) + ) + await self._drain_tasks() + + self.assertEqual(deferred.long_turns, []) + self.assertEqual(runtime.state.user_final_seq, 1) + # A fala VAD tem de sair da fila junto, senao o proximo final consumiria + # a entrada errada. + self.assertEqual(len(deferred.pending_vad_utterances), 0) + self.assertEqual(deferred.pending_long_stt_finals, 0) + + async def test_special_comfort_defers_the_periodic_backend_wait_notice(self) -> None: + runtime, _agent, _executor = self._make_runtime() + runtime.state.deferred_interruption.backend_in_flight = True + runtime._inflight_backend_activity_at = 0.0 + + with mock.patch.object(runtime, "_play_deferred_interruption_comfort"): + runtime.note_vad_speech_end(1500) + + self.assertGreater(runtime._inflight_backend_activity_at, 0.0) + + def test_combined_interruption_transcription_concatenates_text_and_drops_word_timestamps(self) -> None: + turns = [ + SimpleNamespace( + transcription='{"data":{"text":"primeira", "words":[{"word":"primeira"}]}}', + text="primeira", + ), + SimpleNamespace(transcription="segunda", text="segunda"), + ] + + transcription, text = CallRuntime._combined_interruption_transcription(turns) + payload = __import__("json").loads(transcription) + + self.assertEqual(text, "primeira. segunda") + self.assertEqual(payload["data"]["text"], "primeira. segunda") + self.assertNotIn("words", payload["data"]) + + async def test_on_user_input_transcribed_drops_during_protected_feedback(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._timeline = _FakeTimeline() + runtime.state.current_stage = "PRESENTATION" + runtime.state.current_speech.stage = "PRESENTATION" + runtime.state.current_speech.drop_user_input_while_speaking = True + runtime.state.current_speech.agent_message_type = "feedback" + runtime.speaking.set() + + with mock.patch("app.livekit.runtime.call_runtime.log_flow_event") as flow_log: + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="quero falar agora", is_final=True) + ) + await self._drain_tasks() + + self.assertEqual(runtime.state.user_final_seq, 0) + self.assertIsNone(runtime.state.pending_user_final) + self.assertFalse(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + self.assertTrue( + any( + call.args[1] == "user_input_dropped" + and call.kwargs["agent_message_type"] == "feedback" + and call.kwargs["text"] == "quero falar agora" + for call in flow_log.call_args_list + ) + ) + self.assertTrue( + any(event == "user_input_dropped" for event, _fields in runtime._timeline.events) + ) + + async def test_dropped_user_final_does_not_leak_message_id_to_repeated_text(self) -> None: + runtime, _agent, executor = self._make_runtime() + structured_context = StructuredLogContext( + callid="call-1", + session_id="session-drop-repeated-text", + num_telefone="5511999999999", + cod_ani="1234", + nome_agente="conta", + ) + runtime._structured_log_context = structured_context + reset_turn_message_sequence(structured_context, clear_pending=True) + + register_started_turn_message_id(structured_context, "MSG-drop-0001") + register_transcribed_turn( + structured_context, + message_id="MSG-drop-0001", + transcription="mesmo texto", + text="mesmo texto", + ) + runtime._drop_next_user_final_context = { + "reason": "test_drop", + "stage": "PRESENTATION", + "agent_message_type": "ready", + "agent_result_type": "", + } + + try: + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="mesmo texto", is_final=True) + ) + await self._drain_tasks() + self.assertFalse(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + + register_started_turn_message_id(structured_context, "MSG-current-0002") + register_transcribed_turn( + structured_context, + message_id="MSG-current-0002", + transcription="mesmo texto", + text="mesmo texto", + ) + + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="mesmo texto", is_final=True) + ) + await self._drain_tasks() + + pipeline_command = next(cmd for cmd in executor.commands if isinstance(cmd, RunPipelineInput)) + self.assertEqual(pipeline_command.user_input["message_id"], "MSG-current-0002") + finally: + reset_turn_message_sequence(structured_context, clear_pending=True) + + async def test_on_user_input_transcribed_drops_after_feedback_while_backend_is_processing(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._timeline = _FakeTimeline() + runtime._ctx.room.remote_participants = {"user-1": object()} + + await runtime._speak_backend_reply( + BackendReply( + stage="PRESENTATION", + text="Ainda estou consultando sua fatura.", + metadata={ + "event": "feedback", + "agent_message_type": "feedback", + "expects_user_response": False, + "drop_user_input_while_speaking": True, + "is_interruptible": False, + }, + ), + source="backend_push", + add_to_chat_ctx=True, + ) + + self.assertFalse(runtime.speaking.is_set()) + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="posso falar?", is_final=True) + ) + await self._drain_tasks() + + self.assertEqual(runtime.state.user_final_seq, 0) + self.assertFalse(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + self.assertTrue( + any( + event == "user_input_dropped" + and fields["agent_message_type"] == "feedback" + for event, fields in runtime._timeline.events + ) + ) + + async def test_on_user_input_transcribed_drops_after_result_feedback_while_backend_is_processing(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._timeline = _FakeTimeline() + runtime._ctx.room.remote_participants = {"user-1": object()} + + await runtime._speak_backend_reply( + BackendReply( + stage="PRESENTATION", + text="Aguarde um instante, por favor.", + metadata={ + "agent_message_type": "result", + "agent_result_type": "feedback", + "expects_user_response": False, + "drop_user_input_while_speaking": True, + "is_interruptible": False, + }, + ), + source="run_pipeline", + add_to_chat_ctx=True, + ) + + self.assertFalse(runtime.speaking.is_set()) + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="voce esta cancelando?", is_final=True) + ) + await self._drain_tasks() + + self.assertEqual(runtime.state.user_final_seq, 0) + self.assertIsNone(runtime.state.pending_user_final) + self.assertFalse(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + self.assertTrue( + any( + event == "user_input_dropped" + and fields["agent_message_type"] == "result" + and fields["agent_result_type"] == "feedback" + for event, fields in runtime._timeline.events + ) + ) + + async def test_on_user_input_transcribed_drops_during_terminal_speech(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.current_stage = "DONE" + runtime.state.current_speech.stage = "DONE" + runtime.state.current_speech.drop_user_input_while_speaking = True + runtime.state.current_speech.agent_message_type = "result" + runtime.state.current_speech.agent_result_type = "resolvido" + runtime.speaking.set() + + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="tenho outra pergunta", is_final=True) + ) + await self._drain_tasks() + + self.assertEqual(runtime.state.user_final_seq, 0) + self.assertIsNone(runtime.state.pending_user_final) + self.assertFalse(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + + async def test_user_state_speaking_during_ready_drops_late_final_but_arms_timeout(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._timeline = _FakeTimeline() + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + executor.pipeline_result = BackendReply( + stage="PRESENTATION", + text="Posso verificar sua fatura.", + metadata={ + "agent_message_type": "ready", + "expects_user_response": True, + "drop_user_input_while_speaking": True, + "is_interruptible": False, + "wait_timeout_seconds": 60.0, + }, + ) + executor.wait_for_playout_delay_s = 0.03 + runtime.register_callbacks() + + task = asyncio.create_task(runtime.run_pipeline("", "", user_seq=1)) + try: + while not any(cmd.__class__.__name__ == "StartSpeech" for cmd in executor.commands): + await asyncio.sleep(0) + + runtime._session.handlers["user_state_changed"]( + SimpleNamespace(old_state="listening", new_state="speaking") + ) + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="ja estou respondendo", is_final=True) + ) + await task + + pipeline_commands = [cmd for cmd in executor.commands if isinstance(cmd, RunPipelineInput)] + self.assertEqual(len(pipeline_commands), 1) + self.assertEqual(runtime.state.user_final_seq, 1) + self.assertTrue( + any( + event == "user_input_dropped" + and fields["agent_message_type"] == "ready" + for event, fields in runtime._timeline.events + ) + ) + self.assertTrue( + any(event == "agent_wait_timeout_armed" for event, _fields in runtime._timeline.events) + ) + finally: + if not task.done(): + task.cancel() + await asyncio.gather(task, return_exceptions=True) + await self._cancel_pending_tasks() + + async def test_user_state_speaking_during_noninterruptible_presentation_drops_late_final(self) -> None: + runtime, _agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 300.0, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": DEFAULT_WAIT_TEXT, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._timeline = _FakeTimeline() + runtime._ctx.room.remote_participants = {"user-1": object()} + _agent.pipeline = _FakeInflightPushPipeline() + runtime.state.current_stage = "PRESENTATION" + runtime.state.current_speech.stage = "PRESENTATION" + runtime.state.current_speech.allow_interruptions = False + runtime.speaking.set() + runtime.register_callbacks() + + runtime._session.handlers["user_state_changed"]( + SimpleNamespace(old_state="listening", new_state="speaking") + ) + runtime.schedule_pre_backend_wait_notice(reason="vad_end_of_speech") + runtime.speaking.clear() + runtime.state.current_speech.stage = "" + runtime.state.current_speech.allow_interruptions = False + + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="oi", is_final=True) + ) + await self._drain_tasks() + + self.assertEqual(runtime.state.user_final_seq, 0) + self.assertIsNone(runtime.state.pending_user_final) + self.assertFalse(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + self.assertFalse(any(cmd.__class__.__name__ == "StartSpeech" for cmd in executor.commands)) + self.assertTrue( + any( + event == "pre_backend_wait_notice_skipped" + and fields["skipped_reason"] == "user_input_drop_pending" + for event, fields in runtime._timeline.events + ) + ) + self.assertTrue( + any( + event == "user_input_dropped" + and fields["agent_message_type"] == "ready" + and fields["text"] == "oi" + for event, fields in runtime._timeline.events + ) + ) + + async def test_user_state_speaking_in_presentation_post_playout_grace_drops_late_final(self) -> None: + runtime, _agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 300.0, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": DEFAULT_WAIT_TEXT, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._timeline = _FakeTimeline() + runtime._ctx.room.remote_participants = {"user-1": object()} + _agent.pipeline = _FakeInflightPushPipeline() + runtime.register_callbacks() + + await runtime.say_stage( + "Ola, sou a assistente virtual da TIM.", + "PRESENTATION", + allow_interruptions=False, + schedule_idle_after=False, + ) + + self.assertFalse(runtime.speaking.is_set()) + runtime._session.handlers["user_state_changed"]( + SimpleNamespace(old_state="listening", new_state="speaking") + ) + runtime.schedule_pre_backend_wait_notice(reason="vad_end_of_speech") + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="estou falando junto", is_final=True) + ) + await self._drain_tasks() + + self.assertEqual(runtime.state.user_final_seq, 0) + self.assertIsNone(runtime.state.pending_user_final) + self.assertFalse(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + self.assertTrue( + any( + event == "user_input_drop_grace_armed" + and fields["grace_ms"] == 250 + for event, fields in runtime._timeline.events + ) + ) + self.assertTrue( + any( + event == "pre_backend_wait_notice_skipped" + and fields["skipped_reason"] == "user_input_drop_pending" + for event, fields in runtime._timeline.events + ) + ) + self.assertTrue( + any( + event == "user_input_dropped" + and fields["reason"] == "user_state_speaking_protected_post_playout_grace" + and fields["agent_message_type"] == "ready" + and fields["text"] == "estou falando junto" + for event, fields in runtime._timeline.events + ) + ) + + async def test_presentation_post_playout_grace_expires_before_next_user_final(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._ctx.room.remote_participants = {"user-1": object()} + + with mock.patch( + "app.livekit.runtime.call_runtime.PROTECTED_SPEECH_POST_PLAYOUT_DROP_GRACE_S", + 0.001, + ): + await runtime.say_stage( + "Ola, sou a assistente virtual da TIM.", + "PRESENTATION", + allow_interruptions=False, + schedule_idle_after=False, + ) + await asyncio.sleep(0.01) + + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="agora posso responder", is_final=True) + ) + await self._drain_tasks() + + self.assertEqual(runtime.state.user_final_seq, 1) + self.assertTrue(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + + async def test_user_state_speaking_interrupts_current_tts(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.current_stage = "ARGUMENTATION" + runtime.state.current_speech.stage = "ARGUMENTATION" + runtime.state.current_speech.allow_interruptions = True + runtime.state.current_speech.handle = executor.speech_handle + runtime.state.current_speech.speech_id = "7f3a0000-0000-4000-8000-000000000000" + runtime.speaking.set() + runtime.register_callbacks() + + runtime._session.handlers["user_state_changed"]( + SimpleNamespace(old_state="listening", new_state="speaking") + ) + await self._drain_tasks() + + interrupt_commands = [ + cmd for cmd in executor.commands if isinstance(cmd, InterruptSpeech) + ] + self.assertEqual(len(interrupt_commands), 1) + self.assertIs(interrupt_commands[0].handle, executor.speech_handle) + + async def test_user_state_speaking_interrupts_inflight_wait_notice(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.current_stage = "AGENT_BACKEND_WAIT" + runtime.state.current_speech.stage = "AGENT_BACKEND_WAIT" + runtime.state.current_speech.allow_interruptions = False + runtime.state.current_speech.handle = executor.speech_handle + runtime.speaking.set() + runtime.register_callbacks() + + runtime._session.handlers["user_state_changed"]( + SimpleNamespace(old_state="listening", new_state="speaking") + ) + await self._drain_tasks() + + interrupt_commands = [ + cmd for cmd in executor.commands if isinstance(cmd, InterruptSpeech) + ] + self.assertEqual(len(interrupt_commands), 1) + self.assertIs(interrupt_commands[0].handle, executor.speech_handle) + self.assertTrue(interrupt_commands[0].force) + + async def test_user_state_speaking_cancels_agent_wait_timeout(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.register_callbacks() + runtime.arm_agent_wait_timeout_from_reply( + BackendReply( + stage="PRESENTATION", + text="Ola, como posso ajudar?", + metadata={"wait_timeout_seconds": 0.01}, + ), + user_seq=0, + source="test", + ) + + runtime._session.handlers["user_state_changed"]( + SimpleNamespace(old_state="listening", new_state="speaking") + ) + await asyncio.sleep(0.03) + await self._drain_tasks() + + self.assertFalse(any(isinstance(cmd, NotifyBridgeStop) for cmd in executor.commands)) + self.assertFalse(runtime.finalized.is_set()) + + async def test_user_state_listening_does_not_speak_wait_notice_before_stt_final(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 300.0, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + agent.pipeline = _FakeInflightPushPipeline() + runtime.register_callbacks() + + runtime._session.handlers["user_state_changed"]( + SimpleNamespace(old_state="speaking", new_state="listening") + ) + await self._drain_tasks() + + speech_texts = [ + cmd.text + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertEqual(speech_texts, []) + self.assertFalse(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + + async def test_vad_wait_notice_starts_without_waiting_for_user_state_listening(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 300.0, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + agent.pipeline = _FakeInflightPushPipeline() + runtime.register_callbacks() + + runtime.schedule_pre_backend_wait_notice(reason="vad_end_of_speech") + await self._drain_tasks() + + speech_texts = [ + cmd.text + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertEqual(speech_texts, [_expected_wait_text_for_audio(WAIT_LONG_AUDIO_PATH, wait_text)]) + + async def test_vad_wait_notice_fast_path_uses_vad_pause_without_user_state(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 300.0, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + "pre_backend_wait_notice_fast_on_vad_pause": True, + } + ) + runtime._timeline = _FakeTimeline() + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime._user_not_speaking.clear() + agent.pipeline = _FakeInflightPushPipeline() + + with mock.patch( + "app.livekit.runtime.call_runtime.asyncio.sleep", + new=mock.AsyncMock(), + ) as sleep: + runtime.schedule_pre_backend_wait_notice(reason="vad_end_of_speech") + await self._drain_tasks() + + sleep.assert_not_awaited() + speech_texts = [ + cmd.text + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertEqual(speech_texts, [_expected_wait_text_for_audio(WAIT_LONG_AUDIO_PATH, wait_text)]) + self.assertTrue( + any( + event == "pre_backend_wait_notice_started" + and fields["fast_on_vad_pause"] is True + for event, fields in runtime._timeline.events + ) + ) + + async def test_vad_wait_notice_default_path_skips_while_user_state_speaking(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 300.0, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._timeline = _FakeTimeline() + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime._user_not_speaking.clear() + agent.pipeline = _FakeInflightPushPipeline() + + with mock.patch("app.livekit.runtime.call_runtime.PRE_BACKEND_WAIT_NOTICE_GUARD_S", 0): + runtime.schedule_pre_backend_wait_notice(reason="vad_end_of_speech") + await self._drain_tasks() + + self.assertFalse( + any(cmd.__class__.__name__ == "StartSpeech" for cmd in executor.commands) + ) + self.assertTrue( + any( + event == "pre_backend_wait_notice_skipped" + and fields["skipped_reason"] == "user_speaking" + for event, fields in runtime._timeline.events + ) + ) + + def _wait_notice_config(self, wait_text: str) -> dict: + return { + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 300.0, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + + async def test_vad_wait_notice_skipped_while_agent_is_speaking(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides=self._wait_notice_config(wait_text) + ) + runtime._timeline = _FakeTimeline() + runtime._ctx.room.remote_participants = {"user-1": object()} + agent.pipeline = _FakeInflightPushPipeline() + runtime.state.current_stage = "PRESENTATION" + runtime.state.current_speech.stage = "PRESENTATION" + runtime.speaking.set() + + runtime.schedule_pre_backend_wait_notice(reason="vad_end_of_speech") + await self._drain_tasks() + + self.assertFalse( + any(cmd.__class__.__name__ == "StartSpeech" for cmd in executor.commands) + ) + self.assertTrue( + any( + event == "pre_backend_wait_notice_skipped" + and fields["skipped_reason"] == "agent_speaking" + for event, fields in runtime._timeline.events + ) + ) + self.assertFalse(runtime._pre_backend_wait_notice_reserved) + + async def test_vad_wait_notice_skipped_while_backend_is_processing(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides=self._wait_notice_config(wait_text) + ) + runtime._timeline = _FakeTimeline() + runtime._ctx.room.remote_participants = {"user-1": object()} + agent.pipeline = _FakeInflightPushPipeline() + await agent._run_lock.acquire() + + try: + runtime.schedule_pre_backend_wait_notice(reason="vad_end_of_speech") + await self._drain_tasks() + finally: + agent._run_lock.release() + + self.assertFalse( + any(cmd.__class__.__name__ == "StartSpeech" for cmd in executor.commands) + ) + self.assertTrue( + any( + event == "pre_backend_wait_notice_skipped" + and fields["skipped_reason"] == "backend_processing" + for event, fields in runtime._timeline.events + ) + ) + self.assertFalse(runtime._pre_backend_wait_notice_reserved) + + async def test_wait_notice_audio_aborts_when_another_speech_took_the_lock(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides=self._wait_notice_config(wait_text) + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + agent.pipeline = _FakeInflightPushPipeline() + + say_stage_seq_at_schedule = runtime._say_stage_seq + await runtime.say_stage("Resposta do agente.", "PRESENTATION") + + played = await runtime._play_inflight_backend_wait_notice_audio( + user_seq=None, + attempt=1, + abort_if_agent_spoke_since=say_stage_seq_at_schedule, + ) + await self._drain_tasks() + + self.assertFalse(played) + speech_texts = [ + cmd.text + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertEqual(speech_texts, ["Resposta do agente."]) + + async def test_vad_wait_notice_does_not_queue_behind_an_agent_speech(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides=self._wait_notice_config(wait_text) + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + agent.pipeline = _FakeInflightPushPipeline() + + # O aviso e agendado no fim de fala do VAD e so pega o say_lock depois + # que a fala do agente libera: nesse ponto ele nao deve mais sair. + runtime.schedule_pre_backend_wait_notice(reason="vad_end_of_speech") + await runtime.say_stage("Resposta do agente.", "PRESENTATION") + await self._drain_tasks() + + speech_texts = [ + cmd.text + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertEqual(speech_texts, ["Resposta do agente."]) + + async def test_vad_wait_notice_returns_reservation_when_it_does_not_play(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides=self._wait_notice_config(wait_text) + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + agent.pipeline = _FakeInflightPushPipeline() + + runtime.schedule_pre_backend_wait_notice(reason="vad_end_of_speech") + await runtime.say_stage("Resposta do agente.", "PRESENTATION") + await self._drain_tasks() + self.assertFalse(runtime._pre_backend_wait_notice_reserved) + + # A reserva devolvida nao pode bloquear o proximo agendamento. + runtime.schedule_pre_backend_wait_notice(reason="vad_end_of_speech") + await self._drain_tasks() + + speech_texts = [ + cmd.text + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertEqual( + speech_texts, + [ + "Resposta do agente.", + _expected_wait_text_for_audio(WAIT_LONG_AUDIO_PATH, wait_text), + ], + ) + + async def test_vad_wait_notice_uses_started_turn_message_id(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, _executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 300.0, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + structured_context = StructuredLogContext( + callid="call-1", + session_id="session-started-turn-runtime", + num_telefone="5511999999999", + cod_ani="1234", + nome_agente="conta", + ) + runtime._structured_log_context = structured_context + runtime._timeline = _FakeTimeline() + runtime._ctx.room.remote_participants = {"user-1": object()} + agent.pipeline = _FakeInflightPushPipeline() + reset_turn_message_sequence(structured_context, clear_pending=True) + register_started_turn_message_id(structured_context, "GED-test-0002") + + try: + runtime.schedule_pre_backend_wait_notice(reason="vad_end_of_speech") + await self._drain_tasks() + + wait_events = [ + fields + for event, fields in runtime._timeline.events + if event == "inflight_backend_wait_audio" + ] + self.assertEqual(len(wait_events), 1) + self.assertTrue(wait_events[0]["message_id"].startswith("GED-test-0002_conforto_")) + finally: + reset_turn_message_sequence(structured_context, clear_pending=True) + + async def test_vad_wait_notice_uses_last_agent_message_id_without_started_turn(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, _executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 300.0, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + structured_context = StructuredLogContext( + callid="call-1", + session_id="session-last-agent-turn-runtime", + num_telefone="5511999999999", + cod_ani="1234", + nome_agente="conta", + ) + runtime._structured_log_context = structured_context + runtime._timeline = _FakeTimeline() + runtime._ctx.room.remote_participants = {"user-1": object()} + agent.pipeline = _FakeInflightPushPipeline() + reset_turn_message_sequence(structured_context, clear_pending=True) + runtime._remember_agent_base_message_id("MSG-agent-0001") + + try: + runtime.schedule_pre_backend_wait_notice(reason="vad_end_of_speech") + await self._drain_tasks() + + wait_events = [ + fields + for event, fields in runtime._timeline.events + if event == "inflight_backend_wait_audio" + ] + self.assertEqual(len(wait_events), 1) + self.assertTrue(wait_events[0]["message_id"].startswith("MSG-agent-0001_conforto_")) + finally: + reset_turn_message_sequence(structured_context, clear_pending=True) + + async def test_inflight_backend_wait_notice_skips_post_user_final_grace(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, _executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 300.0, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.last_user_final_at = time.monotonic() + agent.pipeline = _FakeInflightPushPipeline() + + with mock.patch.object( + runtime, + "_wait_post_user_final_grace", + new=mock.AsyncMock(), + ) as wait_grace: + await runtime._play_inflight_backend_wait_notice_audio( + user_seq=None, + attempt=1, + ) + + wait_grace.assert_not_awaited() + + async def test_empty_stt_final_logs_and_clears_pre_backend_wait_notice(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._timeline = _FakeTimeline() + runtime._pre_backend_wait_notice_reserved = True + + with ( + mock.patch("app.livekit.runtime.call_runtime.log_flow_event") as flow_log, + mock.patch.object(runtime, "arm_idle_timer") as arm_idle, + ): + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="", is_final=True) + ) + await self._drain_tasks() + + self.assertFalse(runtime._pre_backend_wait_notice_reserved) + arm_idle.assert_called_once_with(reason="empty_stt_final") + self.assertFalse(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + self.assertTrue( + any( + call.args[1] == "stt_final_empty" + and call.kwargs["reason"] == "empty_text" + and call.kwargs["pre_backend_wait_reserved"] is True + for call in flow_log.call_args_list + ) + ) + self.assertIn( + ( + "user_transcript_final_empty", + { + "current_stage": "INTRO", + "speaking": False, + "transcript_len": 0, + "pre_backend_wait_reserved": True, + }, + ), + runtime._timeline.events, + ) + + async def test_empty_stt_final_rearms_agent_wait_timeout_when_available(self) -> None: + runtime, _agent, _executor = self._make_runtime() + reply = BackendReply( + stage="PRESENTATION", + text="Ainda esta ai?", + metadata={"wait_timeout_seconds": 60.0}, + ) + runtime.arm_agent_wait_timeout_from_reply(reply, user_seq=0, source="test") + runtime.cancel_agent_wait_timeout("user_state_speaking") + + try: + with ( + mock.patch.object( + runtime, + "_arm_agent_wait_timeout", + wraps=runtime._arm_agent_wait_timeout, + ) as arm_wait, + mock.patch.object(runtime, "arm_idle_timer") as arm_idle, + ): + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="", is_final=True) + ) + + arm_wait.assert_called_once() + self.assertEqual(arm_wait.call_args.kwargs["user_seq"], 0) + self.assertEqual(arm_wait.call_args.kwargs["source"], "empty_stt_final") + arm_idle.assert_not_called() + finally: + await self._cancel_pending_tasks() + + async def test_empty_stt_final_during_comfort_rearms_after_playout(self) -> None: + runtime, _agent, executor = self._make_runtime() + reply = BackendReply( + stage="PRESENTATION", + text="Ainda esta ai?", + metadata={"wait_timeout_seconds": 60.0}, + ) + runtime.arm_agent_wait_timeout_from_reply(reply, user_seq=0, source="test") + runtime.cancel_agent_wait_timeout("user_state_speaking") + executor.wait_for_playout_callbacks.append( + lambda _command: runtime.handle_empty_stt_final(source="stt_provider") + ) + + try: + with mock.patch.object( + runtime, + "_arm_agent_wait_timeout", + wraps=runtime._arm_agent_wait_timeout, + ) as arm_wait: + await runtime.say_stage( + "Um instantinho", + "AGENT_BACKEND_WAIT", + add_to_chat_ctx=False, + allow_interruptions=False, + schedule_idle_after=False, + ) + + arm_wait.assert_called_once() + self.assertEqual(arm_wait.call_args.kwargs["user_seq"], 0) + self.assertEqual(arm_wait.call_args.kwargs["source"], "empty_stt_final") + self.assertFalse(runtime._empty_stt_recovery_pending) + finally: + await self._cancel_pending_tasks() + + async def test_inflight_backend_wait_audio_leaves_tts_metrics_empty_without_exporting_original_id( + self, + ) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 300.0, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + runtime._timeline = _FakeTimeline() + agent.pipeline = _FakeInflightPushPipeline() + executor.speech_handle = SimpleNamespace(id="speech-no-metric", interrupted=False) + structured_events = [] + + def _capture_event(*args, **kwargs): + structured_events.append(kwargs) + + with mock.patch.dict(os.environ, {"TTS_TTFB_METRIC_WAIT_MS": "0"}), mock.patch( + "app.livekit.runtime.call_runtime.log_structured_event", + side_effect=_capture_event, + ): + await runtime._play_inflight_backend_wait_notice_audio( + user_seq=1, + attempt=2, + message_id="GED-test-0002", + ) + + self.assertEqual(len(structured_events), 1) + self.assertIsNone(structured_events[0]["latencia_total_ms"]) + self.assertIsNone(structured_events[0]["latencia_tffb_ms"]) + self.assertIsNone(structured_events[0]["duracao_audio_ms"]) + self.assertNotIn("original_message_id", structured_events[0]) + + wait_events = [ + fields + for event, fields in runtime._timeline.events + if event == "inflight_backend_wait_audio" + ] + self.assertEqual(len(wait_events), 1) + self.assertTrue(wait_events[0]["message_id"].startswith("GED-test-0002_conforto_")) + self.assertEqual(wait_events[0]["text"], _expected_wait_text_for_audio(WAIT_LONG_AUDIO_PATH, wait_text)) + self.assertNotIn("original_message_id", wait_events[0]) + + async def test_inflight_backend_wait_audio_uses_sidecar_text_for_logged_event(self) -> None: + with TemporaryDirectory() as tmp_dir: + long_dir = Path(tmp_dir) / "long" + long_dir.mkdir() + audio_path = long_dir / "01.wav" + text_path = long_dir / "01.txt" + audio_path.write_bytes(b"fake-wav") + text_path.write_text( + "Estou verificando as informacoes para te ajudar.\nSo um momentinho.", + encoding="utf-8", + ) + expected_text = "Estou verificando as informacoes para te ajudar. So um momentinho." + + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 300.0, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": "Texto padrao que nao deve aparecer.", + "inflight_backend_wait_long_audio_dir": str(long_dir), + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + runtime._timeline = _FakeTimeline() + agent.pipeline = _FakeInflightPushPipeline() + + with ( + mock.patch("app.livekit.runtime.call_runtime.wav_duration_ms", return_value=100), + mock.patch("app.livekit.runtime.call_runtime.wav_audio_frames", return_value="audio-frames"), + ): + await runtime._play_inflight_backend_wait_notice_audio( + user_seq=1, + attempt=2, + message_id="GED-test-0002", + ) + + speech_commands = [ + cmd + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertEqual([cmd.text for cmd in speech_commands], [expected_text]) + wait_events = [ + fields + for event, fields in runtime._timeline.events + if event == "inflight_backend_wait_audio" + ] + self.assertEqual(len(wait_events), 1) + self.assertEqual(wait_events[0]["text"], expected_text) + self.assertEqual(wait_events[0]["path"], str(audio_path)) + + async def test_backend_wait_notice_starts_immediately_after_stt_final(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 0.01, + "inflight_backend_wait_max_notices": 1, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + agent.pipeline = _FakeInflightPushPipeline() + runtime.state.user_final_seq = 1 + never_finishes = asyncio.Event() + + async def _hang_backend() -> None: + await never_finishes.wait() + + executor.run_pipeline_side_effect = _hang_backend + + await runtime.run_pipeline("texto", "texto", user_seq=1) + await self._drain_tasks() + + speech_texts = [ + cmd.text + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertEqual(speech_texts, [_expected_wait_text_for_audio(WAIT_LONG_AUDIO_PATH, wait_text)]) + speech_commands = [ + cmd + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertTrue(speech_commands[0].allow_interruptions) + self.assertIsNotNone(speech_commands[0].audio) + + async def test_pre_stt_wait_notice_activity_controls_next_backend_notice_interval(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 0.01, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + agent.pipeline = _FakeInflightPushPipeline() + runtime._pre_backend_wait_notice_reserved = True + runtime._inflight_backend_activity_at = time.monotonic() - 120.0 + never_finishes = asyncio.Event() + + async def _hang_backend() -> None: + await never_finishes.wait() + + executor.run_pipeline_side_effect = _hang_backend + + await runtime.run_pipeline("texto", "texto", user_seq=1) + await self._drain_tasks() + + speech_commands = [ + cmd + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertEqual( + [cmd.text for cmd in speech_commands], + [_expected_wait_text_for_audio(WAIT_LONG_AUDIO_PATH, wait_text)], + ) + self.assertTrue(speech_commands[0].allow_interruptions) + + async def test_inflight_backend_wait_notice_skips_when_user_is_speaking(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 300.0, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + agent.pipeline = _FakeInflightPushPipeline() + runtime._user_not_speaking.clear() + + await runtime._play_inflight_backend_wait_notice_audio( + user_seq=1, + attempt=1, + message_id="GED-test-0001", + ) + await self._drain_tasks() + + self.assertFalse( + any(cmd.__class__.__name__ == "StartSpeech" for cmd in executor.commands) + ) + + async def test_pre_stt_wait_notice_is_not_restarted_while_pending(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 300.0, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + agent.pipeline = _FakeInflightPushPipeline() + + runtime.schedule_pre_backend_wait_notice(reason="vad_end_of_speech") + runtime.schedule_pre_backend_wait_notice(reason="user_state_listening") + await self._drain_tasks() + + speech_texts = [ + cmd.text + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertEqual(speech_texts, [_expected_wait_text_for_audio(WAIT_LONG_AUDIO_PATH, wait_text)]) + + async def test_stt_final_does_not_interrupt_pre_backend_wait_notice(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._pre_backend_wait_notice_reserved = True + runtime.speaking.set() + runtime.state.current_stage = "PRESENTATION" + runtime.state.current_speech.stage = "AGENT_BACKEND_WAIT" + runtime.state.current_speech.allow_interruptions = True + runtime.state.current_speech.handle = executor.speech_handle + + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="Por que veio mais caro?", is_final=True) + ) + await self._drain_tasks() + + self.assertFalse(any(isinstance(cmd, InterruptSpeech) for cmd in executor.commands)) + self.assertTrue(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + + async def test_sim_during_pre_backend_wait_notice_runs_pipeline(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._pre_backend_wait_notice_reserved = True + runtime.speaking.set() + runtime.state.current_stage = "PRESENTATION" + runtime.state.current_speech.stage = "AGENT_BACKEND_WAIT" + runtime.state.current_speech.allow_interruptions = False + runtime.state.current_speech.handle = executor.speech_handle + + with mock.patch("app.livekit.runtime.call_runtime.log_flow_event") as flow_log: + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="sim", is_final=True) + ) + await self._drain_tasks() + + self.assertFalse(any(isinstance(cmd, InterruptSpeech) for cmd in executor.commands)) + self.assertTrue(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + self.assertFalse( + any( + call.args[1] == "interrupt_ignored" + and call.kwargs.get("reason") == "backchannel" + and call.kwargs.get("text") == "sim" + for call in flow_log.call_args_list + ) + ) + + async def test_run_pipeline_forwards_pending_speech_interruption(self) -> None: + runtime, agent, executor = self._make_runtime() + runtime.state.user_final_seq = 1 + agent._consumed_interrupt = ( + "Claro, vou te explicar", + "7f3a0000-0000-4000-8000-000000000000", + False, + ) + + await runtime.run_pipeline("texto", "texto", user_seq=1) + + interruption_commands = [ + cmd for cmd in executor.commands if isinstance(cmd, SetPipelineInterruption) + ] + self.assertEqual(len(interruption_commands), 1) + self.assertTrue(interruption_commands[0].interrupted) + self.assertEqual(interruption_commands[0].listened_text, "Claro, vou te explicar") + self.assertEqual( + interruption_commands[0].speech_id, + "7f3a0000-0000-4000-8000-000000000000", + ) + + async def test_deferred_replay_marks_processing_interruption(self) -> None: + runtime, agent, executor = self._make_runtime() + runtime.state.user_final_seq = 1 + agent._consumed_interrupt = ( + "", + "7f3a0000-0000-4000-8000-000000000001", + True, + ) + + await runtime.run_pipeline( + "texto interrompido", + "texto interrompido", + user_seq=1, + is_deferred_replay=True, + ) + + command = next( + cmd + for cmd in executor.commands + if isinstance(cmd, SetPipelineProcessingInterruption) + ) + self.assertEqual(command.listened_text, "") + self.assertEqual( + command.speech_id, + "7f3a0000-0000-4000-8000-000000000001", + ) + + async def test_run_pipeline_uses_registered_turn_message_id(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.user_final_seq = 1 + structured_context = StructuredLogContext( + callid="call-1", + session_id="session-1", + num_telefone="5511999999999", + cod_ani="1234", + nome_agente="conta", + ) + runtime._structured_log_context = structured_context + reset_turn_message_sequence(structured_context, clear_pending=True) + register_transcribed_turn( + structured_context, + message_id="MSG-call-1-0001", + transcription="texto", + text="texto", + ) + + await runtime.run_pipeline("texto", "texto", user_seq=1) + + pipeline_command = next(cmd for cmd in executor.commands if isinstance(cmd, RunPipelineInput)) + self.assertEqual(pipeline_command.user_input["message_id"], "MSG-call-1-0001") + enriched_reply = runtime._backend_reply_with_message_id( + BackendReply(stage="PRESENTATION", text="resposta"), + "MSG-call-1-0001", + ) + self.assertEqual(runtime._message_id_from_reply(enriched_reply), "MSG-call-1-0001") + + async def test_on_user_input_transcribed_clears_started_turn_message_id(self) -> None: + runtime, _agent, executor = self._make_runtime() + structured_context = StructuredLogContext( + callid="call-1", + session_id="session-user-final-started-turn", + num_telefone="5511999999999", + cod_ani="1234", + nome_agente="conta", + ) + runtime._structured_log_context = structured_context + reset_turn_message_sequence(structured_context, clear_pending=True) + register_started_turn_message_id(structured_context, "MSG-call-1-0002") + register_transcribed_turn( + structured_context, + message_id="MSG-call-1-0002", + transcription="texto", + text="texto", + ) + + try: + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="texto", is_final=True) + ) + await self._drain_tasks() + + pipeline_command = next(cmd for cmd in executor.commands if isinstance(cmd, RunPipelineInput)) + self.assertEqual(pipeline_command.user_input["message_id"], "MSG-call-1-0002") + self.assertEqual(peek_started_turn_message_id(structured_context), "") + finally: + reset_turn_message_sequence(structured_context, clear_pending=True) + + async def test_say_stage_marks_interruption_with_speech_id(self) -> None: + runtime, _agent, executor = self._make_runtime() + executor.speech_handle = SimpleNamespace(interrupted=True) + executor.spoken_text = "Claro, vou te explicar" + + await runtime.say_stage( + "Claro, vou te explicar sua fatura.", + "ARGUMENTATION", + allow_interruptions=True, + speech_id="7f3a0000-0000-4000-8000-000000000000", + ) + + pending_commands = [cmd for cmd in executor.commands if isinstance(cmd, SetPendingInterrupt)] + self.assertEqual(len(pending_commands), 1) + self.assertEqual(pending_commands[0].listened_text, "Claro, vou te explicar") + self.assertEqual( + pending_commands[0].speech_id, + "7f3a0000-0000-4000-8000-000000000000", + ) + + async def test_backend_reply_metadata_controls_tts_interruptibility(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + executor.pipeline_result = BackendReply( + stage="ARGUMENTATION", + text="Ola, como posso ajudar?", + metadata={ + "speech_id": "11111111-1111-4111-8111-111111111111", + "is_interruptible": False, + }, + ) + + await runtime.run_pipeline("texto", "texto", user_seq=1) + + speech_commands = [cmd for cmd in executor.commands if cmd.__class__.__name__ == "StartSpeech"] + self.assertEqual(len(speech_commands), 1) + self.assertFalse(speech_commands[0].allow_interruptions) + + async def test_run_pipeline_skips_stale_turn_before_running_pipeline(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.user_final_seq = 2 + + await runtime.run_pipeline("texto", "texto", user_seq=1) + + self.assertFalse(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + + async def test_run_pipeline_forwards_short_acknowledgement_while_speaking(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.speaking.set() + + with mock.patch("app.livekit.runtime.call_runtime.log_flow_event") as flow_log: + await runtime.run_pipeline("ok", "ok", user_seq=0) + + self.assertTrue(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + self.assertFalse( + any( + call.args[1] == "interrupt_ignored" + and call.kwargs.get("reason") == "backchannel" + for call in flow_log.call_args_list + ) + ) + + async def test_low_confidence_sim_after_tts_runs_pipeline(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._ctx.room.remote_participants = {"user-1": object()} + + await runtime.say_stage( + "Consegui esclarecer sua dúvida?", + "PRESENTATION", + allow_interruptions=True, + ) + + self.assertFalse(runtime.speaking.is_set()) + low_confidence_sim = stt_text_with_single_word_threshold( + { + "data": { + "text": "sim", + "words": [{"word": "sim", "probability": 0.009}], + } + }, + min_prob_single_word=0.03, + ) + self.assertEqual(low_confidence_sim, "sim") + + with mock.patch("app.livekit.runtime.call_runtime.log_flow_event") as flow_log: + runtime.on_user_input_transcribed( + SimpleNamespace(transcript=low_confidence_sim, is_final=True) + ) + await self._drain_tasks() + + pipeline_commands = [ + cmd for cmd in executor.commands if isinstance(cmd, RunPipelineInput) + ] + self.assertTrue(pipeline_commands) + self.assertEqual(pipeline_commands[-1].user_input, "sim") + self.assertFalse( + any( + call.args[1] == "interrupt_ignored" + and call.kwargs.get("reason") == "backchannel" + for call in flow_log.call_args_list + ) + ) + + async def test_run_pipeline_drops_output_if_user_speaks_during_pipeline(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.user_final_seq = 1 + + async def _mutate_seq(): + runtime.state.user_final_seq = 2 + + executor.pipeline_result = BackendReply(stage="PRESENTATION", text="resposta") + executor.run_pipeline_side_effect = _mutate_seq + + await runtime.run_pipeline("texto", "texto", user_seq=1) + + self.assertEqual(runtime.state.current_stage, "INTRO") + self.assertFalse(any(cmd.__class__.__name__ == "StartSpeech" for cmd in executor.commands)) + + async def test_say_stage_waits_briefly_after_user_final(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.last_user_final_at = time.monotonic() + + sleep_calls = [] + + async def _fake_sleep(delay: float) -> None: + sleep_calls.append(delay) + + with mock.patch("app.livekit.runtime.call_runtime.asyncio.sleep", side_effect=_fake_sleep): + await runtime.say_stage("resposta", "ARGUMENTATION") + + self.assertTrue(sleep_calls) + self.assertGreater(sleep_calls[0], 0.0) + self.assertLessEqual(sleep_calls[0], 0.35) + self.assertTrue(any(cmd.__class__.__name__ == "StartSpeech" for cmd in executor.commands)) + + async def test_say_stage_logs_tts_metrics_and_success_status(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._record_xai_tts_turn_timing( + { + "segment_id": "segment-test", + "max_audio_delta_gap_ms": 840, + "max_playout_underrun_0ms": 125, + "xai_micro_underflows": 2, + "xai_avg_underrun_ms": 80, + } + ) + runtime._record_tts_metric( + SimpleNamespace( + metrics=SimpleNamespace( + type="tts_metrics", + ttfb=0.123, + duration=0.456, + audio_duration=1.500, + speech_id="speech-test", + segment_id="segment-test", + label="provider", + ) + ) + ) + structured_events = [] + + def _capture_event(*args, **kwargs): + structured_events.append(kwargs) + + with mock.patch("app.livekit.runtime.call_runtime.log_structured_event", side_effect=_capture_event): + await runtime.say_stage("resposta", "ARGUMENTATION", message_id="GED-555-0002") + + self.assertTrue(any(cmd.__class__.__name__ == "StartSpeech" for cmd in executor.commands)) + self.assertEqual(len(structured_events), 1) + event = structured_events[0] + self.assertEqual(event["tipo_evento"], "envio msg") + self.assertIsNotNone(event["inicio_ns"]) + self.assertIsNotNone(event["fim_ns"]) + self.assertEqual(event["latencia_total_ms"], 456) + self.assertEqual(event["latencia_tffb_ms"], 123) + self.assertEqual(event["duracao_audio_ms"], 1500) + self.assertEqual(event["tts_max_gap_ms"], 840) + self.assertEqual(event["tts_max_underrun_0ms"], 125) + self.assertEqual(event["tts_underflow_count"], 2) + self.assertEqual(event["tts_avg_underflow_ms"], 80) + self.assertEqual(event["message_id"], "GED-555-0002") + self.assertEqual(event["http_cod_status"], 200) + self.assertEqual(event["http_cod_desc"], "OK") + + async def test_say_stage_leaves_tts_metrics_empty_when_metric_is_missing(self) -> None: + runtime, _agent, executor = self._make_runtime() + executor.speech_handle = SimpleNamespace(id="speech-without-metric", interrupted=False) + structured_events = [] + + def _capture_event(*args, **kwargs): + structured_events.append(kwargs) + + with mock.patch.dict(os.environ, {"TTS_TTFB_METRIC_WAIT_MS": "0"}), mock.patch( + "app.livekit.runtime.call_runtime.log_structured_event", + side_effect=_capture_event, + ): + await runtime.say_stage("resposta", "ARGUMENTATION", message_id="GED-555-0003") + + self.assertEqual(len(structured_events), 1) + self.assertIsNone(structured_events[0]["latencia_total_ms"]) + self.assertIsNone(structured_events[0]["latencia_tffb_ms"]) + self.assertIsNone(structured_events[0]["duracao_audio_ms"]) + + async def test_say_stage_logs_audio_duration_from_tts_metrics(self) -> None: + runtime, _agent, executor = self._make_runtime() + executor.speech_handle = SimpleNamespace(id="speech-with-audio-metric", interrupted=False) + runtime._record_tts_metric( + SimpleNamespace( + metrics=SimpleNamespace( + type="tts_metrics", + ttfb=0.123, + duration=0.400, + audio_duration=1.500, + speech_id="speech-with-audio-metric", + label="provider", + ) + ) + ) + structured_events = [] + flow_events = [] + + def _capture_event(*args, **kwargs): + structured_events.append(kwargs) + + def _capture_flow_event(_logger, event_name, **kwargs): + flow_events.append((event_name, kwargs)) + + with mock.patch( + "app.livekit.runtime.call_runtime.log_structured_event", + side_effect=_capture_event, + ), mock.patch( + "app.livekit.runtime.call_runtime.log_flow_event", + side_effect=_capture_flow_event, + ): + await runtime.say_stage("resposta", "ARGUMENTATION", message_id="GED-555-0004") + + self.assertEqual(len(structured_events), 1) + self.assertEqual(structured_events[0]["latencia_total_ms"], 400) + self.assertEqual(structured_events[0]["latencia_tffb_ms"], 123) + self.assertEqual(structured_events[0]["duracao_audio_ms"], 1500) + tts_done_events = [fields for event, fields in flow_events if event == "tts_done"] + self.assertEqual(tts_done_events[0]["duration_ms"], 400) + self.assertEqual(tts_done_events[0]["provider_duration_ms"], 400) + self.assertEqual(tts_done_events[0]["metrics_source"], "livekit_tts_metrics") + + async def test_say_stage_marks_interruption_with_cancelled_tts_metric(self) -> None: + runtime, _agent, executor = self._make_runtime() + executor.speech_handle = SimpleNamespace(id="speech-cancelled", interrupted=True) + executor.spoken_text = "preciso falar" + runtime._record_tts_metric( + SimpleNamespace( + metrics=SimpleNamespace( + type="tts_metrics", + ttfb=0.150, + duration=0.700, + audio_duration=0.250, + speech_id="speech-cancelled", + label="provider", + cancelled=True, + ) + ) + ) + structured_events = [] + flow_events = [] + + def _capture_event(*args, **kwargs): + structured_events.append(kwargs) + + def _capture_flow_event(_logger, event_name, **kwargs): + flow_events.append((event_name, kwargs)) + + with mock.patch( + "app.livekit.runtime.call_runtime.log_structured_event", + side_effect=_capture_event, + ), mock.patch( + "app.livekit.runtime.call_runtime.log_flow_event", + side_effect=_capture_flow_event, + ): + await runtime.say_stage( + "resposta interrompida", + "ARGUMENTATION", + message_id="GED-555-0005", + allow_interruptions=True, + ) + + self.assertEqual(len(structured_events), 1) + self.assertEqual(structured_events[0]["latencia_total_ms"], 700) + self.assertEqual(structured_events[0]["latencia_tffb_ms"], 150) + self.assertEqual(structured_events[0]["duracao_audio_ms"], 250) + self.assertIs(structured_events[0]["interrupcao"], True) + tts_done_events = [fields for event, fields in flow_events if event == "tts_done"] + self.assertIs(tts_done_events[0]["interruption"], True) + pending_commands = [cmd for cmd in executor.commands if isinstance(cmd, SetPendingInterrupt)] + self.assertEqual(len(pending_commands), 1) + self.assertEqual(pending_commands[0].listened_text, "preciso falar") + + async def test_say_stage_keeps_interruption_duration_without_first_tts_audio(self) -> None: + runtime, _agent, executor = self._make_runtime() + executor.speech_handle = SimpleNamespace(id="speech-cancelled-no-audio", interrupted=True) + runtime._record_tts_metric( + SimpleNamespace( + metrics=SimpleNamespace( + type="tts_metrics", + ttfb=-1.0, + duration=0.700, + audio_duration=0.0, + speech_id="speech-cancelled-no-audio", + label="provider", + cancelled=True, + ) + ) + ) + structured_events = [] + + def _capture_event(*args, **kwargs): + structured_events.append(kwargs) + + with mock.patch( + "app.livekit.runtime.call_runtime.log_structured_event", + side_effect=_capture_event, + ): + await runtime.say_stage( + "resposta interrompida", + "ARGUMENTATION", + message_id="GED-555-0006", + allow_interruptions=True, + ) + + self.assertEqual(len(structured_events), 1) + self.assertEqual(structured_events[0]["latencia_total_ms"], 700) + self.assertIsNone(structured_events[0]["latencia_tffb_ms"]) + self.assertIsNone(structured_events[0]["duracao_audio_ms"]) + self.assertIs(structured_events[0]["interrupcao"], True) + + async def test_say_stage_returns_to_listening_when_no_first_audio_is_seen(self) -> None: + runtime, _agent, executor = self._make_runtime() + first_handle = SimpleNamespace(id="speech-first", interrupted=False) + executor.speech_handles = [first_handle] + executor.wait_for_playout_delays_s = [0.05] + flow_events = [] + + def capture(_logger, event_name, **kwargs): + flow_events.append((event_name, kwargs)) + + with mock.patch.dict(os.environ, {"TTS_PLAYOUT_START_TIMEOUT_S": "0.01", "TTS_TTFB_METRIC_WAIT_MS": "0"}), mock.patch("app.livekit.runtime.call_runtime.log_flow_event", side_effect=capture): + result = await runtime.say_stage("resposta", "ARGUMENTATION", message_id="GED-555-0005") + + self.assertFalse(result) + speech_commands = [cmd for cmd in executor.commands if cmd.__class__.__name__ == "StartSpeech"] + self.assertEqual(len(speech_commands), 1) + self.assertFalse(runtime.finalized.is_set()) + self.assertTrue(first_handle.interrupted) + self.assertTrue(any(event == "tts_failure_return_to_listening" for event, _ in flow_events)) + + async def test_say_stage_returns_to_listening_after_partial_audio_failure(self) -> None: + runtime, _agent, executor = self._make_runtime() + first_handle = SimpleNamespace(id="speech-gap", interrupted=False) + executor.speech_handles = [first_handle] + executor.wait_for_playout_exceptions = [RuntimeError("xAI TTS partial audio failure: underflow_error; socket_resynchronized=0; xai_underrun_estimado_ms=1001; xai_micro_underflows=1; xai_avg_underrun_ms=1001; underflow_error_ms=1000; max_audio_delta_gap_ms=1200; pcm_duration_ms=500; pcm_bytes=24000")] + structured_events = [] + + def capture_event(*_args, **kwargs): + structured_events.append(kwargs) + + with mock.patch.dict(os.environ, {"TTS_TTFB_METRIC_WAIT_MS": "0"}), mock.patch("app.livekit.runtime.call_runtime.log_structured_event", side_effect=capture_event): + result = await runtime.say_stage("resposta com falha no meio", "ARGUMENTATION", message_id="GED-555-0006") + + self.assertFalse(result) + speech_commands = [cmd for cmd in executor.commands if cmd.__class__.__name__ == "StartSpeech"] + self.assertEqual(len(speech_commands), 1) + self.assertFalse(runtime.finalized.is_set()) + self.assertTrue(first_handle.interrupted) + self.assertEqual(structured_events[0]["erro_msg"], "Falha TTS") + self.assertIn("underflow_error", structured_events[0]["erro_detalhe"]) + self.assertIn("underflow_error_ms=1000", structured_events[0]["erro_detalhe"]) + self.assertEqual(structured_events[0]["tts_max_gap_ms"], 1200) + self.assertEqual(structured_events[0]["tts_max_underrun_0ms"], 1001) + self.assertEqual(structured_events[0]["tts_underflow_count"], 1) + self.assertEqual(structured_events[0]["tts_avg_underflow_ms"], 1001) + + async def test_say_stage_publishes_partial_failure_without_livekit_metrics(self) -> None: + runtime, _agent, executor = self._make_runtime() + first_handle = SimpleNamespace(id="speech-partial-event", interrupted=False) + executor.speech_handles = [first_handle] + structured_events = [] + + def emit_partial_failure(_command) -> None: + runtime._record_xai_tts_turn_failed( + { + "segment_id": "segment-partial-event", + "reason": "underflow_error", + "max_audio_delta_gap_ms": 920, + "max_playout_underrun_0ms": 1001, + "xai_micro_underflows": 3, + "xai_avg_underrun_ms": 611, + "provider_synthesis_ms": 2450, + "pcm_duration_ms": 860, + } + ) + + executor.wait_for_playout_callbacks = [emit_partial_failure] + + def capture_event(*_args, **kwargs): + structured_events.append(kwargs) + + with mock.patch.dict(os.environ, {"TTS_TTFB_METRIC_WAIT_MS": "0"}), mock.patch( + "app.livekit.runtime.call_runtime.log_structured_event", + side_effect=capture_event, + ): + result = await runtime.say_stage( + "resposta com falha parcial", + "ARGUMENTATION", + message_id="GED-555-0008", + ) + + self.assertFalse(result) + self.assertTrue(first_handle.interrupted) + self.assertEqual(len(structured_events), 1) + event = structured_events[0] + self.assertEqual(event["erro_msg"], "Falha TTS") + self.assertIn("underflow_error", event["erro_detalhe"]) + self.assertEqual(event["latencia_total_ms"], 2450) + self.assertEqual(event["duracao_audio_ms"], 860) + self.assertEqual(event["tts_max_gap_ms"], 920) + self.assertEqual(event["tts_max_underrun_0ms"], 1001) + self.assertEqual(event["tts_underflow_count"], 3) + self.assertEqual(event["tts_avg_underflow_ms"], 611) + self.assertIsNone(event["http_cod_status"]) + + async def test_say_stage_returns_to_listening_after_swallowed_tts_error(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.register_callbacks() + first_handle = SimpleNamespace(id="speech-swallowed", interrupted=False) + executor.speech_handles = [first_handle] + + def emit_error(_command): + error = SimpleNamespace(type="tts_error", error=RuntimeError("no audio frames were pushed for text: resposta"), recoverable=False) + runtime._session.handlers["error"](SimpleNamespace(error=error)) + + executor.wait_for_playout_callbacks = [emit_error] + with mock.patch.dict(os.environ, {"TTS_TTFB_METRIC_WAIT_MS": "0"}): + result = await runtime.say_stage("resposta", "PRESENTATION", message_id="GED-555-0007") + + self.assertFalse(result) + speech_commands = [cmd for cmd in executor.commands if cmd.__class__.__name__ == "StartSpeech"] + self.assertEqual(len(speech_commands), 1) + self.assertFalse(runtime.finalized.is_set()) + + async def test_tts_metric_zero_is_not_used_as_ttfb(self) -> None: + runtime, _agent, _executor = self._make_runtime() + + runtime._record_tts_metric( + SimpleNamespace( + metrics=SimpleNamespace( + type="tts_metrics", + ttfb=0, + speech_id="speech-zero", + label="provider", + ) + ) + ) + + self.assertNotIn("speech-zero", runtime._tts_tffb_ms_by_speech_handle_id) + + async def test_finalize_is_idempotent(self) -> None: + runtime, _agent, executor = self._make_runtime() + + await asyncio.gather(runtime.finalize("first"), runtime.finalize("second")) + + self.assertTrue(runtime.finalized.is_set()) + self.assertEqual(sum(isinstance(cmd, EndServiceOnce) for cmd in executor.commands), 1) + self.assertEqual(sum(isinstance(cmd, ExportSession) for cmd in executor.commands), 1) + + async def test_run_pipeline_done_triggers_done_notification_and_finalize(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.user_final_seq = 1 + executor.pipeline_result = BackendReply(stage="DONE", text="encerrando", done=True) + + await runtime.run_pipeline("texto", "texto", user_seq=1) + await self._drain_tasks() + + self.assertTrue(any(isinstance(cmd, NotifyBridgeDone) for cmd in executor.commands)) + self.assertTrue(any(isinstance(cmd, EndServiceOnce) for cmd in executor.commands)) + self.assertTrue(any(isinstance(cmd, ExportSession) for cmd in executor.commands)) + + async def test_run_pipeline_non_terminal_reply_does_not_finalize_from_global_done_stage( + self, + ) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.user_final_seq = 1 + executor.pipeline_result = BackendReply( + stage="PRESENTATION", + text="Perfeito! Seguiremos com o cancelamento.", + done=False, + ) + + def _mark_global_stage_done(_command) -> None: + runtime.state.current_stage = "DONE" + + executor.wait_for_playout_callbacks = [_mark_global_stage_done] + + await runtime.run_pipeline("texto", "texto", user_seq=1) + await self._drain_tasks() + + self.assertFalse(any(isinstance(cmd, NotifyBridgeDone) for cmd in executor.commands)) + self.assertFalse(any(isinstance(cmd, EndServiceOnce) for cmd in executor.commands)) + self.assertFalse(runtime.finalized.is_set()) + + async def test_run_pipeline_agent_final_result_speaks_before_terminal_stop(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + executor.pipeline_result = BackendReply( + stage="DONE", + text="Atendimento encerrado com sucesso.", + done=True, + export_payload={ + "type": "nao_resolvido", + "content": "Atendimento encerrado com sucesso.", + "tool_calls": [], + }, + ) + executor.end_reply = BackendReply( + stage="DONE", + text="Nao deveria falar de novo.", + done=True, + export_payload=[{"status": "ok"}], + ) + + await runtime.run_pipeline("texto", "texto", user_seq=1) + await self._drain_tasks() + + speech_commands = [cmd for cmd in executor.commands if cmd.__class__.__name__ == "StartSpeech"] + stop_commands = [cmd for cmd in executor.commands if isinstance(cmd, NotifyBridgeStop)] + self.assertEqual([cmd.text for cmd in speech_commands], ["Atendimento encerrado com sucesso."]) + self.assertEqual(len(stop_commands), 1) + self.assertEqual(stop_commands[0].status, "stop_nao_resolvido") + self.assertEqual(stop_commands[0].reason, "nao_resolvido") + self.assertEqual(stop_commands[0].phase, "in_session") + self.assertFalse(any(isinstance(cmd, NotifyBridgeDone) for cmd in executor.commands)) + self.assertLess( + executor.commands.index(speech_commands[0]), + executor.commands.index(stop_commands[0]), + ) + self.assertTrue(runtime.finalized.is_set()) + self.assertFalse(any(isinstance(cmd, EndServiceOnce) for cmd in executor.commands)) + self.assertFalse(any(isinstance(cmd, ExportSession) for cmd in executor.commands)) + + async def test_terminal_reply_waits_until_user_stops_speaking(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + runtime._user_not_speaking.clear() + executor.pipeline_result = BackendReply( + stage="DONE", + text="Atendimento encerrado.", + done=True, + export_payload={"type": "nao_resolvido", "content": "Atendimento encerrado."}, + ) + + with mock.patch( + "app.livekit.runtime.call_runtime.POST_USER_FINAL_SPEECH_GRACE_S", + 0.01, + ): + task = asyncio.create_task(runtime.run_pipeline("texto", "texto", user_seq=1)) + await asyncio.sleep(0.01) + self.assertFalse(any(isinstance(cmd, StartSpeech) for cmd in executor.commands)) + + runtime._user_not_speaking.set() + await task + + self.assertEqual( + [cmd.text for cmd in executor.commands if isinstance(cmd, StartSpeech)], + ["Atendimento encerrado."], + ) + + async def test_terminal_reply_is_spoken_when_speaking_becomes_new_user_turn(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + runtime._user_not_speaking.clear() + executor.pipeline_result = BackendReply( + stage="DONE", + text="Atendimento encerrado.", + done=True, + export_payload={"type": "nao_resolvido", "content": "Atendimento encerrado."}, + ) + + with mock.patch( + "app.livekit.runtime.call_runtime.POST_USER_FINAL_SPEECH_GRACE_S", + 0.01, + ): + task = asyncio.create_task(runtime.run_pipeline("texto", "texto", user_seq=1)) + await asyncio.sleep(0.01) + runtime._user_not_speaking.set() + runtime.state.user_final_seq = 2 + await task + + speech_commands = [cmd for cmd in executor.commands if isinstance(cmd, StartSpeech)] + self.assertEqual([cmd.text for cmd in speech_commands], ["Atendimento encerrado."]) + self.assertFalse(speech_commands[0].allow_interruptions) + self.assertTrue(any(isinstance(cmd, NotifyBridgeStop) for cmd in executor.commands)) + self.assertTrue(runtime.finalized.is_set()) + + async def test_run_pipeline_skips_new_turn_after_done_stage(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.current_stage = "DONE" + runtime.state.user_final_seq = 2 + + await runtime.run_pipeline("Ok,", "Ok,", user_seq=2) + + self.assertFalse(any(isinstance(cmd, RunPipelineInput) for cmd in executor.commands)) + self.assertFalse(any(isinstance(cmd, StartSpeech) for cmd in executor.commands)) + + async def test_run_pipeline_ready_wait_timeout_sends_long_silence_stop(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 0 + executor.pipeline_result = BackendReply( + stage="PRESENTATION", + text="Ola, como posso ajudar com sua fatura?", + metadata={"wait_timeout_seconds": 0.01}, + ) + structured_events = [] + + def _capture_event(*args, **kwargs): + structured_events.append(kwargs) + + with mock.patch("app.livekit.runtime.call_runtime.log_structured_event", side_effect=_capture_event): + await runtime.run_pipeline("", "", user_seq=0) + await self._drain_tasks() + + speech_commands = [cmd for cmd in executor.commands if cmd.__class__.__name__ == "StartSpeech"] + stop_commands = [cmd for cmd in executor.commands if isinstance(cmd, NotifyBridgeStop)] + self.assertEqual([cmd.text for cmd in speech_commands], ["Ola, como posso ajudar com sua fatura?"]) + self.assertEqual(len(stop_commands), 1) + self.assertEqual(stop_commands[0].status, "stop_silencio_longo") + self.assertEqual(stop_commands[0].reason, "no_user_response") + self.assertEqual(stop_commands[0].phase, "in_session") + self.assertTrue( + any( + event["tipo_evento"] == "recebimento msg" + and event.get("erro_msg") == "Silencio Longo" + for event in structured_events + ) + ) + self.assertTrue(runtime.finalized.is_set()) + self.assertFalse(any(isinstance(cmd, EndServiceOnce) for cmd in executor.commands)) + self.assertFalse(any(isinstance(cmd, ExportSession) for cmd in executor.commands)) + + async def test_run_pipeline_ready_wait_timeout_sends_retry_messages_before_stop(self) -> None: + controller = _FakeVADThresholdController() + runtime, _agent, executor = self._make_runtime(vad_threshold_controller=controller) + runtime._timeline = _FakeTimeline() + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 0 + retry_messages = [ + "Voce ainda esta na linha?", + "Sigo por aqui.", + "Vou confirmar mais uma vez.", + ] + executor.pipeline_result = BackendReply( + stage="PRESENTATION", + text="Ola, como posso ajudar com sua fatura?", + metadata={ + "message_id": "MSG-timeout-0001", + "wait_timeout_seconds": 0.01, + "wait_retry_messages": retry_messages, + }, + ) + + structured_events = [] + + def _capture_structured_event(*args, **kwargs): + structured_events.append(kwargs) + + with mock.patch( + "app.livekit.runtime.call_runtime.log_structured_event", + side_effect=_capture_structured_event, + ): + await runtime.run_pipeline("", "", user_seq=0) + await self._drain_tasks() + + speech_commands = [cmd for cmd in executor.commands if cmd.__class__.__name__ == "StartSpeech"] + stop_commands = [cmd for cmd in executor.commands if isinstance(cmd, NotifyBridgeStop)] + self.assertEqual( + [cmd.text for cmd in speech_commands], + ["Ola, como posso ajudar com sua fatura?", *retry_messages], + ) + self.assertEqual( + [cmd.add_to_chat_ctx for cmd in speech_commands], + [True, False, False, False], + ) + self.assertEqual( + [cmd.allow_interruptions for cmd in speech_commands[1:]], + [True, True, True], + ) + self.assertIn(("activate", 1, "agent_wait_timeout_retry"), controller.calls) + self.assertIn(("restore", "agent_wait_timeout:no_user_response"), controller.calls) + event_ids = [event["message_id"] for event in structured_events] + self.assertEqual( + event_ids[:4], + [ + "MSG-timeout-0001", + "MSG-timeout-0001_inatividade_1", + "MSG-timeout-0001_inatividade_2", + "MSG-timeout-0001_inatividade_3", + ], + ) + self.assertEqual(len(event_ids), 5) + self.assertIn("_erro_terminal_recebimento_", event_ids[4]) + retry_events = [ + fields + for event, fields in runtime._timeline.events + if event == "agent_wait_timeout_retry" + ] + self.assertEqual( + [event["message_id"] for event in retry_events], + [ + "MSG-timeout-0001_inatividade_1", + "MSG-timeout-0001_inatividade_2", + "MSG-timeout-0001_inatividade_3", + ], + ) + self.assertEqual(len(stop_commands), 1) + self.assertEqual(stop_commands[0].status, "stop_silencio_longo") + self.assertEqual(stop_commands[0].reason, "no_user_response") + self.assertEqual(stop_commands[0].phase, "in_session") + self.assertLess( + executor.commands.index(speech_commands[-1]), + executor.commands.index(stop_commands[0]), + ) + self.assertTrue(runtime.finalized.is_set()) + self.assertFalse(any(isinstance(cmd, EndServiceOnce) for cmd in executor.commands)) + self.assertFalse(any(isinstance(cmd, ExportSession) for cmd in executor.commands)) + + async def test_agent_wait_timeout_retry_registers_idle_nudge_event(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._timeline = _FakeTimeline() + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 0 + retry_messages = [ + "Voce ainda esta na linha?", + "Sigo por aqui.", + "Vou confirmar mais uma vez.", + ] + executor.pipeline_result = BackendReply( + stage="PRESENTATION", + text="Ola, como posso ajudar com sua fatura?", + metadata={ + "message_id": "MSG-timeout-0001", + "wait_timeout_seconds": 0.01, + "wait_retry_messages": retry_messages, + }, + ) + + await runtime.run_pipeline("", "", user_seq=0) + await self._drain_tasks() + + nudge_commands = [cmd for cmd in executor.commands if isinstance(cmd, InjectIdleNudge)] + self.assertEqual([cmd.text for cmd in nudge_commands], retry_messages) + + # O evento e bufferizado ANTES da fala, para sobreviver a uma interrupcao + # do cliente em cima do retry. + speech_commands = [cmd for cmd in executor.commands if cmd.__class__.__name__ == "StartSpeech"] + for nudge, speech in zip(nudge_commands, speech_commands[1:]): + self.assertLess( + executor.commands.index(nudge), + executor.commands.index(speech), + ) + + async def test_agent_wait_timeout_metadata_persists_for_later_replies(self) -> None: + runtime, _agent, _executor = self._make_runtime() + runtime._timeline = _FakeTimeline() + + try: + runtime.arm_agent_wait_timeout_from_reply( + BackendReply( + stage="PRESENTATION", + text="Ola, como posso ajudar?", + metadata={ + "wait_timeout_seconds": 60.0, + "wait_retry_messages": ["Voce ainda esta na linha?"], + }, + ), + user_seq=0, + source="initial", + ) + runtime.cancel_agent_wait_timeout("user_final") + runtime._clear_agent_wait_timeout_memory() + runtime.state.user_final_seq = 1 + + runtime.arm_agent_wait_timeout_from_reply( + BackendReply( + stage="PRESENTATION", + text="Pode me responder?", + ), + user_seq=1, + source="next_reply", + ) + + armed_events = [ + fields + for event, fields in runtime._timeline.events + if event == "agent_wait_timeout_armed" + ] + self.assertEqual(len(armed_events), 2) + self.assertEqual(armed_events[-1]["wait_timeout_s"], 60.0) + self.assertEqual(armed_events[-1]["retry_messages"], 1) + self.assertEqual(armed_events[-1]["source"], "next_reply") + self.assertEqual(armed_events[-1]["user_seq"], 1) + finally: + await self._cancel_pending_tasks() + + async def test_agent_wait_timeout_metadata_rewrites_persisted_config(self) -> None: + runtime, _agent, _executor = self._make_runtime() + runtime._timeline = _FakeTimeline() + + try: + runtime.arm_agent_wait_timeout_from_reply( + BackendReply( + stage="PRESENTATION", + text="Primeira pergunta", + metadata={ + "wait_timeout_seconds": 60.0, + "wait_retry_messages": ["Mensagem antiga"], + }, + ), + user_seq=0, + source="initial", + ) + runtime.cancel_agent_wait_timeout("user_final") + runtime._clear_agent_wait_timeout_memory() + runtime.state.user_final_seq = 1 + + runtime.arm_agent_wait_timeout_from_reply( + BackendReply( + stage="PRESENTATION", + text="Nova pergunta", + metadata={ + "wait_timeout_seconds": 30.0, + "wait_retry_messages": ["Nova mensagem", "Ultimo aviso"], + }, + ), + user_seq=1, + source="rewrite", + ) + runtime.cancel_agent_wait_timeout("user_final") + runtime._clear_agent_wait_timeout_memory() + runtime.state.user_final_seq = 2 + + runtime.arm_agent_wait_timeout_from_reply( + BackendReply( + stage="PRESENTATION", + text="Pergunta sem metadata", + ), + user_seq=2, + source="after_rewrite", + ) + + armed_events = [ + fields + for event, fields in runtime._timeline.events + if event == "agent_wait_timeout_armed" + ] + self.assertEqual(len(armed_events), 3) + self.assertEqual(armed_events[-2]["wait_timeout_s"], 30.0) + self.assertEqual(armed_events[-2]["retry_messages"], 2) + self.assertEqual(armed_events[-1]["wait_timeout_s"], 30.0) + self.assertEqual(armed_events[-1]["retry_messages"], 2) + self.assertEqual(armed_events[-1]["source"], "after_rewrite") + self.assertEqual(armed_events[-1]["user_seq"], 2) + finally: + await self._cancel_pending_tasks() + + async def test_feedback_reply_does_not_arm_client_wait_timeout(self) -> None: + runtime, _agent, _executor = self._make_runtime() + runtime._timeline = _FakeTimeline() + + try: + runtime.arm_agent_wait_timeout_from_reply( + BackendReply( + stage="PRESENTATION", + text="Posso verificar sua fatura.", + metadata={ + "agent_message_type": "ready", + "expects_user_response": True, + "drop_user_input_while_speaking": True, + "is_interruptible": False, + "wait_timeout_seconds": 60.0, + "wait_retry_messages": ["Voce esta ai?"], + }, + ), + user_seq=0, + source="ready", + ) + runtime.cancel_agent_wait_timeout("test") + runtime._clear_agent_wait_timeout_memory() + + runtime.arm_agent_wait_timeout_from_reply( + BackendReply( + stage="PRESENTATION", + text="Ainda estou consultando sua fatura.", + metadata={ + "agent_message_type": "feedback", + "expects_user_response": False, + "drop_user_input_while_speaking": True, + "is_interruptible": False, + "event": "feedback", + }, + ), + user_seq=0, + source="feedback", + ) + + armed_events = [ + fields + for event, fields in runtime._timeline.events + if event == "agent_wait_timeout_armed" + ] + self.assertEqual(len(armed_events), 1) + + runtime.arm_agent_wait_timeout_from_reply( + BackendReply( + stage="PRESENTATION", + text="Consegui consultar. Posso te ajudar em mais algo?", + metadata={ + "agent_message_type": "result", + "agent_result_type": "final", + "expects_user_response": True, + "drop_user_input_while_speaking": False, + }, + ), + user_seq=0, + source="final", + ) + + armed_events = [ + fields + for event, fields in runtime._timeline.events + if event == "agent_wait_timeout_armed" + ] + self.assertEqual(len(armed_events), 2) + self.assertEqual(armed_events[-1]["source"], "final") + self.assertEqual(armed_events[-1]["retry_messages"], 1) + finally: + await self._cancel_pending_tasks() + + async def test_agent_wait_timeout_zero_metadata_clears_persisted_config(self) -> None: + runtime, _agent, _executor = self._make_runtime() + runtime._timeline = _FakeTimeline() + + try: + runtime.arm_agent_wait_timeout_from_reply( + BackendReply( + stage="PRESENTATION", + text="Primeira pergunta", + metadata={"wait_timeout_seconds": 60.0}, + ), + user_seq=0, + source="initial", + ) + + runtime.arm_agent_wait_timeout_from_reply( + BackendReply( + stage="PRESENTATION", + text="Sem timeout daqui em diante", + metadata={"wait_timeout_seconds": 0}, + ), + user_seq=0, + source="disable", + ) + runtime.state.user_final_seq = 1 + runtime.arm_agent_wait_timeout_from_reply( + BackendReply( + stage="PRESENTATION", + text="Pergunta sem metadata", + ), + user_seq=1, + source="after_disable", + ) + + armed_events = [ + fields + for event, fields in runtime._timeline.events + if event == "agent_wait_timeout_armed" + ] + self.assertEqual(len(armed_events), 1) + finally: + await self._cancel_pending_tasks() + + async def test_run_pipeline_backend_failure_triggers_terminal_stop_and_finalize(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.user_final_seq = 1 + + async def _fail_backend() -> None: + raise RuntimeError("backend closed connection") + + executor.run_pipeline_side_effect = _fail_backend + structured_events = [] + + def _capture_event(*args, **kwargs): + structured_events.append(kwargs) + + with mock.patch("app.livekit.runtime.call_runtime.log_structured_event", side_effect=_capture_event): + await runtime.run_pipeline("texto", "texto", user_seq=1) + + stop_commands = [cmd for cmd in executor.commands if isinstance(cmd, NotifyBridgeStop)] + self.assertEqual(len(stop_commands), 1) + self.assertEqual(stop_commands[0].status, "stop_agent_backend_unavailable") + self.assertEqual(stop_commands[0].reason, "resource_unhealthy") + self.assertEqual(stop_commands[0].resource, "agent_backend") + self.assertEqual(stop_commands[0].failed_resources, ("agent_backend",)) + self.assertTrue( + any( + event["tipo_evento"] == "envio msg" + and event.get("erro_msg") == "Falha comunicacao" + and "_erro_terminal_envio_" in event.get("message_id", "") + for event in structured_events + ) + ) + self.assertTrue(any(isinstance(cmd, EndServiceOnce) for cmd in executor.commands)) + self.assertTrue(any(isinstance(cmd, ExportSession) for cmd in executor.commands)) + + async def test_run_pipeline_transfer_reply_uses_suffixed_message_id(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime.state.user_final_seq = 1 + executor.pipeline_result = BackendReply( + stage="PRESENTATION", + text="", + done=False, + export_payload={ + "type": "transferred", + "status": "transferred", + "additionalInformations": {"changeAgentId": "ATH-001"}, + }, + ) + structured_events = [] + + def _capture_event(*args, **kwargs): + structured_events.append(kwargs) + + with mock.patch("app.livekit.runtime.call_runtime.log_structured_event", side_effect=_capture_event): + await runtime.run_pipeline( + "texto", + "texto", + user_seq=1, + message_id="GED-test-0002", + ) + + self.assertTrue( + any( + event["tipo_evento"] == "envio msg" + and event.get("erro_msg") == "Transferido" + and event.get("message_id", "").startswith("GED-test-0002_transferencia_") + for event in structured_events + ) + ) + + async def test_finalize_speaks_end_reply_when_remote_listener_exists(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._ctx.room.remote_participants = {"user-1": object()} + executor.end_reply = BackendReply( + stage="DONE", + text="Mensagem final do backend.", + done=True, + export_payload=[{"status": "ok"}], + ) + + await runtime.finalize("manual") + + self.assertTrue(any(cmd.__class__.__name__ == "StartSpeech" for cmd in executor.commands)) + self.assertTrue(any(isinstance(cmd, ExportSession) for cmd in executor.commands)) + + async def test_finalize_skips_end_reply_tts_without_remote_listener(self) -> None: + runtime, _agent, executor = self._make_runtime() + executor.end_reply = BackendReply( + stage="DONE", + text="Mensagem final do backend.", + done=True, + export_payload=[{"status": "ok"}], + ) + + await runtime.finalize("manual") + + self.assertFalse(any(cmd.__class__.__name__ == "StartSpeech" for cmd in executor.commands)) + self.assertTrue(any(isinstance(cmd, ExportSession) for cmd in executor.commands)) + + async def test_register_callbacks_wires_session_and_shutdown(self) -> None: + runtime, _agent, _executor = self._make_runtime() + + runtime.register_callbacks() + + self.assertIn("user_input_transcribed", runtime._session.handlers) + self.assertIn("close", runtime._session.handlers) + self.assertIn("data_received", runtime._ctx.room.handlers) + self.assertEqual(len(runtime._ctx.shutdown_callbacks), 1) + + async def test_session_close_error_triggers_resource_stop(self) -> None: + runtime, _agent, executor = self._make_runtime() + + runtime.register_callbacks() + runtime._session.handlers["close"]( + SimpleNamespace( + reason=SimpleNamespace(value="error"), + error=SimpleNamespace(type="stt_error"), + ) + ) + await self._drain_tasks() + + stop_commands = [cmd for cmd in executor.commands if isinstance(cmd, NotifyBridgeStop)] + self.assertEqual(len(stop_commands), 1) + self.assertEqual(stop_commands[0].status, "stop_stt_unavailable") + self.assertEqual(stop_commands[0].resource, "stt") + + async def test_bridge_control_client_audio_enabled_triggers_initial_agent_turn(self) -> None: + runtime, _agent, executor = self._make_runtime(agent_starts_conversation=True) + runtime._ctx.room.remote_participants = {"user-1": object()} + executor.pipeline_result = BackendReply(stage="PRESENTATION", text="Ola, sou o agent.") + + runtime.register_callbacks() + runtime._ctx.room.handlers["data_received"]( + SimpleNamespace( + topic="bridge.control", + data=b'{"type":"client_audio_enabled","room":"room-test","protocol":"PRT-1"}', + ) + ) + await self._drain_tasks() + + run_commands = [cmd for cmd in executor.commands if isinstance(cmd, RunPipelineInput)] + self.assertEqual(len(run_commands), 1) + self.assertEqual(run_commands[0].user_input, "") + self.assertTrue(any(cmd.__class__.__name__ == "StartSpeech" for cmd in executor.commands)) + + async def test_run_holds_user_audio_input_before_starting_session(self) -> None: + runtime, _agent, executor = self._make_runtime(agent_starts_conversation=True) + runtime._ctx.room.remote_participants = {"user-1": object()} + audio_enabled_on_start = [] + executor.on_start_session = lambda _cmd: audio_enabled_on_start.append( + runtime._session.input.audio_enabled + ) + + await runtime.run() + await asyncio.sleep(0) + await self._cancel_pending_tasks() + + # O RoomIO le o estado do input ao anexar a track: se o hold viesse depois + # do StartSession, o audio do setup ja teria chegado no VAD/STT. + self.assertEqual(audio_enabled_on_start, [False]) + self.assertFalse(runtime._session.input.audio_enabled) + self.assertEqual(runtime._session.input.calls, [False]) + + async def test_run_keeps_user_audio_input_when_user_starts_conversation(self) -> None: + runtime, _agent, _executor = self._make_runtime(agent_starts_conversation=False) + runtime._ctx.room.remote_participants = {"user-1": object()} + + await runtime.run() + await asyncio.sleep(0) + await self._cancel_pending_tasks() + + self.assertTrue(runtime._session.input.audio_enabled) + self.assertEqual(runtime._session.input.calls, []) + + async def test_initial_agent_turn_releases_user_audio_input_after_playout(self) -> None: + runtime, _agent, executor = self._make_runtime(agent_starts_conversation=True) + runtime._ctx.room.remote_participants = {"user-1": object()} + executor.pipeline_result = BackendReply(stage="PRESENTATION", text="Ola, sou o agent.") + audio_enabled_during_playout = [] + executor.wait_for_playout_callbacks = [ + lambda _cmd: audio_enabled_during_playout.append( + runtime._session.input.audio_enabled + ) + ] + + runtime.hold_user_audio_input(reason="room_setup") + runtime.register_callbacks() + runtime._ctx.room.handlers["data_received"]( + SimpleNamespace( + topic="bridge.control", + data=b'{"type":"client_audio_enabled","room":"room-test","protocol":"PRT-1"}', + ) + ) + await self._drain_tasks() + + self.assertEqual(audio_enabled_during_playout, [False]) + self.assertTrue(runtime._session.input.audio_enabled) + self.assertEqual(runtime._session.input.calls, [False, True]) + + async def test_initial_agent_turn_releases_user_audio_input_when_pipeline_fails(self) -> None: + runtime, _agent, executor = self._make_runtime(agent_starts_conversation=True) + runtime._ctx.room.remote_participants = {"user-1": object()} + + async def _boom() -> None: + raise RuntimeError("backend fora do ar") + + executor.run_pipeline_side_effect = _boom + + runtime.hold_user_audio_input(reason="room_setup") + runtime.register_callbacks() + runtime._ctx.room.handlers["data_received"]( + SimpleNamespace( + topic="bridge.control", + data=b'{"type":"client_audio_enabled","room":"room-test","protocol":"PRT-1"}', + ) + ) + await self._drain_tasks() + + # Falha no turno inicial nao pode deixar a chamada surda. + self.assertTrue(runtime._session.input.audio_enabled) + + async def test_user_audio_input_gate_timeout_releases_when_initial_turn_never_starts( + self, + ) -> None: + runtime, _agent, _executor = self._make_runtime(agent_starts_conversation=True) + runtime._ctx.room.remote_participants = {"user-1": object()} + + with mock.patch.dict(os.environ, {"USER_AUDIO_INPUT_SETUP_TIMEOUT_S": "0.01"}): + runtime.hold_user_audio_input(reason="room_setup") + runtime._arm_user_audio_input_gate_timeout() + await self._drain_tasks() + + # client_audio_enabled nunca chegou: o gate abre sozinho. + self.assertTrue(runtime._session.input.audio_enabled) + self.assertEqual(runtime._session.input.calls, [False, True]) + + async def test_backend_push_message_is_spoken_without_new_user_turn(self) -> None: + runtime, _agent, executor = self._make_runtime( + agent_starts_conversation=True, + push_replies=[BackendReply(stage="PRESENTATION", text="Segunda mensagem do backend.")], + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + executor.pipeline_result = BackendReply(stage="PRESENTATION", text="Primeira mensagem do backend.") + + runtime.register_callbacks() + runtime._ctx.room.handlers["data_received"]( + SimpleNamespace( + topic="bridge.control", + data=b'{"type":"client_audio_enabled","room":"room-test","protocol":"PRT-1"}', + ) + ) + await self._drain_tasks() + + speech_texts = [ + cmd.text + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertEqual( + speech_texts, + [ + "Primeira mensagem do backend.", + "Segunda mensagem do backend.", + ], + ) + + async def test_inflight_backend_feedback_is_spoken_before_final_reply(self) -> None: + runtime, agent, executor = self._make_runtime() + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + + pipeline = _FakeInflightPushPipeline() + agent.pipeline = pipeline + executor.pipeline_result = BackendReply( + stage="PRESENTATION", + text="Resposta final do backend.", + ) + + async def _emit_feedback_then_finish() -> None: + await pipeline.push( + BackendReply( + stage="PRESENTATION", + text="Ainda estou consultando sua fatura.", + metadata={ + "event": "feedback", + "agent_message_type": "feedback", + "expects_user_response": False, + "drop_user_input_while_speaking": True, + "is_interruptible": False, + }, + ) + ) + await asyncio.sleep(0.01) + pipeline.close() + + executor.run_pipeline_side_effect = _emit_feedback_then_finish + + await runtime.run_pipeline("texto", "texto", user_seq=1) + await self._drain_tasks() + + speech_texts = [ + cmd.text + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertEqual( + speech_texts, + [ + "Ainda estou consultando sua fatura.", + "Resposta final do backend.", + ], + ) + speech_commands = [ + cmd + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertFalse(speech_commands[0].allow_interruptions) + + async def test_inflight_backend_wait_notice_is_spoken_immediately_after_stt_final(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 0.01, + "inflight_backend_wait_max_notices": 1, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + agent.pipeline = _FakeInflightPushPipeline() + never_finishes = asyncio.Event() + + async def _hang_backend() -> None: + await never_finishes.wait() + + executor.run_pipeline_side_effect = _hang_backend + + await runtime.run_pipeline("texto", "texto", user_seq=1) + await self._drain_tasks() + + speech_texts = [ + cmd.text + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertEqual(speech_texts, [_expected_wait_text_for_audio(WAIT_LONG_AUDIO_PATH, wait_text)]) + + async def test_inflight_backend_wait_audio_sequence_starts_short_then_long(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + with TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) + short_dir = base_dir / "short" + long_dir = base_dir / "long" + short_dir.mkdir() + long_dir.mkdir() + short_audio = short_dir / "curto.wav" + long_audio = long_dir / "longo.wav" + short_audio.write_bytes(b"short") + long_audio.write_bytes(b"long") + + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 0.005, + "inflight_backend_wait_timeout_s": 0.6, + "inflight_backend_wait_max_notices": 2, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_short_audio_dir": str(short_dir), + "inflight_backend_wait_long_audio_dir": str(long_dir), + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + runtime._timeline = _FakeTimeline() + agent.pipeline = _FakeInflightPushPipeline() + never_finishes = asyncio.Event() + + async def _hang_backend() -> None: + await never_finishes.wait() + + def _audio_frames(path: str): + return f"audio:{Path(path).name}" + + executor.run_pipeline_side_effect = _hang_backend + + with ( + mock.patch("app.livekit.runtime.call_runtime.wav_duration_ms", return_value=100), + mock.patch( + "app.livekit.runtime.call_runtime.wav_audio_frames", + side_effect=_audio_frames, + ), + mock.patch.object(runtime, "_log_resource_error_events") as structured_error, + ): + await runtime.run_pipeline("texto", "texto", user_seq=1) + await self._drain_tasks() + + speech_commands = [ + cmd + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertEqual( + [cmd.audio for cmd in speech_commands], + ["audio:curto.wav", "audio:longo.wav"], + ) + max_notice_events = [ + fields + for event, fields in runtime._timeline.events + if event == "inflight_backend_wait_max_notices_reached" + ] + self.assertEqual(len(max_notice_events), 1) + self.assertEqual(max_notice_events[0]["notices_sent"], 2) + self.assertEqual(max_notice_events[0]["max_notices"], 2) + max_notice_structured_calls = [ + call + for call in structured_error.call_args_list + if call.kwargs.get("reason") + == "inflight_backend_wait_max_notices_reached" + ] + self.assertEqual(len(max_notice_structured_calls), 1) + self.assertEqual( + max_notice_structured_calls[0].kwargs["resource"], + "agent_backend", + ) + + async def test_inflight_backend_wait_zero_max_notices_is_unlimited(self) -> None: + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 0.005, + "inflight_backend_wait_timeout_s": 0.2, + "inflight_backend_wait_max_notices": 0, + "inflight_backend_wait_text": DEFAULT_WAIT_TEXT, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + runtime._timeline = _FakeTimeline() + agent.pipeline = _FakeInflightPushPipeline() + backend_done = asyncio.Event() + attempts: list[int] = [] + + async def _hang_backend() -> None: + await backend_done.wait() + + async def _play_notice(**kwargs): + attempts.append(kwargs["attempt"]) + if len(attempts) == 3: + backend_done.set() + return InflightBackendWaitNoticeResult(started=True) + + executor.run_pipeline_side_effect = _hang_backend + + with mock.patch.object( + runtime, + "_play_inflight_backend_wait_notice_audio", + side_effect=_play_notice, + ): + await runtime.run_pipeline("texto", "texto", user_seq=1) + + self.assertEqual(attempts, [1, 2, 3]) + self.assertFalse( + any( + event == "inflight_backend_wait_max_notices_reached" + for event, _fields in runtime._timeline.events + ) + ) + + async def test_inflight_backend_wait_skipped_notice_does_not_consume_attempt(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + with TemporaryDirectory() as tmp_dir: + base_dir = Path(tmp_dir) + short_dir = base_dir / "short" + short_dir.mkdir() + short_audio = short_dir / "curto.wav" + short_audio.write_bytes(b"short") + + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 0.005, + "inflight_backend_wait_timeout_s": 0.06, + "inflight_backend_wait_max_notices": 1, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_short_audio_dir": str(short_dir), + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + runtime._user_not_speaking.clear() + agent.pipeline = _FakeInflightPushPipeline() + never_finishes = asyncio.Event() + + async def _hang_backend() -> None: + await never_finishes.wait() + + async def _mark_user_not_speaking() -> None: + await asyncio.sleep(0.015) + runtime._user_not_speaking.set() + + def _audio_frames(path: str): + return f"audio:{Path(path).name}" + + executor.run_pipeline_side_effect = _hang_backend + restore_task = asyncio.create_task(_mark_user_not_speaking()) + + try: + with ( + mock.patch("app.livekit.runtime.call_runtime.wav_duration_ms", return_value=100), + mock.patch( + "app.livekit.runtime.call_runtime.wav_audio_frames", + side_effect=_audio_frames, + ), + ): + await runtime.run_pipeline("texto", "texto", user_seq=1) + finally: + restore_task.cancel() + await asyncio.gather(restore_task, return_exceptions=True) + + await self._drain_tasks() + + speech_commands = [ + cmd + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertEqual([cmd.audio for cmd in speech_commands], ["audio:curto.wav"]) + + async def test_inflight_backend_wait_interval_counts_after_audio_playout(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 0.025, + "inflight_backend_wait_timeout_s": 0.04, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + agent.pipeline = _FakeInflightPushPipeline() + executor.wait_for_playout_delay_s = 0.02 + never_finishes = asyncio.Event() + + async def _hang_backend() -> None: + await never_finishes.wait() + + executor.run_pipeline_side_effect = _hang_backend + + await runtime.run_pipeline("texto", "texto", user_seq=1) + await self._drain_tasks() + + speech_texts = [ + cmd.text + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + self.assertEqual(speech_texts, [_expected_wait_text_for_audio(WAIT_LONG_AUDIO_PATH, wait_text)]) + + async def test_short_processing_interruption_rearms_periodic_notice_deadline(self) -> None: + interval_s = 0.05 + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": interval_s, + "inflight_backend_wait_timeout_s": 0.3, + "inflight_backend_wait_max_notices": 2, + "inflight_backend_wait_text": DEFAULT_WAIT_TEXT, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + agent.pipeline = _FakeInflightPushPipeline() + backend_done = asyncio.Event() + attempts: list[int] = [] + attempted_at: list[float] = [] + + async def _hang_backend() -> None: + await backend_done.wait() + + async def _finish_short_speech() -> None: + await asyncio.sleep(0.01) + runtime.note_vad_speech_end(300) + runtime._user_not_speaking.set() + + async def _play_notice(**kwargs): + attempt = kwargs["attempt"] + attempts.append(attempt) + attempted_at.append(time.monotonic()) + if attempts == [1]: + return InflightBackendWaitNoticeResult(started=True) + if attempts == [1, 2]: + runtime._user_not_speaking.clear() + asyncio.create_task(_finish_short_speech()) + return InflightBackendWaitNoticeResult( + started=True, + interrupted_by_user=True, + ) + backend_done.set() + return InflightBackendWaitNoticeResult(started=True) + + executor.run_pipeline_side_effect = _hang_backend + + with mock.patch.object( + runtime, + "_play_inflight_backend_wait_notice_audio", + side_effect=_play_notice, + ): + await runtime.run_pipeline("texto", "texto", user_seq=1) + + self.assertEqual(attempts, [1, 2, 2]) + self.assertGreaterEqual(attempted_at[2] - attempted_at[1], interval_s) + + async def test_short_speech_during_silent_backend_wait_does_not_rearm_notice_deadline(self) -> None: + interval_s = 0.05 + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": interval_s, + "inflight_backend_wait_timeout_s": 0.3, + "inflight_backend_wait_max_notices": 2, + "inflight_backend_wait_text": DEFAULT_WAIT_TEXT, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + agent.pipeline = _FakeInflightPushPipeline() + backend_done = asyncio.Event() + attempts: list[int] = [] + attempted_at: list[float] = [] + short_speech_ended_at = 0.0 + + async def _hang_backend() -> None: + await backend_done.wait() + + async def _emit_short_speech_during_wait() -> None: + nonlocal short_speech_ended_at + await asyncio.sleep(interval_s / 2) + runtime.note_vad_speech_end(300) + short_speech_ended_at = time.monotonic() + + async def _play_notice(**kwargs): + attempts.append(kwargs["attempt"]) + attempted_at.append(time.monotonic()) + if attempts == [1]: + asyncio.create_task(_emit_short_speech_during_wait()) + else: + backend_done.set() + return InflightBackendWaitNoticeResult(started=True) + + executor.run_pipeline_side_effect = _hang_backend + + with mock.patch.object( + runtime, + "_play_inflight_backend_wait_notice_audio", + side_effect=_play_notice, + ): + await runtime.run_pipeline("texto", "texto", user_seq=1) + + self.assertEqual(attempts, [1, 2]) + self.assertGreater(short_speech_ended_at, 0.0) + self.assertLess(attempted_at[1] - short_speech_ended_at, interval_s) + + async def test_valid_processing_interruption_rearms_periodic_notice_deadline(self) -> None: + interval_s = 0.04 + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": interval_s, + "inflight_backend_wait_timeout_s": 0.3, + "inflight_backend_wait_max_notices": 2, + "inflight_backend_wait_text": DEFAULT_WAIT_TEXT, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + agent.pipeline = _FakeInflightPushPipeline() + backend_done = asyncio.Event() + attempts: list[int] = [] + attempted_at: list[float] = [] + + async def _hang_backend() -> None: + await backend_done.wait() + + async def _finish_valid_speech() -> None: + await asyncio.sleep(0.01) + runtime.note_vad_speech_end(1000) + runtime._user_not_speaking.set() + + async def _play_notice(**kwargs): + attempt = kwargs["attempt"] + attempts.append(attempt) + attempted_at.append(time.monotonic()) + if attempts == [1]: + return InflightBackendWaitNoticeResult(started=True) + if attempts == [1, 2]: + runtime._user_not_speaking.clear() + asyncio.create_task(_finish_valid_speech()) + return InflightBackendWaitNoticeResult( + started=True, + interrupted_by_user=True, + ) + backend_done.set() + return InflightBackendWaitNoticeResult(started=True) + + executor.run_pipeline_side_effect = _hang_backend + + with ( + mock.patch.object( + runtime, + "_play_inflight_backend_wait_notice_audio", + side_effect=_play_notice, + ), + mock.patch.object(runtime, "_play_deferred_interruption_comfort"), + ): + await runtime.run_pipeline("texto", "texto", user_seq=1) + + self.assertEqual(attempts, [1, 2, 2]) + self.assertGreaterEqual( + attempted_at[2] - attempted_at[1], + interval_s, + ) + + async def test_long_processing_interruption_keeps_periodic_notices_active(self) -> None: + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 0.01, + "inflight_backend_wait_timeout_s": 0.2, + "inflight_backend_wait_max_notices": 2, + "inflight_backend_wait_text": DEFAULT_WAIT_TEXT, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + runtime.state.deferred_interruption.backend_in_flight = True + agent.pipeline = _FakeInflightPushPipeline() + backend_done = asyncio.Event() + attempts: list[int] = [] + + async def _hang_backend() -> None: + await backend_done.wait() + + async def _interrupt_after_first_notice() -> None: + await asyncio.sleep(0) + runtime.note_vad_speech_end(1500) + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="e nao resolve", is_final=True) + ) + + async def _play_notice(**kwargs): + attempts.append(kwargs["attempt"]) + if attempts == [1]: + asyncio.create_task(_interrupt_after_first_notice()) + else: + backend_done.set() + return InflightBackendWaitNoticeResult(started=True) + + executor.run_pipeline_side_effect = _hang_backend + + with ( + mock.patch.object( + runtime, + "_play_inflight_backend_wait_notice_audio", + side_effect=_play_notice, + ), + mock.patch.object(runtime, "_play_deferred_interruption_comfort"), + ): + await runtime._execute_pipeline_with_inflight_backend_wait( + "pedido original", + user_seq=1, + ) + + self.assertEqual(runtime.state.user_final_seq, 2) + self.assertEqual( + [turn.text for turn in runtime.state.deferred_interruption.long_turns], + ["e nao resolve"], + ) + self.assertEqual(attempts, [1, 2]) + + async def test_long_processing_interruption_does_not_disable_backend_timeout(self) -> None: + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 60.0, + "inflight_backend_wait_timeout_s": 0.03, + "inflight_backend_wait_max_notices": 1, + "inflight_backend_wait_text": DEFAULT_WAIT_TEXT, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + runtime.state.deferred_interruption.backend_in_flight = True + agent.pipeline = _FakeInflightPushPipeline() + never_finishes = asyncio.Event() + + async def _hang_backend() -> None: + await never_finishes.wait() + + async def _play_notice(**_kwargs): + runtime.note_vad_speech_end(1500) + runtime.on_user_input_transcribed( + SimpleNamespace(transcript="continua demorando", is_final=True) + ) + return InflightBackendWaitNoticeResult(started=True) + + executor.run_pipeline_side_effect = _hang_backend + + with ( + mock.patch.object( + runtime, + "_play_inflight_backend_wait_notice_audio", + side_effect=_play_notice, + ), + mock.patch.object(runtime, "_play_deferred_interruption_comfort"), + ): + with self.assertRaises(InflightBackendWaitTimedOut): + await runtime._execute_pipeline_with_inflight_backend_wait( + "pedido original", + user_seq=1, + ) + + self.assertEqual(runtime.state.user_final_seq, 2) + self.assertTrue(runtime.finalized.is_set()) + + async def test_inflight_backend_wait_notice_then_backend_unavailable_stop(self) -> None: + wait_text = "Um momento, ainda estou consultando para te ajudar." + runtime, agent, executor = self._make_runtime( + runtime_config_overrides={ + "inflight_backend_wait_interval_s": 0.01, + "inflight_backend_wait_timeout_s": 0.035, + "inflight_backend_wait_max_notices": 6, + "inflight_backend_wait_text": wait_text, + "inflight_backend_wait_long_audio_path": WAIT_LONG_AUDIO_PATH, + } + ) + runtime._ctx.room.remote_participants = {"user-1": object()} + runtime.state.user_final_seq = 1 + agent.pipeline = _FakeInflightPushPipeline() + never_finishes = asyncio.Event() + + async def _hang_backend() -> None: + await never_finishes.wait() + + executor.run_pipeline_side_effect = _hang_backend + + structured_events = [] + + def _capture_event(*args, **kwargs): + structured_events.append(kwargs) + + with mock.patch("app.livekit.runtime.call_runtime.log_structured_event", side_effect=_capture_event): + await runtime.run_pipeline( + "texto", + "texto", + user_seq=1, + message_id="GED-test-0002", + ) + await self._drain_tasks() + + speech_texts = [ + cmd.text + for cmd in executor.commands + if cmd.__class__.__name__ == "StartSpeech" + ] + stop_commands = [cmd for cmd in executor.commands if isinstance(cmd, NotifyBridgeStop)] + + self.assertGreaterEqual(len(speech_texts), 1) + self.assertTrue( + all( + text == _expected_wait_text_for_audio(WAIT_LONG_AUDIO_PATH, wait_text) + for text in speech_texts + ) + ) + comfort_events = [ + event + for event in structured_events + if str(event.get("message_id") or "").startswith("GED-test-0002_conforto_") + ] + self.assertGreaterEqual(len(comfort_events), 1) + self.assertTrue( + any( + event["tipo_evento"] == "envio msg" + and event.get("erro_msg") == "Falha comunicacao" + and "_erro_terminal_envio_" in event.get("message_id", "") + for event in structured_events + ) + ) + self.assertEqual(len(stop_commands), 1) + self.assertEqual(stop_commands[0].status, "stop_agent_backend_unavailable") + self.assertEqual(stop_commands[0].reason, "resource_unhealthy") + self.assertEqual(stop_commands[0].resource, "agent_backend") + self.assertEqual(stop_commands[0].failed_resources, ("agent_backend",)) + self.assertTrue(runtime.finalized.is_set()) + + async def test_run_starts_session_with_bridge_identity(self) -> None: + runtime, _agent, executor = self._make_runtime() + runtime._ctx.room.remote_participants = {"user-1": object()} + + await runtime.run() + await asyncio.sleep(0) + await self._cancel_pending_tasks() + + start_commands = [cmd for cmd in executor.commands if isinstance(cmd, StartSession)] + self.assertEqual(len(start_commands), 1) + room_options = start_commands[0].room_options + participant_identity = getattr(room_options, "participant_identity", None) + if participant_identity is None: + participant_identity = room_options.kwargs["participant_identity"] + self.assertEqual(participant_identity, "bridge-1") + self.assertIn("user_input_transcribed", runtime._session.handlers) + + +if __name__ == "__main__": + unittest.main() + +#só pra fazer um novo PR diff --git a/tests/livekit/test_vad_flow_logging.py b/tests/livekit/test_vad_flow_logging.py new file mode 100644 index 0000000..44031a5 --- /dev/null +++ b/tests/livekit/test_vad_flow_logging.py @@ -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] diff --git a/tests/livekit/test_wav_audio.py b/tests/livekit/test_wav_audio.py new file mode 100644 index 0000000..93d38fc --- /dev/null +++ b/tests/livekit/test_wav_audio.py @@ -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 diff --git a/tests/providers/__init__.py b/tests/providers/__init__.py new file mode 100644 index 0000000..9d48db4 --- /dev/null +++ b/tests/providers/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/tests/providers/test_fake_stt.py b/tests/providers/test_fake_stt.py new file mode 100644 index 0000000..4ac07ba --- /dev/null +++ b/tests/providers/test_fake_stt.py @@ -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() diff --git a/tests/providers/test_internal_http_stt.py b/tests/providers/test_internal_http_stt.py new file mode 100644 index 0000000..f28972a --- /dev/null +++ b/tests/providers/test_internal_http_stt.py @@ -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() diff --git a/tests/providers/test_tts.py b/tests/providers/test_tts.py new file mode 100644 index 0000000..d88467b --- /dev/null +++ b/tests/providers/test_tts.py @@ -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() diff --git a/tests/services/test_session_context.py b/tests/services/test_session_context.py new file mode 100644 index 0000000..87abb3f --- /dev/null +++ b/tests/services/test_session_context.py @@ -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" diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py new file mode 100644 index 0000000..4dbf6a8 --- /dev/null +++ b/tests/tools/__init__.py @@ -0,0 +1 @@ +"""Tests for operational tooling.""" diff --git a/tests/tools/test_local_stresstest.py b/tests/tools/test_local_stresstest.py new file mode 100644 index 0000000..6418317 --- /dev/null +++ b/tests/tools/test_local_stresstest.py @@ -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(" 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 diff --git a/tests/tools/test_oci_audio_download.py b/tests/tools/test_oci_audio_download.py new file mode 100644 index 0000000..96799b4 --- /dev/null +++ b/tests/tools/test_oci_audio_download.py @@ -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, + ) diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/utils/test_audio_backlog.py b/tests/utils/test_audio_backlog.py new file mode 100644 index 0000000..18230d4 --- /dev/null +++ b/tests/utils/test_audio_backlog.py @@ -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" diff --git a/tests/utils/test_audio_output_tracker.py b/tests/utils/test_audio_output_tracker.py new file mode 100644 index 0000000..bbd49f7 --- /dev/null +++ b/tests/utils/test_audio_output_tracker.py @@ -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"] diff --git a/tests/utils/test_background.py b/tests/utils/test_background.py new file mode 100644 index 0000000..21ce6a7 --- /dev/null +++ b/tests/utils/test_background.py @@ -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(" None: + monkeypatch.setenv("WS_OUTPUT_GAIN", "1.5") + + frame = struct.pack(" None: + monkeypatch.setenv("WS_OUTPUT_GAIN", "1.5") + + frame = struct.pack(" 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(" 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() diff --git a/tests/utils/test_call_timeline_async_writer.py b/tests/utils/test_call_timeline_async_writer.py new file mode 100644 index 0000000..8cc1437 --- /dev/null +++ b/tests/utils/test_call_timeline_async_writer.py @@ -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 diff --git a/tests/utils/test_full_call_recording.py b/tests/utils/test_full_call_recording.py new file mode 100644 index 0000000..d2121d8 --- /dev/null +++ b/tests/utils/test_full_call_recording.py @@ -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(" 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(" 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" diff --git a/tests/utils/test_logging_async_file.py b/tests/utils/test_logging_async_file.py new file mode 100644 index 0000000..cc041d5 --- /dev/null +++ b/tests/utils/test_logging_async_file.py @@ -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 diff --git a/tests/utils/test_logging_pubsub.py b/tests/utils/test_logging_pubsub.py new file mode 100644 index 0000000..8e320b0 --- /dev/null +++ b/tests/utils/test_logging_pubsub.py @@ -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", + } + ) diff --git a/tests/utils/test_structured_otlp.py b/tests/utils/test_structured_otlp.py new file mode 100644 index 0000000..fe863fc --- /dev/null +++ b/tests/utils/test_structured_otlp.py @@ -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 diff --git a/tests/utils/test_stt_audio_upload.py b/tests/utils/test_stt_audio_upload.py new file mode 100644 index 0000000..30ce0b4 --- /dev/null +++ b/tests/utils/test_stt_audio_upload.py @@ -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 + ) diff --git a/tests/utils/test_turn_ids.py b/tests/utils/test_turn_ids.py new file mode 100644 index 0000000..56f6b0e --- /dev/null +++ b/tests/utils/test_turn_ids.py @@ -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) == "" diff --git a/tests/ws_gateway/__init__.py b/tests/ws_gateway/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/ws_gateway/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/ws_gateway/test_audio_flow_logging.py b/tests/ws_gateway/test_audio_flow_logging.py new file mode 100644 index 0000000..70ead6f --- /dev/null +++ b/tests/ws_gateway/test_audio_flow_logging.py @@ -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 diff --git a/tests/ws_gateway/test_audio_input_backlog.py b/tests/ws_gateway/test_audio_input_backlog.py new file mode 100644 index 0000000..3f32460 --- /dev/null +++ b/tests/ws_gateway/test_audio_input_backlog.py @@ -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 diff --git a/tests/ws_gateway/test_fake_remote_agent.py b/tests/ws_gateway/test_fake_remote_agent.py new file mode 100644 index 0000000..7d7d5fa --- /dev/null +++ b/tests/ws_gateway/test_fake_remote_agent.py @@ -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() diff --git a/tests/ws_gateway/test_readiness.py b/tests/ws_gateway/test_readiness.py new file mode 100644 index 0000000..77ec17d --- /dev/null +++ b/tests/ws_gateway/test_readiness.py @@ -0,0 +1,1056 @@ +from __future__ import annotations + +import asyncio +import json +from types import SimpleNamespace +from unittest import mock + +from fastapi.testclient import TestClient + +from app.ws_gateway import main as main_module +from app.ws_gateway.readiness import ( + BridgeReadinessManager, + CapacityReservation, + ReadinessReport, + ResourceCheck, + ServicesHealthReport, + build_bridge_failed_stop_message, + build_capacity_stop_message, + build_completed_stop_message, +) + + +def _build_start_payload() -> dict: + return { + "type": "start", + "data": { + "agent": "conta", + "ani": "5511999999999", + "gsm": "5511999999999", + "session_id": "550e8400-e29b-41d4-a716-446655440200", + "routerCallKeyDay": "20260409", + "routerCallKey": "RCK-001", + "callIdGed": "GED-001", + "agentData": { + "idFatura": "fat-123", + }, + }, + } + + +class _FakeLease: + def __init__(self) -> None: + self.release_calls = 0 + + async def release(self) -> None: + self.release_calls += 1 + + +class _FakeReadinessManager: + def __init__( + self, + *, + reservation: CapacityReservation | None = None, + report: ReadinessReport | None = None, + ) -> None: + self.lease = reservation.lease if reservation is not None else _FakeLease() + self.reservation = reservation or CapacityReservation( + allowed=True, + lease=self.lease, + active_connections=1, + max_connections=5, + ) + self.report = report or ReadinessReport( + checks={ + "agent_runtime": ResourceCheck(status="ok"), + "agent_backend": ResourceCheck(status="ok"), + "stt": ResourceCheck(status="ok"), + "tts": ResourceCheck(status="ok"), + }, + failed_resources=[], + active_connections=1, + max_connections=5, + cached=False, + ) + self.services_report = ServicesHealthReport( + checks={ + "agent_runtime": { + "htp_cod_status": 200, + "hhtp_cod_desc": "SUCCESS", + }, + "agent_backend": { + "contas": { + "htp_cod_status": 200, + "hhtp_cod_desc": "SUCCESS", + }, + "oferta": { + "htp_cod_status": 200, + "hhtp_cod_desc": "SUCCESS", + }, + }, + "stt": { + "sofya": { + "htp_cod_status": 200, + "hhtp_cod_desc": "SUCCESS", + }, + }, + "tts": { + "xAI": { + "htp_cod_status": 200, + "hhtp_cod_desc": "SUCCESS", + }, + }, + }, + healthy=True, + ) + self.evaluate_calls: list[tuple[str, dict]] = [] + self.default_calls = 0 + self.services_calls = 0 + + async def reserve_capacity(self) -> CapacityReservation: + return self.reservation + + async def evaluate_resources(self, *, agent_name: str, call_config: dict | None) -> ReadinessReport: + self.evaluate_calls.append((agent_name, dict(call_config or {}))) + return self.report + + async def evaluate_default_resources(self) -> ReadinessReport: + self.default_calls += 1 + return self.report + + async def evaluate_services(self) -> ServicesHealthReport: + self.services_calls += 1 + return self.services_report + + +class _FakeTimeline: + def __init__(self) -> None: + self.events: list[tuple[str, dict]] = [] + self.path = "/tmp/timeline.jsonl" + + def emit(self, event: str, **fields) -> None: + self.events.append((event, fields)) + + +def test_stop_message_helpers_follow_contract() -> None: + assert build_capacity_stop_message() == { + "type": "stop", + "data": { + "status": "stop_capacity_tia", + "reason": "capacity_exceeded", + "resource": "capacity", + "failed_resources": ["capacity"], + "phase": "pre_ready", + }, + } + assert build_completed_stop_message("finished") == { + "type": "stop", + "data": { + "status": "stop_resolvido_e_finalizado", + "reason": "finished", + "phase": "in_session", + }, + } + assert build_bridge_failed_stop_message() == { + "type": "stop", + "data": { + "status": "stop_bridge_failed", + "reason": "bridge_failed", + "resource": "bridge", + "phase": "in_session", + }, + } + + +def test_build_completed_stop_message_maps_reason_to_configured_status(monkeypatch) -> None: + monkeypatch.setenv("FINAL_STOP_STATUS_RESOLVED", "stop_custom_resolvido") + monkeypatch.setenv("FINAL_STOP_STATUS_UNRESOLVED", "stop_custom_nao_resolvido") + monkeypatch.setenv("FINAL_STOP_STATUS_OTHER_SUBJECT", "stop_custom_outro_assunto") + monkeypatch.setenv("FINAL_STOP_STATUS_LONG_SILENCE", "stop_custom_silencio") + monkeypatch.setenv("FINAL_STOP_REASON_RESOLVED", "resolvido") + monkeypatch.setenv("FINAL_STOP_REASON_UNRESOLVED", "nao_resolvido") + monkeypatch.setenv("FINAL_STOP_REASON_OTHER_SUBJECT", "outro_assunto") + monkeypatch.setenv("FINAL_STOP_REASON_LONG_SILENCE", "silencio_longo") + + assert build_completed_stop_message("nao_resolvido") == { + "type": "stop", + "data": { + "status": "stop_custom_nao_resolvido", + "reason": "nao_resolvido", + "phase": "in_session", + }, + } + assert build_completed_stop_message("outro_assunto") == { + "type": "stop", + "data": { + "status": "stop_custom_outro_assunto", + "reason": "outro_assunto", + "phase": "in_session", + }, + } + assert build_completed_stop_message("silencio_longo") == { + "type": "stop", + "data": { + "status": "stop_custom_silencio", + "reason": "silencio_longo", + "phase": "in_session", + }, + } + assert build_completed_stop_message("resolvido") == { + "type": "stop", + "data": { + "status": "stop_custom_resolvido", + "reason": "resolvido", + "phase": "in_session", + }, + } + + +def test_capacity_reservation_blocks_and_releases(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.setenv("TIA_WS_MAX_CONNECTIONS", "1") + + first = await manager.reserve_capacity() + second = await manager.reserve_capacity() + assert first.allowed is True + assert second.allowed is False + + await first.lease.release() + + third = await manager.reserve_capacity() + assert third.allowed is True + await third.lease.release() + + asyncio.run(_run()) + + +def test_probe_agent_backend_prefers_explicit_health_url(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.setenv("REMOTE_AGENT_HEALTH_URL_CONTA", "https://agent.example/health") + monkeypatch.setenv("AGENT_BACKEND", "remote_ws") + + async def _fake_probe(check_name: str, url: str) -> ResourceCheck: + assert check_name == "agent_backend" + assert url == "https://agent.example/health" + return ResourceCheck(status="ok", url=url) + + with mock.patch.object(manager, "_probe_http_health", side_effect=_fake_probe): + check = await manager.probe_agent_backend(agent_name="conta", call_config={}) + + assert check.status == "ok" + assert check.url == "https://agent.example/health" + + asyncio.run(_run()) + + +def test_probe_agent_backend_derives_health_url_from_websocket(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.delenv("REMOTE_AGENT_HEALTH_URL_CONTA", raising=False) + monkeypatch.delenv("REMOTE_AGENT_HEALTH_URL", raising=False) + monkeypatch.setenv("REMOTE_AGENT_WS_URL", "wss://agent.internal/agent/ws") + monkeypatch.setenv("AGENT_BACKEND", "remote_ws") + + async def _fake_probe(check_name: str, url: str) -> ResourceCheck: + assert check_name == "agent_backend" + assert url == "https://agent.internal/health" + return ResourceCheck(status="ok", url=url) + + with mock.patch.object(manager, "_probe_http_health", side_effect=_fake_probe): + check = await manager.probe_agent_backend(agent_name="conta", call_config={}) + + assert check.status == "ok" + assert check.url == "https://agent.internal/health" + + asyncio.run(_run()) + + +def test_probe_agent_backend_derives_health_url_from_sse(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.delenv("REMOTE_AGENT_HEALTH_URL_CONTA", raising=False) + monkeypatch.delenv("REMOTE_AGENT_HEALTH_URL_CONTAS", raising=False) + monkeypatch.delenv("REMOTE_AGENT_HEALTH_URL", raising=False) + monkeypatch.setenv("REMOTE_AGENT_SSE_URL_CONTA", "http://agent.internal/agent/sse") + monkeypatch.setenv("AGENT_BACKEND", "remote_sse") + + async def _fake_probe(check_name: str, url: str) -> ResourceCheck: + assert check_name == "agent_backend" + assert url == "http://agent.internal/health" + return ResourceCheck(status="ok", url=url) + + with mock.patch.object(manager, "_probe_http_health", side_effect=_fake_probe): + check = await manager.probe_agent_backend(agent_name="conta", call_config={}) + + assert check.status == "ok" + assert check.url == "http://agent.internal/health" + + asyncio.run(_run()) + + +def test_probe_agent_backend_uses_oferta_health_url(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.setenv("REMOTE_AGENT_HEALTH_URL_OFERTA", "https://oferta.example/health") + monkeypatch.setenv("AGENT_BACKEND", "remote_sse") + + async def _fake_probe(check_name: str, url: str) -> ResourceCheck: + assert check_name == "agent_backend" + assert url == "https://oferta.example/health" + return ResourceCheck(status="ok", url=url) + + with mock.patch.object(manager, "_probe_http_health", side_effect=_fake_probe): + check = await manager.probe_agent_backend(agent_name="oferta", call_config={}) + + assert check.status == "ok" + assert check.url == "https://oferta.example/health" + + asyncio.run(_run()) + + +def test_probe_agent_backend_skips_oferta_health_probe_without_explicit_route(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.delenv("REMOTE_AGENT_HEALTH_URL_OFERTA", raising=False) + monkeypatch.delenv("REMOTE_AGENT_HEALTH_URL_OFERTAS", raising=False) + monkeypatch.setenv("REMOTE_AGENT_HEALTH_URL", "http://conta.internal/health") + monkeypatch.setenv("REMOTE_AGENT_SSE_URL_OFERTA", "http://oferta.internal/agent/execute") + monkeypatch.setenv("AGENT_BACKEND", "remote_sse") + + with mock.patch.object(manager, "_probe_http_health") as mocked_probe: + check = await manager.probe_agent_backend(agent_name="ofertas", call_config={}) + + mocked_probe.assert_not_called() + assert check.status == "skipped" + assert check.url == "" + assert "oferta backend health probe disabled" in check.message + + asyncio.run(_run()) + + +def test_probe_stt_derives_health_url_from_stt_url(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.delenv("STT_HEALTH_URL", raising=False) + monkeypatch.setenv("STT_PROVIDER", "internal_http") + monkeypatch.setenv("STT_URL", "http://stt.internal/api/transcriber") + + async def _fake_probe(check_name: str, url: str) -> ResourceCheck: + assert check_name == "stt" + assert url == "http://stt.internal/health" + return ResourceCheck(status="ok", url=url) + + with mock.patch.object(manager, "_probe_http_health", side_effect=_fake_probe): + check = await manager.probe_stt({}) + + assert check.status == "ok" + assert check.url == "http://stt.internal/health" + + asyncio.run(_run()) + + +def test_probe_stt_can_be_temporarily_skipped_by_env(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.setenv("TIA_SKIP_STT_READINESS", "1") + monkeypatch.setenv("STT_PROVIDER", "internal_http") + monkeypatch.setenv("STT_URL", "http://stt.internal/api/transcriber") + + with mock.patch.object(manager, "_probe_http_health") as mocked_probe: + check = await manager.probe_stt({}) + + assert check.status == "skipped" + assert check.message == "STT readiness disabled by env" + mocked_probe.assert_not_called() + + asyncio.run(_run()) + + +def test_probe_http_health_preserves_returned_status_and_description() -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + + class _FakeResponse: + status_code = 503 + reason_phrase = "Service Unavailable" + text = '{"message":"db down"}' + + def json(self) -> dict: + return {"message": "db down"} + + class _FakeAsyncClient: + def __init__(self, *args, **kwargs) -> None: + pass + + async def __aenter__(self) -> "_FakeAsyncClient": + return self + + async def __aexit__(self, *args) -> None: + return None + + async def get(self, url: str, timeout: float): + assert url == "https://service.example/health" + assert timeout > 0 + return _FakeResponse() + + with mock.patch("app.ws_gateway.readiness.httpx.AsyncClient", _FakeAsyncClient): + check = await manager._probe_http_health("agent_backend", "https://service.example/health") + + assert check.status == "fail" + assert check.message == "agent_backend returned status 503: db down" + assert check.http_status_code == 503 + assert check.http_status_desc == "db down" + + asyncio.run(_run()) + + +def test_evaluate_resources_uses_cache(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.setenv("TIA_RESOURCE_HEALTH_TTL_S", "60") + counters = { + "agent_runtime": 0, + "agent_backend": 0, + "stt": 0, + "tts": 0, + } + + async def _ok(name: str) -> ResourceCheck: + counters[name] += 1 + return ResourceCheck(status="ok") + + async def _probe_agent_runtime() -> ResourceCheck: + return await _ok("agent_runtime") + + async def _probe_agent_backend(**_: dict) -> ResourceCheck: + return await _ok("agent_backend") + + async def _probe_stt(*_: dict) -> ResourceCheck: + return await _ok("stt") + + async def _probe_tts(*_: dict) -> ResourceCheck: + return await _ok("tts") + + with ( + mock.patch.object(manager, "probe_agent_runtime", side_effect=_probe_agent_runtime), + mock.patch.object(manager, "probe_agent_backend", side_effect=_probe_agent_backend), + mock.patch.object(manager, "probe_stt", side_effect=_probe_stt), + mock.patch.object(manager, "probe_tts", side_effect=_probe_tts), + ): + first = await manager.evaluate_resources(agent_name="conta", call_config={}) + second = await manager.evaluate_resources(agent_name="conta", call_config={}) + + assert first.cached is False + assert second.cached is True + assert counters == { + "agent_runtime": 1, + "agent_backend": 1, + "stt": 1, + "tts": 1, + } + + asyncio.run(_run()) + + +def test_evaluate_resources_runs_resource_probes_in_parallel(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.setenv("TIA_RESOURCE_HEALTH_TTL_S", "0") + started: set[str] = set() + all_started = asyncio.Event() + + async def _probe(name: str) -> ResourceCheck: + started.add(name) + if len(started) == 4: + all_started.set() + await asyncio.wait_for(all_started.wait(), timeout=0.2) + return ResourceCheck(status="ok") + + async def _probe_agent_runtime() -> ResourceCheck: + return await _probe("agent_runtime") + + async def _probe_agent_backend(**_: dict) -> ResourceCheck: + return await _probe("agent_backend") + + async def _probe_stt(*_: dict) -> ResourceCheck: + return await _probe("stt") + + async def _probe_tts(*_: dict) -> ResourceCheck: + return await _probe("tts") + + with ( + mock.patch.object(manager, "probe_agent_runtime", side_effect=_probe_agent_runtime), + mock.patch.object(manager, "probe_agent_backend", side_effect=_probe_agent_backend), + mock.patch.object(manager, "probe_stt", side_effect=_probe_stt), + mock.patch.object(manager, "probe_tts", side_effect=_probe_tts), + ): + report = await manager.evaluate_resources(agent_name="conta", call_config={}) + + assert report.is_healthy() is True + assert started == {"agent_runtime", "agent_backend", "stt", "tts"} + + asyncio.run(_run()) + + +def test_probe_tts_fake_returns_ok() -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + check = await manager.probe_tts({"tts": {"provider": "fake"}}) + assert check.status == "ok" + + asyncio.run(_run()) + + +def test_probe_tts_elevenlabs_can_be_mocked(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.setenv("ELEVENLABS_API_KEY", "key-123") + monkeypatch.setenv("ELEVENLABS_VOICE_ID", "voice-123") + monkeypatch.setenv("ELEVENLABS_MODEL_ID", "model-123") + + fake_stream = mock.Mock() + fake_stream.collect = mock.AsyncMock(return_value=mock.Mock(data=b"\x00\x01")) + fake_tts = mock.Mock() + fake_tts.synthesize.return_value = fake_stream + fake_tts.aclose = mock.AsyncMock() + + with mock.patch("livekit.plugins.elevenlabs.TTS", return_value=fake_tts) as mocked_tts: + check = await manager.probe_tts({"tts": {"provider": "elevenlabs"}}) + + assert check.status == "ok" + assert mocked_tts.call_args.kwargs["http_session"] is not None + fake_tts.aclose.assert_awaited_once() + + asyncio.run(_run()) + + +def test_probe_tts_xai_can_be_mocked(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.setenv("XAI_API_KEY", "key-123") + + fake_stream = mock.Mock() + fake_stream.collect = mock.AsyncMock(return_value=mock.Mock(data=b"\x00\x01")) + fake_tts = mock.Mock() + fake_tts.synthesize.return_value = fake_stream + fake_tts.aclose = mock.AsyncMock() + + with mock.patch("app.ws_gateway.readiness.OraclexAITTS", return_value=fake_tts) as mocked_tts: + check = await manager.probe_tts( + {"tts": {"provider": "xai", "voiceId": "voice-123", "language": "pt-BR"}} + ) + + assert check.status == "ok" + assert mocked_tts.call_args.kwargs["api_key"] == "key-123" + assert mocked_tts.call_args.kwargs["voice"] == "voice-123" + assert mocked_tts.call_args.kwargs["language"] == "pt-BR" + assert mocked_tts.call_args.kwargs["http_session"] is not None + fake_tts.aclose.assert_awaited_once() + + asyncio.run(_run()) + + +def test_probe_tts_xai_can_only_connect(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.setenv("XAI_API_KEY", "key-123") + monkeypatch.setenv("XAI_TTS_READINESS_MODE", "connect") + + fake_tts = mock.Mock() + fake_tts.connect = mock.AsyncMock() + fake_tts.aclose = mock.AsyncMock() + + with mock.patch("app.ws_gateway.readiness.OraclexAITTS", return_value=fake_tts): + check = await manager.probe_tts({"tts": {"provider": "xai"}}) + + assert check.status == "ok" + assert check.message == "xAI TTS websocket connected" + fake_tts.connect.assert_awaited_once() + fake_tts.synthesize.assert_not_called() + fake_tts.aclose.assert_awaited_once() + + asyncio.run(_run()) + + +def test_probe_http_health_timeout_has_diagnostic_message(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.setenv("TIA_RESOURCE_HEALTH_TIMEOUT_S", "0.1") + + class _FailingAsyncClient: + def __init__(self, *args, **kwargs) -> None: + pass + + async def __aenter__(self) -> "_FailingAsyncClient": + return self + + async def __aexit__(self, *args) -> None: + return None + + async def get(self, url: str, timeout: float): + raise TimeoutError() + + with mock.patch("app.ws_gateway.readiness.httpx.AsyncClient", _FailingAsyncClient): + check = await manager._probe_http_health("stt", "https://service.example/health") + + assert check.status == "fail" + assert check.message == "stt readiness timed out after 100ms (TimeoutError)" + + asyncio.run(_run()) + + +def test_probe_tts_xai_timeout_has_diagnostic_message(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.setenv("XAI_API_KEY", "key-123") + monkeypatch.setenv("TIA_RESOURCE_HEALTH_TIMEOUT_S", "0.1") + + fake_tts = mock.Mock() + fake_tts.connect = mock.AsyncMock(side_effect=TimeoutError()) + fake_tts.aclose = mock.AsyncMock() + + with mock.patch("app.ws_gateway.readiness.OraclexAITTS", return_value=fake_tts): + monkeypatch.setenv("XAI_TTS_READINESS_MODE", "connect") + check = await manager.probe_tts({"tts": {"provider": "xai"}}) + + assert check.status == "fail" + assert check.message == "xai TTS readiness timed out after 100ms (TimeoutError)" + fake_tts.aclose.assert_awaited_once() + + asyncio.run(_run()) + + +def test_probe_tts_xai_iam_does_not_require_legacy_api_key(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.setenv("XAI_TTS_AUTH_METHOD", "INSTANCE_PRINCIPAL") + monkeypatch.setenv("OCI_COMPARTMENT_ID", "ocid1.compartment.oc1..example") + monkeypatch.setenv("XAI_TTS_READINESS_MODE", "connect") + monkeypatch.delenv("XAI_API_KEY", raising=False) + + fake_tts = mock.Mock() + fake_tts.connect = mock.AsyncMock() + fake_tts.aclose = mock.AsyncMock() + + with mock.patch("app.ws_gateway.readiness.OraclexAITTS", return_value=fake_tts) as mocked_tts: + check = await manager.probe_tts({"tts": {"provider": "xai"}}) + + assert check.status == "ok" + assert mocked_tts.call_args.kwargs["auth_method"] == "INSTANCE_PRINCIPAL" + assert "api_key" not in mocked_tts.call_args.kwargs + fake_tts.connect.assert_awaited_once() + fake_tts.aclose.assert_awaited_once() + + asyncio.run(_run()) + + +def test_probe_tts_xai_requires_api_key(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.delenv("XAI_API_KEY", raising=False) + + check = await manager.probe_tts({"tts": {"provider": "xai"}}) + + assert check.status == "fail" + assert check.message == "missing xAI TTS config: XAI_API_KEY" + + asyncio.run(_run()) + + +def test_probe_tts_azure_rest_can_be_mocked(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.setenv("TTS_PROVIDER", "azure") + monkeypatch.setenv("AZURE_TTS_IMPLEMENTATION", "rest") + monkeypatch.setenv("AZURE_SPEECH_KEY", "key-123") + monkeypatch.setenv("AZURE_SPEECH_REGION", "brazilsouth") + monkeypatch.setenv("AZURE_SPEECH_VOICE", "pt-BR-FranciscaNeural") + + with mock.patch("app.ws_gateway.readiness.AzureRESTTTS.synthesize_pcm", return_value=b"\x00\x01"): + check = await manager.probe_tts({"tts": {"provider": "azure"}}) + + assert check.status == "ok" + + asyncio.run(_run()) + + +def test_probe_tts_azure_plugin_can_be_mocked(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.setenv("TTS_PROVIDER", "azure") + monkeypatch.setenv("AZURE_TTS_IMPLEMENTATION", "plugin") + monkeypatch.setenv("AZURE_SPEECH_KEY", "key-123") + monkeypatch.setenv("AZURE_SPEECH_REGION", "brazilsouth") + monkeypatch.setenv("AZURE_SPEECH_VOICE", "pt-BR-FranciscaNeural") + + fake_stream = mock.Mock() + fake_stream.collect = mock.AsyncMock(return_value=mock.Mock(data=b"\x00\x01")) + fake_tts = mock.Mock() + fake_tts.synthesize.return_value = fake_stream + fake_tts.aclose = mock.AsyncMock() + + with mock.patch("livekit.plugins.azure.TTS", return_value=fake_tts) as mocked_tts: + check = await manager.probe_tts({"tts": {"provider": "azure"}}) + + assert check.status == "ok" + assert mocked_tts.call_args.kwargs["http_session"] is not None + fake_tts.aclose.assert_awaited_once() + + asyncio.run(_run()) + + +def test_evaluate_services_returns_requested_shape() -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + backend_calls: list[tuple[str, dict]] = [] + + async def _probe_agent_runtime() -> ResourceCheck: + return ResourceCheck( + status="fail", + message="down", + http_status_code=503, + http_status_desc="Service Unavailable", + ) + + async def _probe_agent_backend(*, agent_name: str) -> ResourceCheck: + backend_calls.append((agent_name, {})) + if agent_name == "conta": + return ResourceCheck( + status="ok", + http_status_code=204, + http_status_desc="No Content", + ) + return ResourceCheck( + status="ok", + http_status_code=200, + http_status_desc="oferta healthy", + ) + + async def _probe_stt() -> ResourceCheck: + return ResourceCheck( + status="ok", + http_status_code=200, + http_status_desc="sofya ready", + ) + + async def _probe_tts() -> ResourceCheck: + return ResourceCheck(status="ok", message="xAI TTS websocket connected") + + with ( + mock.patch.object(manager, "probe_agent_runtime", side_effect=_probe_agent_runtime), + mock.patch.object(manager, "probe_configured_agent_backend_service", side_effect=_probe_agent_backend), + mock.patch.object(manager, "probe_configured_stt_service", side_effect=_probe_stt), + mock.patch.object(manager, "probe_configured_xai_tts_service", side_effect=_probe_tts), + ): + report = await manager.evaluate_services() + + assert report.is_healthy() is False + assert report.as_dict() == { + "status": "fail", + "checks": { + "agent_runtime": { + "htp_cod_status": 503, + "hhtp_cod_desc": "Service Unavailable", + }, + "agent_backend": { + "contas": { + "htp_cod_status": 204, + "hhtp_cod_desc": "No Content", + }, + "oferta": { + "htp_cod_status": 200, + "hhtp_cod_desc": "oferta healthy", + }, + }, + "stt": { + "sofya": { + "htp_cod_status": 200, + "hhtp_cod_desc": "sofya ready", + }, + }, + "tts": { + "xAI": { + "htp_cod_status": 200, + "hhtp_cod_desc": "xAI TTS websocket connected", + }, + }, + }, + } + assert backend_calls == [("conta", {}), ("oferta", {})] + + asyncio.run(_run()) + + +def test_configured_agent_backend_service_uses_real_env_urls_when_backend_is_fake(monkeypatch) -> None: + async def _run() -> None: + manager = BridgeReadinessManager() + monkeypatch.setenv("AGENT_BACKEND", "remote_ws_fake") + monkeypatch.setenv("REMOTE_AGENT_HEALTH_URL_CONTA", "http://contas.example/health") + monkeypatch.delenv("REMOTE_AGENT_HEALTH_URL_OFERTA", raising=False) + monkeypatch.delenv("REMOTE_AGENT_HEALTH_URL_OFERTAS", raising=False) + monkeypatch.setenv("REMOTE_AGENT_SSE_URL_OFERTA", "https://oferta.example/agent/execute") + monkeypatch.setenv("REMOTE_AGENT_SSE_TLS_VERIFY_OFERTA", "0") + monkeypatch.delenv("REMOTE_AGENT_SSE_TLS_VERIFY", raising=False) + calls: list[tuple[str, str, bool]] = [] + + async def _fake_probe(check_name: str, url: str, *, verify: bool = True) -> ResourceCheck: + calls.append((check_name, url, verify)) + return ResourceCheck(status="ok", http_status_code=200, http_status_desc="OK") + + with mock.patch.object(manager, "_probe_http_health", side_effect=_fake_probe): + contas = await manager.probe_configured_agent_backend_service(agent_name="conta") + oferta = await manager.probe_configured_agent_backend_service(agent_name="oferta") + + assert contas.status == "ok" + assert oferta.status == "ok" + assert calls == [ + ("agent_backend", "http://contas.example/health", True), + ("agent_backend", "https://oferta.example/health", False), + ] + + asyncio.run(_run()) + + +def test_health_resources_route_returns_503(monkeypatch) -> None: + report = ReadinessReport( + checks={ + "agent_runtime": ResourceCheck(status="fail", message="down"), + "agent_backend": ResourceCheck(status="ok"), + "stt": ResourceCheck(status="ok"), + "tts": ResourceCheck(status="ok"), + }, + failed_resources=["agent_runtime"], + active_connections=2, + max_connections=5, + cached=False, + ) + fake_manager = _FakeReadinessManager(report=report) + monkeypatch.setattr(main_module, "READINESS_GATE", fake_manager) + + with TestClient(main_module.app) as client: + response = client.get("/health/resources") + + assert response.status_code == 503 + assert response.json()["failed_resources"] == ["agent_runtime"] + assert fake_manager.default_calls == 1 + + +def test_health_services_route_returns_503(monkeypatch) -> None: + services_report = ServicesHealthReport( + checks={ + "agent_runtime": { + "htp_cod_status": 500, + "hhtp_cod_desc": "FAIL", + }, + "agent_backend": { + "contas": { + "htp_cod_status": 200, + "hhtp_cod_desc": "SUCCESS", + }, + "oferta": { + "htp_cod_status": 200, + "hhtp_cod_desc": "SUCCESS", + }, + }, + "stt": { + "sofya": { + "htp_cod_status": 200, + "hhtp_cod_desc": "SUCCESS", + }, + }, + "tts": { + "xAI": { + "htp_cod_status": 200, + "hhtp_cod_desc": "SUCCESS", + }, + }, + }, + healthy=False, + ) + fake_manager = _FakeReadinessManager() + fake_manager.services_report = services_report + monkeypatch.setattr(main_module, "READINESS_GATE", fake_manager) + + with TestClient(main_module.app) as client: + response = client.get("/health/services") + + assert response.status_code == 503 + assert response.json() == services_report.as_dict() + assert fake_manager.services_calls == 1 + + +def test_ws_agent_rejects_on_capacity_with_stop_data(monkeypatch) -> None: + lease = _FakeLease() + fake_manager = _FakeReadinessManager( + reservation=CapacityReservation( + allowed=False, + lease=lease, + active_connections=1, + max_connections=1, + ) + ) + monkeypatch.setattr(main_module, "READINESS_GATE", fake_manager) + monkeypatch.setenv("LIVEKIT_URL", "ws://127.0.0.1:7880") + + with TestClient(main_module.app) as client: + with client.websocket_connect("/ws/agent") as websocket: + websocket.send_text(json.dumps(_build_start_payload())) + payload = websocket.receive_json() + + assert payload == build_capacity_stop_message() + assert fake_manager.evaluate_calls == [] + assert lease.release_calls == 0 + + +def test_ws_agent_rejects_on_readiness_failure_and_releases_capacity(monkeypatch) -> None: + lease = _FakeLease() + report = ReadinessReport( + checks={ + "agent_runtime": ResourceCheck(status="ok"), + "agent_backend": ResourceCheck(status="ok"), + "stt": ResourceCheck(status="fail", message="down"), + "tts": ResourceCheck(status="ok"), + }, + failed_resources=["stt"], + active_connections=1, + max_connections=2, + cached=False, + ) + fake_manager = _FakeReadinessManager( + reservation=CapacityReservation( + allowed=True, + lease=lease, + active_connections=1, + max_connections=2, + ), + report=report, + ) + monkeypatch.setattr(main_module, "READINESS_GATE", fake_manager) + monkeypatch.setenv("LIVEKIT_URL", "ws://127.0.0.1:7880") + + with TestClient(main_module.app) as client: + with client.websocket_connect("/ws/agent") as websocket: + websocket.send_text(json.dumps(_build_start_payload())) + ready_payload = websocket.receive_json() + while True: + message = websocket.receive() + if message.get("text") is not None: + payload = json.loads(message["text"]) + break + assert ready_payload["type"] == "ready" + + assert payload == { + "type": "stop", + "data": { + "status": "stop_stt_unavailable", + "reason": "resource_unhealthy", + "resource": "stt", + "failed_resources": ["stt"], + "phase": "in_session", + }, + } + assert fake_manager.evaluate_calls == [("conta", {})] + assert lease.release_calls == 1 + + +def test_ws_agent_sends_terminal_stop_on_in_session_bridge_failure(monkeypatch) -> None: + lease = _FakeLease() + fake_manager = _FakeReadinessManager( + reservation=CapacityReservation( + allowed=True, + lease=lease, + active_connections=1, + max_connections=2, + ) + ) + timeline = _FakeTimeline() + monkeypatch.setattr(main_module, "READINESS_GATE", fake_manager) + monkeypatch.setenv("LIVEKIT_URL", "ws://127.0.0.1:7880") + + class _FakeRoom: + def __init__(self) -> None: + self.handlers = {} + self.disconnected = 0 + + def on(self, event_name: str): + def _decorator(fn): + self.handlers[event_name] = fn + return fn + + return _decorator + + async def disconnect(self) -> None: + self.disconnected += 1 + + class _FakeAudioSource: + def __init__(self, *args, **kwargs) -> None: + self.args = args + self.kwargs = kwargs + + class _FakeLocalAudioTrack: + @staticmethod + def create_audio_track(name: str, source: object) -> object: + return {"name": name, "source": source} + + class _FakeTrackPublishOptions: + def __init__(self) -> None: + self.source = None + + class _FakeTrackSource: + SOURCE_MICROPHONE = "microphone" + + async def _pending(*args, **kwargs) -> None: + await asyncio.Event().wait() + + async def _raise_dispatch(*args, **kwargs) -> None: + raise RuntimeError("bridge exploded") + + finalize_calls: list[bool] = [] + + class _FakeRecorder: + async def finalize(self) -> None: + finalize_calls.append(True) + + bootstrap = SimpleNamespace( + call_id_ged="GED-001", + call_config={}, + remote_agent_context={"agent": "conta"}, + room_name="dev-room-123", + identity="ws-bridge-dev-001", + token="token-001", + protocol="PRT-001", + phone_number="5511999999999", + timeline=timeline, + dispatch_metadata={}, + ) + + monkeypatch.setattr(main_module, "build_bridge_session_bootstrap", lambda **kwargs: bootstrap) + monkeypatch.setattr( + main_module, + "create_entire_call_recorder_from_env", + lambda **kwargs: _FakeRecorder(), + ) + monkeypatch.setattr(main_module, "dispatch_agent", _raise_dispatch) + monkeypatch.setattr(main_module, "connect_publish_livekit", _pending) + monkeypatch.setattr(main_module, "publish_queue_to_livekit", _pending) + monkeypatch.setattr(main_module, "ws_out_loop", _pending) + monkeypatch.setattr(main_module, "notify_client_audio_enabled", _pending) + monkeypatch.setattr(main_module.rtc, "Room", _FakeRoom) + monkeypatch.setattr(main_module.rtc, "AudioSource", _FakeAudioSource) + monkeypatch.setattr(main_module.rtc, "LocalAudioTrack", _FakeLocalAudioTrack) + monkeypatch.setattr(main_module.rtc, "TrackPublishOptions", _FakeTrackPublishOptions) + monkeypatch.setattr(main_module.rtc, "TrackSource", _FakeTrackSource) + + with TestClient(main_module.app) as client: + with client.websocket_connect("/ws/agent") as websocket: + websocket.send_text(json.dumps(_build_start_payload())) + ready_payload = websocket.receive_json() + stop_payload = websocket.receive_json() + + assert ready_payload["type"] == "ready" + assert stop_payload == build_bridge_failed_stop_message() + assert fake_manager.evaluate_calls == [("conta", {})] + assert lease.release_calls == 1 + assert finalize_calls == [True] diff --git a/tests/ws_gateway/test_session_audio.py b/tests/ws_gateway/test_session_audio.py new file mode 100644 index 0000000..fb27673 --- /dev/null +++ b/tests/ws_gateway/test_session_audio.py @@ -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()) diff --git a/tests/ws_gateway/test_session_bootstrap.py b/tests/ws_gateway/test_session_bootstrap.py new file mode 100644 index 0000000..f6407bd --- /dev/null +++ b/tests/ws_gateway/test_session_bootstrap.py @@ -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" diff --git a/tests/ws_gateway/test_session_lifecycle.py b/tests/ws_gateway/test_session_lifecycle.py new file mode 100644 index 0000000..774e96a --- /dev/null +++ b/tests/ws_gateway/test_session_lifecycle.py @@ -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()) diff --git a/tests/ws_gateway/test_session_start.py b/tests/ws_gateway/test_session_start.py new file mode 100644 index 0000000..252b0f4 --- /dev/null +++ b/tests/ws_gateway/test_session_start.py @@ -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 diff --git a/tests/ws_gateway/test_voice_client.py b/tests/ws_gateway/test_voice_client.py new file mode 100644 index 0000000..85b2fec --- /dev/null +++ b/tests/ws_gateway/test_voice_client.py @@ -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