first commit
This commit is contained in:
47
.dockerignore
Normal file
47
.dockerignore
Normal 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
182
.env.kube.dev
Normal 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
179
.env.kube.fqa
Normal 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
175
.env.kube.prd
Normal 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
54
.gitignore
vendored
Normal 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
26
Dockerfile
Normal 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
253
README.md
Normal 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
136
azure-pipelines/dev.yml
Normal 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
142
azure-pipelines/fqa.yml
Normal 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
156
azure-pipelines/prod.yml
Normal 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
3
ddl/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Introduction
|
||||||
|
DDLs para criação de tabelas ou views
|
||||||
|
|
||||||
150
docker-compose.yml
Normal file
150
docker-compose.yml
Normal 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
17
docs/README.md
Normal 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
728
docs/api-overview.md
Normal 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
612
docs/refactor-log.md
Normal 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
116
docs/refactor-plan.md
Normal 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
|
||||||
416
docs/regional/ARQUITETURA_TIA_XAI_REGIONAL.md
Normal file
416
docs/regional/ARQUITETURA_TIA_XAI_REGIONAL.md
Normal 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.
|
||||||
32
docs/regional/CHANGELOG_IMPLEMENTACAO.md
Normal file
32
docs/regional/CHANGELOG_IMPLEMENTACAO.md
Normal 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.
|
||||||
385
docs/regional/DEPLOYMENT_TIA_XAI_REGIONAL.md
Normal file
385
docs/regional/DEPLOYMENT_TIA_XAI_REGIONAL.md
Normal 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.
|
||||||
151
docs/regional/TESTES_TIA_XAI_REGIONAL.md
Normal file
151
docs/regional/TESTES_TIA_XAI_REGIONAL.md
Normal 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
193
docs/vad-pause-logs.md
Normal 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
4
k8s/helm_deploy.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: ${APP_NAME}
|
||||||
|
description: helm para tia
|
||||||
|
version: ${IMAGE_TAG}
|
||||||
7
k8s/livekit/Dockerfile
Normal file
7
k8s/livekit/Dockerfile
Normal 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"]
|
||||||
19
k8s/livekit/configmap.yaml
Normal file
19
k8s/livekit/configmap.yaml
Normal 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
100
k8s/livekit/deployment.yaml
Normal 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
|
||||||
211
k8s/regional/deployment-region.yaml
Normal file
211
k8s/regional/deployment-region.yaml
Normal 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
|
||||||
32
k8s/regional/hpa-region.yaml
Normal file
32
k8s/regional/hpa-region.yaml
Normal 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
10
k8s/regional/pdb.yaml
Normal 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
|
||||||
51
k8s/regional/regions.env.example
Normal file
51
k8s/regional/regions.env.example
Normal 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
17
k8s/regional/service.yaml
Normal 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
|
||||||
19
k8s/regional/xai-secret.example.yaml
Normal file
19
k8s/regional/xai-secret.example.yaml
Normal 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
11
k8s/secrets.yaml
Normal 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
31
k8s/tia/Dockerfile
Normal 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
264
k8s/tia/deployment.yaml
Normal 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
8
livekit.yaml
Normal 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
495
makefile
Normal 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
16
pytest.ini
Normal 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
34
requirements.txt
Normal 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
|
||||||
15
scripts/deploy-regional-k8s.sh
Normal file
15
scripts/deploy-regional-k8s.sh
Normal 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
|
||||||
24
scripts/render-regional-k8s.sh
Normal file
24
scripts/render-regional-k8s.sh
Normal 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"
|
||||||
11
scripts/validate-regional-k8s.sh
Normal file
11
scripts/validate-regional-k8s.sh
Normal 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
13
sh/run_export.sh
Normal 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
|
||||||
66
src/agent/base/base_classifier.py
Normal file
66
src/agent/base/base_classifier.py
Normal 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()")
|
||||||
205
src/agent/base/base_stage.py
Normal file
205
src/agent/base/base_stage.py
Normal 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 ""
|
||||||
40
src/agent/classifier/check_guardrail.py
Normal file
40
src/agent/classifier/check_guardrail.py
Normal 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()
|
||||||
80
src/agent/classifier/conversation_classifier.py
Normal file
80
src/agent/classifier/conversation_classifier.py
Normal 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",
|
||||||
|
}
|
||||||
13
src/agent/classifier/conversation_summary.py
Normal file
13
src/agent/classifier/conversation_summary.py
Normal 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
|
||||||
23
src/agent/classifier/interruption_classifier.py
Normal file
23
src/agent/classifier/interruption_classifier.py
Normal 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))
|
||||||
164
src/agent/pipeline/__main__.py
Normal file
164
src/agent/pipeline/__main__.py
Normal 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()
|
||||||
942
src/agent/pipeline/customer_pipeline_langgraph.py
Normal file
942
src/agent/pipeline/customer_pipeline_langgraph.py
Normal 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,
|
||||||
|
}
|
||||||
663
src/agent/pipeline/customer_pipeline_unified.py
Normal file
663
src/agent/pipeline/customer_pipeline_unified.py
Normal 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
|
||||||
31
src/agent/pipeline/pipeline_streaming_text.py
Normal file
31
src/agent/pipeline/pipeline_streaming_text.py
Normal 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 {}
|
||||||
132
src/agent/stage/argumentation.py
Normal file
132
src/agent/stage/argumentation.py
Normal 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
|
||||||
153
src/agent/stage/data_confirmation.py
Normal file
153
src/agent/stage/data_confirmation.py
Normal 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
|
||||||
67
src/agent/stage/formalization.py
Normal file
67
src/agent/stage/formalization.py
Normal 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
|
||||||
91
src/agent/stage/presentation.py
Normal file
91
src/agent/stage/presentation.py
Normal 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
|
||||||
466
src/agent/stage/prompt_manager.py
Normal file
466
src/agent/stage/prompt_manager.py
Normal 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", "tá", "sim", "é ele"/"é ela", "falando", ou disser o próprio nome):
|
||||||
|
- Invoque a tool classify_customer_response com confirmed_target_customer.
|
||||||
|
- Gere uma resposta curta e natural de transição, como: "Que bom! Então..."
|
||||||
|
|
||||||
|
#### NÃO É O CLIENTE ALVO
|
||||||
|
Se o interlocutor indicar que não é o cliente ou que é familiar, terceiro ou que o cliente não está disponível:
|
||||||
|
- Invoque a tool classify_customer_response com denied_target_customer.
|
||||||
|
- Finalize imediatamente a chamada com uma mensagem de despedida adequada.
|
||||||
|
Nunca tente validar novamente nesses casos.
|
||||||
|
|
||||||
|
#### DÚVIDAS, PERGUNTAS OU SITUAÇÕES INTERMEDIÁRIAS
|
||||||
|
- Se perguntarem o motivo da ligação: responda de forma breve "É sobre uma oferta da TIM, mas antes preciso confirmar se estou falando com {customer_first_name}."
|
||||||
|
- Se o cliente demonstrar que não ouviu ou não entendeu: repita a última frase.
|
||||||
|
- Se o cliente pedir para aguardar: responda "Claro, estou aguardando."
|
||||||
|
- Se o cliente falar de assunto fora do contexto 3x sem avanço, finalize a chamada.
|
||||||
|
- Para qualquer outra resposta, tente conduzir naturalmente para a confirmação do nome.
|
||||||
|
|
||||||
|
### FINALIZAÇÃO
|
||||||
|
Finalize a chamada quando:
|
||||||
|
- O interlocutor não é o cliente alvo.
|
||||||
|
- Pedido para ligar em outro momento.
|
||||||
|
- Cliente não está disponível.
|
||||||
|
- Mensagens automáticas ou caixa postal.
|
||||||
|
- 4 tentativas sem confirmação.
|
||||||
|
|
||||||
|
A mensagem final deve conter obrigatoriamente: "TIM agradece sua atenção."
|
||||||
|
|
||||||
|
### EXEMPLOS
|
||||||
|
|
||||||
|
Exemplo 1:
|
||||||
|
Agent: Olá, meu nome é Helena, sou consultora de vendas da Tim. Eu estou falando com jose?
|
||||||
|
User: Não, ele foi para o hospital
|
||||||
|
Agent: ok, irei fechar a conversa por não conseguir contactar o titular. A TIM agradece sua atenção.
|
||||||
|
|
||||||
|
Exemplo 2:
|
||||||
|
Agent: Olá, meu nome é Helena, sou consultora de vendas da Tim. Eu estou falando com brenda?
|
||||||
|
User: O que você quer?
|
||||||
|
Agent: hmm, eu gostaria de apresentar uma oferta mas antes preciso confirmar se falo com a brenda.
|
||||||
|
User: pois não
|
||||||
|
(confirmed_target_customer)
|
||||||
|
Agent: Que bom! Então tenho uma novidade ótima pra você..."""
|
||||||
|
|
||||||
|
# ── Argumentation ──
|
||||||
|
|
||||||
|
def _instructions_argumentation(self) -> str:
|
||||||
|
return """## ETAPA ATUAL: ARGUMENTAÇÃO
|
||||||
|
|
||||||
|
Seu objetivo é convencer o cliente a trocar de plano. A primeira mensagem desta etapa já foi enviada pelo sistema (a apresentação da oferta). Você deve continuar a conversa a partir da resposta do cliente.
|
||||||
|
|
||||||
|
### REGRAS DA ARGUMENTAÇÃO
|
||||||
|
- Sempre tente conduzir o cliente à aceitação da oferta.
|
||||||
|
- Sempre que citar plano ou "servicos_valor_agregado", mantenha aspas exatamente como fornecidas.
|
||||||
|
- Não invente benefícios, características ou condições que não estejam explicitamente informadas.
|
||||||
|
- Quando o cliente perguntar sobre um serviço, ele quer saber se está incluso no preço da oferta.
|
||||||
|
- A oferta apresentada é sempre mais cara que o plano atual, porém com mais benefícios.
|
||||||
|
- Se precisar reapresentar a oferta, varie a fraseologia e o foco dos argumentos para evitar repetição.
|
||||||
|
- Você não pode falar com o cliente em outro momento. Se ele pedir para ligar depois, informe que a oferta só pode ser tratada agora.
|
||||||
|
- Sua função se limita exclusivamente à troca de plano.
|
||||||
|
- Você pode apenas oferecer o plano alvo apresentado, nunca invente outro plano. Não pode oferecer descontos, modificar oferta ou citar opções mais baratas.
|
||||||
|
- Mantenha o tom respeitoso e confiante, sem pressionar com insistência longa.
|
||||||
|
- MÁXIMO de 2 frases por turno.
|
||||||
|
- SEMPRE termine seu turno passando a vez pro cliente com uma pergunta curta.
|
||||||
|
- Adicionar social proof sutil: "Muitos clientes como você já estão aproveitando".
|
||||||
|
|
||||||
|
### ROTEIRO DE ARGUMENTAÇÃO (Turn-taking)
|
||||||
|
O cliente vai responder ao gancho. Seu objetivo é apresentar os detalhes EM PARTES (micro-interações), sempre terminando com uma pergunta de validação curta (ex: "Faz sentido para você?", "Vamos fechar?", "tem interesse?", "Vamos trocar?" NÃO use o mesmo em sequência).
|
||||||
|
|
||||||
|
Ordem de benefícios (obrigatória):
|
||||||
|
1° Apps e redes sociais que não consomem gigas
|
||||||
|
2° Serviços ilimitados (ligações, SMS, WhatsApp)
|
||||||
|
3° Outros benefícios (Deezer, TIM no Avião, roaming, etc.)
|
||||||
|
|
||||||
|
- 1ª rejeição: Perguntar o motivo do cliente não ter interesse.
|
||||||
|
- 2ª rejeição: Contra-argumentar o ponto que ele disse. Se ainda negar, finalize.
|
||||||
|
- 3ª rejeição: Finalizar com "Tudo bem... A TIM agradece sua atenção."
|
||||||
|
|
||||||
|
### CONFIRMAÇÃO DE ACEITE
|
||||||
|
Se o cliente demonstrar interesse ("quero", "pode ser", "topo", "sim"):
|
||||||
|
- Invoque detect_intention_purchase com customer_accepted_purchase.
|
||||||
|
- Pergunte novamente: "vamos fechar?" ou similar.
|
||||||
|
- Se confirmar de novo:
|
||||||
|
- Invoque detect_intention_purchase com customer_accepted_purchase.
|
||||||
|
- Gere uma mensagem de transição natural.
|
||||||
|
- São necessárias exatamente DUAS confirmações.
|
||||||
|
|
||||||
|
### NEGATIVA
|
||||||
|
Se o cliente recusar ("não", "não quero", "caro", "tá bom do jeito que tá"):
|
||||||
|
- Invoque detect_intention_purchase com customer_declined_purchase.
|
||||||
|
|
||||||
|
### OFF-TOPIC
|
||||||
|
- Se o cliente falar coisas aleatórias: off_topic_from_sale.
|
||||||
|
- 3 mensagens off-topic insistindo: finalizar.
|
||||||
|
|
||||||
|
### FINALIZAÇÃO
|
||||||
|
- 3 recusas: finalizar com "Tudo bem... A TIM agradece sua atenção."
|
||||||
|
- 3 off-topics: finalizar com "Tudo bem... A TIM agradece sua atenção."
|
||||||
|
- Mensagens de finalização devem vir sozinhas.
|
||||||
|
|
||||||
|
### EXEMPLO
|
||||||
|
Agente: "hmm Legal. No TIM Black você passa a ter [dados_GB] gigas. É bastante internet pra navegar à vontade. Vamos fechar?"
|
||||||
|
Cliente: "não sei" (other)
|
||||||
|
Agente: "sabe, e tem mais: Redes sociais não descontam dessa franquia, e o valor fica [valor_plano_final]. O que acha?"
|
||||||
|
Cliente: "Não quero" (customer_declined_purchase)
|
||||||
|
Agente: "E tem o Deezer incluso que muitos clientes já aproveitam, o que acha?"
|
||||||
|
Cliente: "Gostei" (customer_accepted_purchase)
|
||||||
|
Agente: "Então, Vamos fechar?"
|
||||||
|
Cliente: "Sim" (customer_accepted_purchase)
|
||||||
|
Agente: "Ótimo! Vamos prosseguir então..."
|
||||||
|
|
||||||
|
### INFORMAÇÕES DO PLANO
|
||||||
|
Plano atual: {actual_plan}
|
||||||
|
Plano alvo: {target_plan}
|
||||||
|
|
||||||
|
### BASE DE CONHECIMENTO
|
||||||
|
{knowledge_base}"""
|
||||||
|
|
||||||
|
# ── Data Confirmation ──
|
||||||
|
|
||||||
|
def _instructions_data_confirmation(self) -> str:
|
||||||
|
return """## ETAPA ATUAL: CONFIRMAÇÃO DE DADOS
|
||||||
|
|
||||||
|
Seu objetivo é verificar a identidade do cliente confirmando nome completo e CPF ou data de nascimento.
|
||||||
|
- Chame apenas uma tool por turno, nunca duas ao mesmo tempo.
|
||||||
|
- Você NUNCA deve inventar um input que não foi dito pelo cliente.
|
||||||
|
- Não chame a tool se não sabe o que enviar ou se não entendeu e pediu para repetir.
|
||||||
|
- "meia" ou "seis" = 6
|
||||||
|
|
||||||
|
### 1. VALIDAÇÃO DE NOME
|
||||||
|
|
||||||
|
Ao receber ###START### SEMPRE diga:
|
||||||
|
"Ótimo! então vamos ativar agora para você já aproveitar! Para prosseguirmos você pode por favor confirmar se seu nome é {customer_full_name}? Se sim, diga eu confirmo"
|
||||||
|
|
||||||
|
#### 1.1 Confirmação
|
||||||
|
Se o cliente dizer "eu confirmo":
|
||||||
|
- Invoque detect_name_target_customer com is_target_customer=true
|
||||||
|
|
||||||
|
#### 1.2 Negação
|
||||||
|
Se o cliente negar:
|
||||||
|
- Invoque detect_name_target_customer com is_target_customer=false e pergunte novamente
|
||||||
|
|
||||||
|
#### 1.3 Confirmação diferente
|
||||||
|
Se não for dito "eu confirmo", pergunte se ele confirma.
|
||||||
|
|
||||||
|
#### 1.4 Esperar
|
||||||
|
Se pedir para esperar, gere resposta educada dizendo que irá aguardar. Quando retornar, volte à confirmação.
|
||||||
|
|
||||||
|
### 2. VALIDAÇÃO DO CPF (APÓS confirmar nome)
|
||||||
|
|
||||||
|
Diga: "Poderia informar os últimos três dígitos do seu CPF?"
|
||||||
|
|
||||||
|
- Se fornecer, chame check_cpf_digits.
|
||||||
|
- NUNCA INVENTE.
|
||||||
|
- Se negar, siga para data de nascimento.
|
||||||
|
- Por mais que seja pedido os últimos três dígitos, certifique de passar todos dígitos que foram ditos. Mas NUNCA peça ele completo.
|
||||||
|
|
||||||
|
Formatação do CPF:
|
||||||
|
- 123.123.123.45 → dígitos = 12312312345
|
||||||
|
- um dois meia oito → dígitos = 1268
|
||||||
|
- meia sete oito dois vinte e quatro → dígitos = 678224
|
||||||
|
- zero vinte e quatro sete sete sete oito quarenta e sete vinte e três → dígitos = 02477784723
|
||||||
|
|
||||||
|
#### 2.1 CPF correto → avançar para conclusão
|
||||||
|
#### 2.2 CPF incorreto → pedir data de nascimento
|
||||||
|
#### 2.3 Esperar → aguardar e retomar
|
||||||
|
|
||||||
|
### 2B. DATA DE NASCIMENTO (se CPF negado ou errado)
|
||||||
|
|
||||||
|
Diga: "Poderia confirmar sua data de nascimento?"
|
||||||
|
|
||||||
|
- Chame check_birth_date com formato YYYY-MM-DD.
|
||||||
|
- Em português, "70" = 1970.
|
||||||
|
|
||||||
|
#### Data correta → avançar
|
||||||
|
#### Data incorreta → pedir novamente
|
||||||
|
|
||||||
|
### 3. CONCLUSÃO
|
||||||
|
Após tudo validado (nome + CPF ou nascimento), retorne uma mensagem vazia "".
|
||||||
|
|
||||||
|
### 4. CANCELAMENTO
|
||||||
|
Se o cliente desistir de comprar:
|
||||||
|
- Invoque purchase_cancellation com end=true.
|
||||||
|
- Diga: "Tudo bem, não vamos prosseguir com a venda. A TIM agradece sua atenção!"
|
||||||
|
|
||||||
|
### REGRAS CRÍTICAS
|
||||||
|
- Todos itens precisam ser validados: 1. Nome 2. CPF ou data de nascimento
|
||||||
|
- NUNCA finalize sem completar a verificação.
|
||||||
|
- NUNCA peça novamente algo já verificado.
|
||||||
|
- APENAS chame tool referente ao que foi dito, não ao passado.
|
||||||
|
|
||||||
|
### EXEMPLO
|
||||||
|
Agent: "Ótimo! Para prosseguirmos, confirme se seu nome é fulano da silva? Se sim, diga eu confirmo"
|
||||||
|
Humano: "eu confirmo"
|
||||||
|
Agent: "Joia, poderia informar os últimos três dígitos do seu cpf?"
|
||||||
|
Humano: "123"
|
||||||
|
Agent: ""
|
||||||
|
|
||||||
|
### INFORMAÇÕES DO CLIENTE ALVO
|
||||||
|
Informações do cliente: {customer}"""
|
||||||
|
|
||||||
|
# ── Formalization ──
|
||||||
|
|
||||||
|
def _instructions_formalization(self) -> str:
|
||||||
|
return """## ETAPA ATUAL: FORMALIZAÇÃO
|
||||||
|
|
||||||
|
Seu objetivo é apresentar as informações do plano e obter a confirmação final do cliente.
|
||||||
|
|
||||||
|
### 1.0 INÍCIO OBRIGATÓRIO
|
||||||
|
|
||||||
|
Ao receber ###START### diga EXATAMENTE (substituindo apenas as variáveis entre colchetes):
|
||||||
|
|
||||||
|
"Pra finalizar eu preciso apresentar uma série de informações sobre o novo plano. Vou começar: {tratamento} está adquirindo uma oferta do plano {plano_target}, que por doze meses terá o custo de {valor_plano_final}. Este é um desconto de {valor_desconto} em relação ao valor total do plano, que é {valor_plano_bruto}. Nesta oferta, {tratamento} tem {dados_GB} gigas para navegar na internet. Além disso, você tem os seguintes benefícios no novo plano: {beneficios_todos} e também recebe uma série de serviços de valor agregado. Você pode conhecê-los no APP meu Tim. A sua data de vencimento e forma de pagamento permanecem os mesmos. Na sua próxima fatura será cobrado o valor integral do plano antigo, mais o proporcional dos dias utilizados do plano adquirido. {tratamento} confirma a migração para o {plano_target} no valor de {valor_plano_final} fidelizado por doze meses?? Se sim, diga eu confirmo."
|
||||||
|
|
||||||
|
### 1.1 Repetir
|
||||||
|
Só repita se o cliente pedir ou for necessário.
|
||||||
|
|
||||||
|
### 2.0 CONFIRMAÇÃO DE VENDA
|
||||||
|
|
||||||
|
#### 2.1 Se o cliente recusar:
|
||||||
|
- Diga: "Tem alguma dúvida que eu possa esclarecer??"
|
||||||
|
- Invoque detect_intention_purchase com customer_declined_purchase.
|
||||||
|
|
||||||
|
#### 2.2 Se o cliente fizer uma pergunta:
|
||||||
|
- Responda brevemente, sem citar todos benefícios de uma vez.
|
||||||
|
- Se possível, termine com: "Se confirma em mudar de plano, diga: Eu confirmo".
|
||||||
|
- Invoque detect_intention_purchase com customer_asked_question.
|
||||||
|
|
||||||
|
##### 2.2.1 Recusa após pergunta:
|
||||||
|
- Diga: "Tudo bem, {customer_first_name}. A Tim agradece sua atenção. Obrigado."
|
||||||
|
- Invoque detect_intention_purchase com customer_declined_purchase.
|
||||||
|
|
||||||
|
#### 2.3 Se a pessoa aceitar ("eu confirmo"):
|
||||||
|
- Diga: "Obrigado, {customer_first_name}! Seu pedido de troca foi iniciado e em até vinte e quatro horas sua migração para o novo plano estará concluída. Entre três a cinco dias {tratamento} receberá uma pesquisa de satisfação. Para Tim cliente satisfeito é nota 9 ou 10. A Tim agradece a sua atenção."
|
||||||
|
- Invoque detect_intention_purchase com customer_accepted_purchase.
|
||||||
|
|
||||||
|
#### 2.4 Se pedir para repetir:
|
||||||
|
- Diga de forma resumida.
|
||||||
|
|
||||||
|
### 3. CANCELAMENTO
|
||||||
|
Se desistir de comprar:
|
||||||
|
- Invoque purchase_cancellation com end=true.
|
||||||
|
- Diga: "Tudo bem, não vamos prosseguir com a venda. A TIM agradece sua atenção!"
|
||||||
|
|
||||||
|
### REGRAS
|
||||||
|
- 2 recusas: finalizar com "Tudo bem. A Tim agradece sua atenção."
|
||||||
|
- Se perguntas fora de contexto: responda e tente terminar com mensagem positiva sugerindo trocar.
|
||||||
|
- Você não pode fornecer descontos, a oferta é fixa.
|
||||||
|
|
||||||
|
### INFORMAÇÕES DO PLANO
|
||||||
|
Informações do cliente: {customer}
|
||||||
|
Plano atual: {actual_plan}
|
||||||
|
Plano alvo: {target_plan}
|
||||||
|
|
||||||
|
### BASE DE CONHECIMENTO
|
||||||
|
{knowledge_base}"""
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
# Bloco 4 — Informações do cliente
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _customer_info(self) -> str:
|
||||||
|
return "## FIM DAS INSTRUÇÕES"
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
# Substituição de variáveis
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _apply_vars(self, prompt: str) -> str:
|
||||||
|
"""Substitui placeholders {key} pelos valores de prompt_vars."""
|
||||||
|
for key, value in self.prompt_vars.items():
|
||||||
|
prompt = prompt.replace("{" + key + "}", str(value))
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
# Carregar knowledge base
|
||||||
|
# ══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@lru_cache
|
||||||
|
def load_knowledge_base() -> str:
|
||||||
|
return resources.files("agent.prompts").joinpath("knowledge_base.txt").read_text(encoding="utf-8")
|
||||||
48
src/agent/stage/teste.py
Normal file
48
src/agent/stage/teste.py
Normal 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)
|
||||||
313
src/agent/stage/unified_agent.py
Normal file
313
src/agent/stage/unified_agent.py
Normal 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,
|
||||||
|
}
|
||||||
107
src/agent/stage/unified_state.py
Normal file
107
src/agent/stage/unified_state.py
Normal 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
254
src/agent/utils/utils.py
Normal 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
9
src/app/agent_entry.py
Normal 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
32
src/app/bridge_entry.py
Normal 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()
|
||||||
265
src/app/common/call_config.py
Normal file
265
src/app/common/call_config.py
Normal 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
18
src/app/common/stage.py
Normal 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
81
src/app/common/timed.py
Normal 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
|
||||||
43
src/app/livekit/adapters/agent_backend.py
Normal file
43
src/app/livekit/adapters/agent_backend.py
Normal 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: ...
|
||||||
104
src/app/livekit/adapters/audio_gain.py
Normal file
104
src/app/livekit/adapters/audio_gain.py
Normal 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)
|
||||||
242
src/app/livekit/adapters/azure_rest_tts.py
Normal file
242
src/app/livekit/adapters/azure_rest_tts.py
Normal 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()
|
||||||
135
src/app/livekit/adapters/backend_factory.py
Normal file
135
src/app/livekit/adapters/backend_factory.py
Normal 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}")
|
||||||
112
src/app/livekit/adapters/bridge_gateway.py
Normal file
112
src/app/livekit/adapters/bridge_gateway.py
Normal 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,
|
||||||
|
)
|
||||||
11
src/app/livekit/adapters/export_service.py
Normal file
11
src/app/livekit/adapters/export_service.py
Normal 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)
|
||||||
428
src/app/livekit/adapters/fake_remote_ws_adapter.py
Normal file
428
src/app/livekit/adapters/fake_remote_ws_adapter.py
Normal 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
|
||||||
65
src/app/livekit/adapters/fake_tts.py
Normal file
65
src/app/livekit/adapters/fake_tts.py
Normal 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()
|
||||||
202
src/app/livekit/adapters/pipeline_adapter.py
Normal file
202
src/app/livekit/adapters/pipeline_adapter.py
Normal 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
|
||||||
1817
src/app/livekit/adapters/remote_agent_sse_adapter.py
Normal file
1817
src/app/livekit/adapters/remote_agent_sse_adapter.py
Normal file
File diff suppressed because it is too large
Load Diff
1577
src/app/livekit/adapters/remote_agent_ws_adapter.py
Normal file
1577
src/app/livekit/adapters/remote_agent_ws_adapter.py
Normal file
File diff suppressed because it is too large
Load Diff
111
src/app/livekit/adapters/speech_service.py
Normal file
111
src/app/livekit/adapters/speech_service.py
Normal 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)
|
||||||
579
src/app/livekit/adapters/xai_pool_proxy.py
Normal file
579
src/app/livekit/adapters/xai_pool_proxy.py
Normal 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()
|
||||||
1519
src/app/livekit/adapters/xai_tts.py
Normal file
1519
src/app/livekit/adapters/xai_tts.py
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src/app/livekit/assets/comfort/fails/tts_fail_recovery.wav
Normal file
BIN
src/app/livekit/assets/comfort/fails/tts_fail_recovery.wav
Normal file
Binary file not shown.
1
src/app/livekit/assets/comfort/interruption/01.txt
Normal file
1
src/app/livekit/assets/comfort/interruption/01.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Um instante
|
||||||
BIN
src/app/livekit/assets/comfort/interruption/01.wav
Normal file
BIN
src/app/livekit/assets/comfort/interruption/01.wav
Normal file
Binary file not shown.
1
src/app/livekit/assets/comfort/long/01.txt
Normal file
1
src/app/livekit/assets/comfort/long/01.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Estou verificando as informações para te ajudar. Só um momentinho.
|
||||||
BIN
src/app/livekit/assets/comfort/long/01.wav
Normal file
BIN
src/app/livekit/assets/comfort/long/01.wav
Normal file
Binary file not shown.
1
src/app/livekit/assets/comfort/long/02.txt
Normal file
1
src/app/livekit/assets/comfort/long/02.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Estou confirmando algumas informações. Um instantinho, por favor.
|
||||||
BIN
src/app/livekit/assets/comfort/long/02.wav
Normal file
BIN
src/app/livekit/assets/comfort/long/02.wav
Normal file
Binary file not shown.
1
src/app/livekit/assets/comfort/long/03.txt
Normal file
1
src/app/livekit/assets/comfort/long/03.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Peço apenas mais um instante e já retorno com você
|
||||||
BIN
src/app/livekit/assets/comfort/long/03.wav
Normal file
BIN
src/app/livekit/assets/comfort/long/03.wav
Normal file
Binary file not shown.
1
src/app/livekit/assets/comfort/short/01.txt
Normal file
1
src/app/livekit/assets/comfort/short/01.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Um momentinho
|
||||||
BIN
src/app/livekit/assets/comfort/short/01.wav
Normal file
BIN
src/app/livekit/assets/comfort/short/01.wav
Normal file
Binary file not shown.
1
src/app/livekit/assets/comfort/short/02.txt
Normal file
1
src/app/livekit/assets/comfort/short/02.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Um instantinho
|
||||||
BIN
src/app/livekit/assets/comfort/short/02.wav
Normal file
BIN
src/app/livekit/assets/comfort/short/02.wav
Normal file
Binary file not shown.
1
src/app/livekit/assets/comfort/short/03.txt
Normal file
1
src/app/livekit/assets/comfort/short/03.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Um momentinho, por favor
|
||||||
BIN
src/app/livekit/assets/comfort/short/03.wav
Normal file
BIN
src/app/livekit/assets/comfort/short/03.wav
Normal file
Binary file not shown.
1
src/app/livekit/assets/comfort/short/04.txt
Normal file
1
src/app/livekit/assets/comfort/short/04.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Um instantinho, por favor.
|
||||||
BIN
src/app/livekit/assets/comfort/short/04.wav
Normal file
BIN
src/app/livekit/assets/comfort/short/04.wav
Normal file
Binary file not shown.
104
src/app/livekit/azure_speech.py
Normal file
104
src/app/livekit/azure_speech.py
Normal 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,
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
10
src/app/livekit/call_config.py
Normal file
10
src/app/livekit/call_config.py
Normal 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
42
src/app/livekit/compat.py
Normal 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
Reference in New Issue
Block a user