first commit

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

47
.dockerignore Normal file
View File

@@ -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

182
.env.kube.dev Normal file
View File

@@ -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

179
.env.kube.fqa Normal file
View File

@@ -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

175
.env.kube.prd Normal file
View File

@@ -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

54
.gitignore vendored Normal file
View File

@@ -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/

26
Dockerfile Normal file
View File

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

253
README.md Normal file
View File

@@ -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.

136
azure-pipelines/dev.yml Normal file
View File

@@ -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'

142
azure-pipelines/fqa.yml Normal file
View File

@@ -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'

156
azure-pipelines/prod.yml Normal file
View File

@@ -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'

3
ddl/README.md Normal file
View File

@@ -0,0 +1,3 @@
# Introduction
DDLs para criação de tabelas ou views

150
docker-compose.yml Normal file
View File

@@ -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:

17
docs/README.md Normal file
View File

@@ -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

728
docs/api-overview.md Normal file
View File

@@ -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.

612
docs/refactor-log.md Normal file
View File

@@ -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

116
docs/refactor-plan.md Normal file
View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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/<namespace>/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='<ORD_KEY>' \
--from-literal=OCI_COMPARTMENT_ID='<COMPARTMENT_OCID>'
kubectl -n "$K8S_NAMESPACE" create secret generic xai-iad-credentials \
--from-literal=XAI_API_KEY='<IAD_KEY>' \
--from-literal=OCI_COMPARTMENT_ID='<COMPARTMENT_OCID>'
```
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.

View File

@@ -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/<tia-ord>
```
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`.

193
docs/vad-pause-logs.md Normal file
View File

@@ -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/<room>.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
```

4
k8s/helm_deploy.yaml Normal file
View File

@@ -0,0 +1,4 @@
apiVersion: v2
name: ${APP_NAME}
description: helm para tia
version: ${IMAGE_TAG}

7
k8s/livekit/Dockerfile Normal file
View File

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

View File

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

100
k8s/livekit/deployment.yaml Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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}

10
k8s/regional/pdb.yaml Normal file
View File

@@ -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

View File

@@ -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

17
k8s/regional/service.yaml Normal file
View File

@@ -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

View File

@@ -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: ""

11
k8s/secrets.yaml Normal file
View File

@@ -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}"

31
k8s/tia/Dockerfile Normal file
View File

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

264
k8s/tia/deployment.yaml Normal file
View File

@@ -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

8
livekit.yaml Normal file
View File

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

495
makefile Normal file
View File

@@ -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=<id-da-chamada>"; 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"

16
pytest.ini Normal file
View File

@@ -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

34
requirements.txt Normal file
View File

@@ -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

View File

@@ -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

View File

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

View File

@@ -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

13
sh/run_export.sh Normal file
View File

@@ -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/<sua_senha>@<SEU_ADW_SERVICE> @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

View File

@@ -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()")

View File

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

View File

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

View File

@@ -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",
}

View File

@@ -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

View File

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

View File

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

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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 {}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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", "", "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")

48
src/agent/stage/teste.py Normal file
View File

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

View File

@@ -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,
}

View File

@@ -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}")

254
src/agent/utils/utils.py Normal file
View File

@@ -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

9
src/app/agent_entry.py Normal file
View File

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

32
src/app/bridge_entry.py Normal file
View File

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

View File

@@ -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"))

18
src/app/common/stage.py Normal file
View File

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

81
src/app/common/timed.py Normal file
View File

@@ -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

View File

@@ -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: ...

View File

@@ -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="<i2", count=count).astype(np.float32) / 32768.0
y = self.ceiling * np.tanh((self.gain / self.ceiling) * x)
out = np.clip(np.rint(y * 32768.0), -32768.0, 32767.0).astype("<i2")
# preserva um eventual byte ímpar solto (não deveria ocorrer com PCM16)
return out.tobytes() + pcm[count * 2:]
def tts_output_gain_from_env() -> 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)

View File

@@ -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"<speak version='1.0' "
f"xmlns='http://www.w3.org/2001/10/synthesis' "
f"xmlns:mstts='http://www.w3.org/2001/mstts' "
f"xml:lang={quoteattr(language)}>"
f"<voice name={quoteattr(self._voice)}>{escaped_text}</voice>"
f"</speak>"
).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()

View File

@@ -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_<AGENT>")
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}")

View File

@@ -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,
)

View File

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

View File

@@ -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

View File

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

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,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)

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
Um instante

Binary file not shown.

View File

@@ -0,0 +1 @@
Estou verificando as informações para te ajudar. Só um momentinho.

Binary file not shown.

View File

@@ -0,0 +1 @@
Estou confirmando algumas informações. Um instantinho, por favor.

Binary file not shown.

View File

@@ -0,0 +1 @@
Peço apenas mais um instante e já retorno com você

Binary file not shown.

View File

@@ -0,0 +1 @@
Um momentinho

Binary file not shown.

View File

@@ -0,0 +1 @@
Um instantinho

Binary file not shown.

View File

@@ -0,0 +1 @@
Um momentinho, por favor

Binary file not shown.

View File

@@ -0,0 +1 @@
Um instantinho, por favor.

Binary file not shown.

View File

@@ -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,
},
[],
)

View File

@@ -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,
)

42
src/app/livekit/compat.py Normal file
View File

@@ -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

Some files were not shown because too many files have changed in this diff Show More