first commit

This commit is contained in:
2026-06-16 20:54:49 -03:00
commit 60c2f03eb3
153 changed files with 19704 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

308
.env.example Normal file
View File

@@ -0,0 +1,308 @@
APP_NAME=agent-contas-first-migrated
APP_ENV=local
LOG_LEVEL=DEBUG
API_HOST=0.0.0.0
API_PORT=8000
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# =====================================================================
# Agent Framework OCI - LLM
# Mapeado a partir das variáveis TIM_LLM_* do legado.
# =====================================================================
LLM_PROVIDER=oci
LLM_TEMPERATURE=0.0
LLM_MAX_TOKENS=10000
LLM_TIMEOUT_SECONDS=0
OCI_GENAI_BASE_URL=https://pegruagntaiatenddev.pe.inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com
OCI_GENAI_MODEL=ocid1.generativeaiendpoint.oc1.sa-saopaulo-1.amaaaaaaaehl73aa2amkvyvpsv3ts6rdbx6tzya43zgwx7vqljtjade2vjya
OCI_GENAI_API_KEY=
OCI_CONFIG_FILE=/Users/cristianohoshikawa/Dropbox/ORACLE/TIM/FIRST/config/config
OCI_PROFILE=DEFAULT
OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaa3prjvf7mkvijwn5ng5h6n5ftanbbwqfg6cu44kmffwamhyy267iq
OCI_REGION=sa-saopaulo-1
# Variáveis OCI legadas mantidas para compatibilidade com adapters/scripts.
TIM_LLM_PROVIDER=oci
TIM_LLM_MODEL=120b
TIM_LLM_API_KEY=
TIM_LLM_BASE_URL=
TIM_LLM_API_VERSION=
TIM_LLM_TEMPERATURE=0.0
TIM_LLM_MAX_TOKENS=10000
TIM_LLM_REQUEST_TIMEOUT=0
TIM_LLM_EXTRA={"auth_type":"API_KEY","provider":"meta"}
TIM_LLM_OCI_VARIANT=120b
TIM_LLM_OCI_MODEL_ID_20B=ocid1.generativeaiendpoint.oc1.sa-saopaulo-1.amaaaaaaaehl73aarckk4norgg263as5zleyp36tujnynrssoohshf3nwlhq
TIM_LLM_OCI_MODEL_ID_120B=ocid1.generativeaiendpoint.oc1.sa-saopaulo-1.amaaaaaaaehl73aa2amkvyvpsv3ts6rdbx6tzya43zgwx7vqljtjade2vjya
TIM_LLM_OCI_SERVICE_ENDPOINT=https://pegruagntaiatenddev.pe.inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com
TIM_LLM_OCI_MODEL_KWARGS={"temperature":0.1,"top_p":0.1,"reasoning_effort":"LOW","max_tokens":5000}
TIM_LLM_OCI_MODEL_KWARGS_20B={"temperature":0.0,"top_p":0.1,"reasoning_effort":"LOW","max_tokens":5000}
TIM_LLM_OCI_MODEL_KWARGS_120B={"temperature":0.0,"top_p":0.1,"reasoning_effort":"LOW","max_tokens":5000}
TIM_LLM_OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaa3prjvf7mkvijwn5ng5h6n5ftanbbwqfg6cu44kmffwamhyy267iq
TIM_OCI_USER=ocid1.user.oc1..aaaaaaaa2m2rrqp2m272oa6kfulx3kouaijutu4iik4xhtttg6l76nrp4rza
TIM_OCI_FINGERPRINT=89:b7:68:10:11:ff:60:45:e1:4c:4b:f2:68:3e:e5:3f
TIM_OCI_TENANCY=ocid1.tenancy.oc1..aaaaaaaayzepbi32gafno3hj23o3d6tuquqetuw3yelxb5y7t2ft2wtm2i7q
TIM_OCI_REGION=sa-saopaulo-1
TIM_OCI_KEY_FILE=/Users/cristianohoshikawa/Dropbox/ORACLE/TIM/FIRST/config/sa-agnt-ai-atendimento-contas-dev-2026-03-20T14_02_49.145Z.pem
TIM_LLM_OCI_AUTH_FILE_LOCATION=/Users/cristianohoshikawa/Dropbox/ORACLE/TIM/FIRST/config/config
# =====================================================================
# Repositórios / persistência do framework
# =====================================================================
SESSION_REPOSITORY_PROVIDER=memory
MEMORY_REPOSITORY_PROVIDER=memory
CHECKPOINT_REPOSITORY_PROVIDER=memory
USAGE_REPOSITORY_PROVIDER=memory
TIM_STATE_BACKEND=memory
# Autonomous DB / Oracle Store
ADB_USER=USR_ADB_AGNTATEND_W_DEV
ADB_PASSWORD="T!M#Esta026!"
ADB_DSN="(description=(retry_count=20)(retry_delay=3)(address=(protocol=tcps)(port=1522)(host=10.152.100.72))(connect_data=(service_name=gf9a4a2e79cfeb2_agntatendimentodev_low.adb.oraclecloud.com))(security=(ssl_server_dn_match=no)))"
ADB_WALLET_LOCATION=
ADB_WALLET_PASSWORD=
ADB_TABLE_PREFIX=AGENTFW
TIM_DB_TNS_NAME=agntatendimentodev_low
TIM_DB_CONNECT_STRING="(description= (retry_count=20)(retry_delay=3)(address=(protocol=tcps)(port=1522)(host=cxprbmua.adb.sa-saopaulo-1.oraclecloud.com))(connect_data=(service_name=gf9a4a2e79cfeb2_agntatendimentodev_low.adb.oraclecloud.com))(security=(ssl_server_dn_match=no)))"
TIM_DB_USER=
TIM_DB_PASSWORD=
TIM_DB_WALLET_PASSWORD=
TIM_DB_USE_WALLET=true
# =====================================================================
# RAG / Vector Store do framework
# =====================================================================
VECTOR_STORE_PROVIDER=autonomous
GRAPH_STORE_PROVIDER=memory
RAG_TOP_K=5
EMBEDDING_PROVIDER=oci
OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0
RAG_FILE_GLOBS=*.md,*.txt,*.yaml,*.yml,*.json,*.pdf
TIM_RAG_ENABLED=true
TIM_RAG_OCI_SERVICE_ENDPOINT=https://inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com
TIM_RAG_DB_USER=USR_ADB_AGNTATEND_W_DEV
TIM_RAG_DB_PASSWORD="T!M#Esta026!"
TIM_RAG_DB_DSN="(description=(retry_count=20)(retry_delay=3)(address=(protocol=tcps)(port=1522)(host=10.152.100.72))(connect_data=(service_name=gf9a4a2e79cfeb2_agntatendimentodev_low.adb.oraclecloud.com))(security=(ssl_server_dn_match=no)))"
# =====================================================================
# Langfuse / observabilidade
# =====================================================================
ENABLE_LANGFUSE=true
LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=
LANGFUSE_HOST=http://localhost:3005
ENABLE_OTEL=false
TIM_LLM_GATEWAY_LANGFUSE_PUBLIC_KEY="pk-lf-ec665df6-a762-4c10-9412-7cb7035a7a19"
TIM_LLM_GATEWAY_LANGFUSE_SECRET_KEY="sk-lf-e90bef95-b166-4ee4-8a23-dfc1ac169a17"
TIM_LLM_GATEWAY_LANGFUSE_HOST=http://localhost:3005
TIM_LLM_GATEWAY_LANGFUSE_DEFAULT_LABEL=production
TIM_LLM_GATEWAY_LANGFUSE_CACHE_TTL_SECONDS=60
TIM_LLM_GATEWAY_LANGFUSE_MAX_RETRIES=3
TIM_LLM_GATEWAY_LANGFUSE_FETCH_TIMEOUT_SECONDS=5
TIM_LLM_GATEWAY_LANGFUSE_MASK_SENSITIVE_DATA=true
TIM_AGENT_FRAMEWORK_LOG_EXPORT_ENABLED=false
ENABLE_ANALYTICS=false
ANALYTICS_PROVIDERS=noop
ENABLE_OCI_STREAMING=false
# =====================================================================
# Guardrails / Supervisor / Judges do framework
# =====================================================================
ENABLE_INPUT_GUARDRAILS=true
ENABLE_OUTPUT_GUARDRAILS=true
ENABLE_JUDGES=true
ENABLE_SUPERVISOR=true
ENABLE_OUTPUT_SUPERVISOR=true
ENABLE_PARALLEL_GUARDRAILS=true
GUARDRAILS_FAIL_FAST=true
OUTPUT_SUPERVISOR_MAX_RETRIES=3
GUARDRAILS_CONFIG_PATH=./config/guardrails.yaml
JUDGES_CONFIG_PATH=./config/judges.yaml
PROMPT_POLICY_PATH=./config/prompt_policy.yaml
USE_MOCK_LLM=false
GUARDRAIL_LLM=20b
# =====================================================================
# Channel / Routing / MCP framework
# =====================================================================
DEFAULT_CHANNEL=web
FRAMEWORK_CHANNEL_INPUT_MODE=embedded
ENABLE_VOICE_ADAPTER=true
ENABLE_WHATSAPP_ADAPTER=true
ENABLE_TEXT_ADAPTER=true
ROUTING_CONFIG_PATH=./config/routing.yaml
ENABLE_LLM_ROUTER=false
ROUTING_MODE=router
ENABLE_MCP_TOOLS=true
MCP_SERVERS_CONFIG_PATH=./config/mcp_servers.yaml
TOOLS_CONFIG_PATH=./config/tools.yaml
MCP_PARAMETER_MAPPING_PATH=./config/mcp_parameter_mapping.yaml
MCP_TOOL_TIMEOUT_SECONDS=30
IDENTITY_CONFIG_PATH=./config/identity.yaml
ENABLE_REDIS_CACHE=false
REDIS_URL=redis://localhost:6379/0
ENABLE_CONVERSATION_SUMMARY_MEMORY=true
MEMORY_CONTEXT_STRATEGY=summary
MEMORY_HISTORY_LIMIT=80
MEMORY_RECENT_MESSAGES_LIMIT=8
MEMORY_SUMMARY_TRIGGER_MESSAGES=20
MEMORY_MAX_SUMMARY_CHARS=6000
MEMORY_SUMMARY_USE_LLM=false
MEMORY_INJECT_RECENT_MESSAGES=true
MEMORY_INJECT_SUMMARY=true
# =====================================================================
# MCP legacy TIM/FIRST - integração real por padrão
# =====================================================================
TIM_MCP_USE_MOCK=false
TIM_MCP_TIMEOUT_SECONDS=30
TIM_GATEWAY_RETRY_MAX_RETRIES=3
TIM_GATEWAY_RETRY_BACKOFF_FACTOR=0.5
TIM_GATEWAY_DEFAULT_TIMEOUT=30
TIM_DEFAULT_AUTH="Basic dXJhOnVyYTAwMDAx"
TIM_CLIENT_ID=AIAGENTCR
TIM_USER_ID=AIAGENTCR
TIM_DEFAULT_CLIENT_ID=CHAT
TIM_DEFAULT_CSP_ID=740
TIM_DEFAULT_CHANNEL=APP
TIM_DEFAULT_CHANNEL_LEGACY=APP
TIM_AUTHORIZATION_OAM=
TIM_CN_FIELD=
TIM_TYPE_FIELD=
# Consulta VAS
TIM_QUERY_VAS_URL=http://10.151.3.100:8000/access/v1/productsSingleVas
TIM_QUERY_VAS_AUTH="Basic dXJhOnVyYTAwMDAx"
TIM_QUERY_VAS_TIMEOUT=30
TIM_URL_CONSULTA_VAS=http://10.151.3.100:8000/access/v1/productsSingleVas
TIM_CONSULTA_AUTH="Basic dXJhOnVyYTAwMDAx"
TIM_CONSULTA_TIMEOUT=30
# Bloqueio VAS
TIM_BLOCK_VAS_URL=http://10.151.3.100:8000/customers/v1/createPartialBlocking
TIM_BLOCK_VAS_AUTH="Basic YmZmZGlnaXRhbDpiZmZkaWdpdGFsMDE="
TIM_BLOCK_VAS_TIMEOUT=30
TIM_BLOCK_VAS_CLIENT_ID=AIAGENTCR
TIM_BLOCK_VAS_OPERATION_TYPE=block
TIM_BLOCK_VAS_PAYLOAD_MODE=auto
TIM_BLOCK_VAS_ACCEPT_ENCODING=gzip,deflate
TIM_URL_BLOQUEIO_VAS=http://10.151.3.100:8000/customers/v1/createPartialBlocking
TIM_BLOQUEIO_AUTH="Basic YmZmZGlnaXRhbDpiZmZkaWdpdGFsMDE="
TIM_BLOQUEIO_TIMEOUT=30
TIM_BLOQUEIO_OPERATION_TYPE=block
TIM_BLOQUEIO_ACCEPT_ENCODING=gzip,deflate
# Cancelamento VAS
TIM_CANCEL_VAS_URL=http://10.151.3.100:8000/access/v1/singleVas
TIM_CANCEL_VAS_AUTH="Basic dXJhOnVyYTAwMDAx"
TIM_CANCEL_VAS_TIMEOUT=30
TIM_CANCEL_VAS_CLIENT_ID=AIAGENTCR
TIM_CANCELAMENTO_URL=http://10.151.3.100:8000/access/v1/singleVas
TIM_CANCELAMENTO_AUTH="Basic dXJhOnVyYTAwMDAx"
TIM_CANCELAMENTO_TIMEOUT=30
# Contrato
TIM_CONTRATO_URL=http://10.151.3.100:8000/access/v1/contractInformation
TIM_CONTRATO_AUTH="Basic dXJhOnVyYTAwMDAx"
TIM_CONTRATO_TIMEOUT=30
# Complete invoices / fatura completa
TIM_COMPLETE_INVOICES_URL=http://10.151.3.100:8000/customers/v1/completeInvoices
TIM_COMPLETE_INVOICES_AUTH="Basic dXJhOnVyYTAwMDAx"
TIM_COMPLETE_INVOICES_TIMEOUT=30
TIM_COMPLETE_INVOICES_CLIENT_ID=AIAGENTCR
# Perfil de fatura
TIM_PROFILE_BILL_URL=http://10.151.3.100:8000/customers/v1/completeInvoices
TIM_PROFILE_BILL_AUTH="Basic dXJhOnVyYTAwMDAx"
TIM_PROFILE_BILL_TIMEOUT=30
TIM_URL_PERFIL_FATURA=http://10.151.3.100:8000/customers/v1/completeInvoices
TIM_PROFILE_FULL_URL=http://server:port/v1/r-profile-full
# Divergência / billing analysis
TIM_DIVERGENCIA_URL=http://10.151.3.100:8000/customers/v1/billingAnalysis
TIM_DIVERGENCIA_AUTH="Basic dXJhOnVyYTAwMDAx"
TIM_INVOICE_EXPLANATION_URL=http://10.151.3.100:8000/customers/v1/billingAnalysis
TIM_INVOICE_EXPLANATION_AUTH="Basic dXJhOnVyYTAwMDAx"
# Bill/PDF / invoice recover
TIM_BILL_PDF_URL=http://10.151.3.100:8000/invoices/v1/invoiceRecover
TIM_BILL_PDF_AUTH="Basic YmZmZGlnaXRhbDpiZmZkaWdpdGFsMDE="
TIM_BILL_PDF_TIMEOUT=30
TIM_BILL_PDF_CLIENT_ID=TIMX
TIM_URL_INVOICE_RECOVER=http://10.151.3.100:8000/invoices/v1/invoiceRecover
TIM_INVOICE_RECOVER_AUTH="Basic YmZmZGlnaXRhbDpiZmZkaWdpdGFsMDE="
TIM_INVOICE_RECOVER_TIMEOUT=30
TIM_INVOICE_RECOVER_CLIENT_ID=TIMX
# Protocolo / backOfficeSRopening
TIM_PROTOCOL_URL=http://10.151.3.100:8000/customers/v1/backOfficeSRopening
TIM_PROTOCOL_AUTH="Basic YmZmZGlnaXRhbDpiZmZkaWdpdGFsMDE="
TIM_PROTOCOL_TIMEOUT=30
# Contestação/SR
TIM_CUSTOMER_CONTESTATION_URL=http://10.151.3.100:8000/interactions/v1/customerContestation
TIM_CUSTOMER_CONTESTATION_AUTH="Basic dXJhOnVyYTAwMDAx"
TIM_CUSTOMER_CONTESTATION_TIMEOUT=30
TIM_CUSTOMER_CONTESTATION_CLIENT_ID=AIAGENTCR
TIM_CUSTOMER_CONTESTATION_USER_ID=aiagentcr
# Status SR
TIM_SERVICE_REQUEST_STATUS_URL=http://10.151.3.100:8000/interactions/v1/statusServiceRequest
TIM_SERVICE_REQUEST_STATUS_AUTH="Basic dXJhOnVyYTAwMDAx"
TIM_SERVICE_REQUEST_STATUS_TIMEOUT=30
# SMS
TIM_SMS_URL=http://10.151.3.100:8000/customers/v1/smsSend
TIM_SMS_AUTH="Bearer eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCIsIng1dCI6IjVubEFuRWxwaFlNZ3R4Nm1JbFRMN1VuX2x2RSIsImtpZCI6Im9yYWtleSJ9.eyJzdWIiOiJlY29tbWVyY2VjYyIsImlzcyI6Im9yYWtleTJfb2FtdmFzcWEiLCJvcmFjbGUub2F1dGguc3ZjX3BfbiI6Ik9BdXRoU2VydmljZVByb2ZpbGUiLCJpYXQiOjE3NzQ2MjU3NjIsIm9yYWNsZS5vYXV0aC5wcm4uaWRfdHlwZSI6IkNsaWVudElEIiwiZXhwIjoxNzc0NjQwMTYyLCJvcmFjbGUub2F1dGgudGtfY29udGV4dCI6InJlc291cmNlX2FjY2Vzc190ayIsInBybiI6ImVjb21tZXJjZWNjIiwianRpIjoiZGJkNmUwOTgtOThmYi00YmFjLTkyOGItOTRkZmIwYTIwYWI3Iiwib3JhY2xlLm9hdXRoLmNsaWVudF9vcmlnaW5faWQiOiJlY29tbWVyY2VjYyIsIm9yYWNsZS5vYXV0aC5zY29wZSI6ImVjb21tZXJjZS5jdXN0b21lciIsInVzZXIudGVuYW50Lm5hbWUiOiJEZWZhdWx0RG9tYWluIiwib3JhY2xlLm9hdXRoLmlkX2RfaWQiOiIxMjM0NTY3OC0xMjM0LTEyMzQtMTIzNC0xMjM0NTY3ODkwMTIifQ.fJ_zeOegG9UcYFz87NqSf3E-pnV2i_GLiJ19BumC6cyiGJunjBNTa0CxpAx0YTJznwV9l7kcBVh2rmmVrm4JH4db3Q0N_sR9l0qaKfwjFgzjUcwI7dVSDzzCCZHuYfqg1rOAUghQOPk02Ht2kxT5jaJtCYxRnK9FSnupPyzPXHI"
TIM_SMS_TIMEOUT=30
TIM_SMS_CLIENT_ID=Ecommercecc
TIM_SMS_SENDER_ADDRESS=TIM
TIM_SMS_SENDER_NAME=TIM
TIM_SMS_TOKEN_URL=
TIM_SMS_TOKEN_CLIENT_ID=
TIM_SMS_TOKEN_CLIENT_SECRET=
TIM_SMS_TOKEN_SCOPE=
TIM_SMS_TOKEN_AUDIENCE=
TIM_SMS_TOKEN_TIMEOUT=
SMS_BARCODE_AUTH="Bearer eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCIsIng1dCI6IjVubEFuRWxwaFlNZ3R4Nm1JbFRMN1VuX2x2RSIsImtpZCI6Im9yYWtleSJ9.eyJzdWIiOiJlY29tbWVyY2VjYyIsImlzcyI6Im9yYWtleTJfb2FtdmFzcWEiLCJvcmFjbGUub2F1dGguc3ZjX3BfbiI6Ik9BdXRoU2VydmljZVByb2ZpbGUiLCJpYXQiOjE3NzQ2MjU3NjIsIm9yYWNsZS5vYXV0aC5wcm4uaWRfdHlwZSI6IkNsaWVudElEIiwiZXhwIjoxNzc0NjQwMTYyLCJvcmFjbGUub2F1dGgudGtfY29udGV4dCI6InJlc291cmNlX2FjY2Vzc190ayIsInBybiI6ImVjb21tZXJjZWNjIiwianRpIjoiZGJkNmUwOTgtOThmYi00YmFjLTkyOGItOTRkZmIwYTIwYWI3Iiwib3JhY2xlLm9hdXRoLmNsaWVudF9vcmlnaW5faWQiOiJlY29tbWVyY2VjYyIsIm9yYWNsZS5vYXV0aC5zY29wZSI6ImVjb21tZXJjZS5jdXN0b21lciIsInVzZXIudGVuYW50Lm5hbWUiOiJEZWZhdWx0RG9tYWluIiwib3JhY2xlLm9hdXRoLmlkX2RfaWQiOiIxMjM0NTY3OC0xMjM0LTEyMzQtMTIzNC0xMjM0NTY3ODkwMTIifQ.fJ_zeOegG9UcYFz87NqSf3E-pnV2i_GLiJ19BumC6cyiGJunjBNTa0CxpAx0YTJznwV9l7kcBVh2rmmVrm4JH4db3Q0N_sR9l0qaKfwjFgzjUcwI7dVSDzzCCZHuYfqg1rOAUghQOPk02Ht2kxT5jaJtCYxRnK9FSnupPyzPXHI"
# Tracking activities
TIM_TRACKING_ACTIVITIES_URL=http://10.151.3.100:8000/customers/v1/trackingActivities
TIM_TRACKING_ACTIVITIES_AUTH="Basic dXJhOnVyYTAwMDAx"
TIM_TRACKING_ACTIVITIES_TIMEOUT=30
TIM_TRACKING_ACTIVITIES_USER_LOGIN=
TIM_TRACKING_ACTIVITIES_CHANNEL=
TIM_TRACKING_ACTIVITIES_CLIENT_ID=
# Legacy app / workflows
TIM_WORKFLOWS_DIR=workflows
TIM_WORKFLOW_POSTGRES_DSN=
TIM_APP_HOST=0.0.0.0
TIM_APP_PORT=8000
TIM_APP_RELOAD=false
TIM_APP_LOG_LEVEL=DEBUG
# LLM Gateway legado
TIM_LLM_GATEWAY_SOURCES=txt
TIM_LLM_GATEWAY_LOCAL_DIR=prompts
TIM_LLM_GATEWAY_CAPABILITIES_DIR=
TIM_LLM_GATEWAY_LANGGRAPH_URL=
TIM_LLM_GATEWAY_LANGGRAPH_TIMEOUT=5
TIM_LLM_GATEWAY_FALLBACK_PROMPT_ID=default
TIM_LLM_GATEWAY_FALLBACK_PROMPT_CONTENT="Você é um agente para operações VAS TIM. Use tools quando precisar executar ações no backend."
# Pub/Sub / export legado
GCP_PROJECT_ID=tim-bigdata-dev-ca1f
AGENT_PUBSUB_TOPIC=pbs-ingest-agnt-ai-contas-curadoria
TIM_PUBSUB_DIRECT_ENABLED=true
GOOGLE_APPLICATION_CREDENTIALS=GCP_ACESS_KEY.json
# Mock legado explicitamente desligado
TIM_MOCK_LATENCY_MS=0
TIM_MOCK_FAILURES=
TIM_MOCK_FIXTURES_DIR=

10
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Environment-dependent path to Maven home directory
/mavenHomeManager.xml
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

9
.idea/first_contas.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_24" default="true" project-jdk-name="24" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/first_contas.iml" filepath="$PROJECT_DIR$/.idea/first_contas.iml" />
</modules>
</component>
</project>

7
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View File

@@ -0,0 +1,24 @@
# Sample guideline, please follow similar structure for guideline with code samples
# 1. Suggest using streams instead of simple loops for better readability.
# <example>
# *Comment:
# Category: Minor
# Issue: Use streams instead of a loop for better readability.
# Code Block:
#
# ```java
# // Calculate squares of numbers
# List<Integer> squares = new ArrayList<>();
# for (int number : numbers) {
# squares.add(number * number);
# }
# ```
# Recommendation:
#
# ```java
# // Calculate squares of numbers
# List<Integer> squares = Arrays.stream(numbers)
# .map(n -> n * n) // Map each number to its square
# .toList();
# ```
# </example>

6
Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
COPY agent_framework /agent_framework
COPY agent_template_backend /app
RUN pip install --no-cache-dir -e /agent_framework -r requirements.txt
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

831
README.md Normal file
View File

@@ -0,0 +1,831 @@
# Agent Contas FIRST migrado para Agent Framework OCI
Este projeto é uma versão executável do **Agent Contas FIRST** migrada para o padrão do **Agent Framework OCI**. Ele implementa um agente de atendimento para dúvidas e operações de contas/faturas de telecom, mantendo a lógica específica do domínio de contas em uma camada fina e delegando workflow, roteamento, guardrails, judges, memória, RAG, MCP, cache e observabilidade para o framework corporativo.
> Observação de segurança: o arquivo `.env.example` do pacote contém exemplos de valores sensíveis. Este README explica os parâmetros, mas não reproduz senhas, tokens, chaves privadas nem secrets. Para uso real, mantenha esses valores fora do Git e injete por vault, secret manager, variável de ambiente segura ou arquivo `.env` local não versionado.
## O que é este projeto
O pacote contém quatro partes principais:
| Parte | Caminho | Função |
|---|---|---|
| Backend do agente | `app/` | API FastAPI que expõe `/gateway/message`, normaliza canal, resolve identidade, executa o workflow LangGraph e chama o Agent Contas. |
| Configurações do framework | `config/` e `llm_profiles.yaml` | YAMLs de agente, roteamento, guardrails, judges, identity, MCP servers, tools e mapeamento de parâmetros. |
| MCP Server legado TIM/FIRST | `mcp_servers/legacy_tim_mcp/` | Adaptador HTTP MCP que expõe tools no contrato esperado pelo framework e chama APIs TIM/FIRST ou mock. |
| Mock de serviços externos | `backend_mock/` | Servidor FastAPI que simula endpoints TIM/FIRST como fatura, contrato, VAS, PDF, protocolo, contestação, status, SMS e tracking. |
O projeto também mantém `legacy_reference/` apenas como referência de rastreabilidade do legado. A decisão de migração foi não portar a camada própria de workflow/guardrails/judges/RAG do legado, evitando duplicidade com o Agent Framework OCI.
## O que é o Agente de Contas
O **Agente de Contas** é um agente especializado no domínio `telecom_contas`. Ele atende perguntas e fluxos relacionados a fatura, pagamento, segunda via, contestação, plano, serviços adicionais, protocolos e SMS. No código, a lógica específica fica principalmente em:
- `app/agents/contas_agent.py`: comportamento de domínio, prompt do agente, seleção declarativa de tools por intent e montagem de contexto com RAG/MCP.
- `config/routing.yaml`: intents suportadas e tools relacionadas.
- `config/tools.yaml`: catálogo das tools MCP disponíveis.
- `config/mcp_parameter_mapping.yaml`: mapeamento das chaves canônicas de negócio para argumentos reais das APIs TIM/FIRST.
- `config/identity.yaml`: resolução de identidade e aliases, como `msisdn`, `invoice_id`, `asset_id`, `ura_call_id` e `session_id`.
## Funcionalidades prometidas pelo agente
### Atendimento de faturas e contas
- Explicar fatura, valor, vencimento, status e itens cobrados.
- Consultar fatura completa via `consultar_fatura`.
- Consultar pagamentos e baixa via `consultar_pagamentos`.
- Ajudar em dúvidas sobre cobrança proporcional, divergência de valor e histórico de cobrança.
### Segunda via e PDF
- Recuperar dados da fatura atual.
- Recuperar PDF ou segunda via quando disponível via `recuperar_pdf_fatura`.
- Preparar envio de informações de boleto/código por canal, quando a política permitir.
### Contestação e protocolo
- Identificar intenção de contestar cobrança.
- Consultar evidências antes de orientar o cliente.
- Abrir contestação via `abrir_contestacao`, marcada como ação sensível e com confirmação requerida.
- Registrar protocolo via `registrar_protocolo`.
- Consultar status de protocolo/SR via `consultar_status_sr`.
- Atualizar status de SR via `atualizar_status_sr`, também como ação sensível.
### Plano, serviços e VAS
- Consultar plano/contrato ativo via `consultar_plano`.
- Listar serviços ativos, VAS/SVA, bundles e benefícios via `listar_servicos`.
- Orientar cancelamento ou bloqueio de VAS.
- Executar `cancelar_vas` e `bloquear_vas` apenas como ações com confirmação explícita.
### SMS e tracking operacional
- Enviar SMS via `enviar_sms`, com confirmação.
- Registrar tracking/activity via `registrar_tracking`.
### Segurança, qualidade e observabilidade
- Guardrails de entrada e saída configuráveis por YAML.
- Judges de qualidade, groundedness, sentimento e tom.
- Output Supervisor com retry.
- Eventos IC/NOC/GRL e telemetria Langfuse/OTEL.
- Cache MCP para consultas.
- Memória conversacional e summary memory.
## Como ele funciona com o Agent Framework OCI
A arquitetura de execução é:
```text
Frontend / curl / canal
FastAPI backend: POST /gateway/message
ChannelGateway do Agent Framework OCI
IdentityResolver + BusinessContext
SessionRepository + Memory + Summary Memory
Workflow LangGraph do template
Input Guardrails
Enterprise Router / routing.yaml
ContasAgent / AgentRuntimeMixin
RAG nativo + MCPToolRouter nativo
legacy_tim_mcp
APIs TIM/FIRST reais ou backend_mock
LLM + Output Supervisor + Output Guardrails + Judges
Resposta final + telemetria + persistência
```
O backend inicializa os componentes nativos em `app/main.py`:
- `ChannelGateway` para normalizar mensagens de web/voz/WhatsApp/texto.
- `IdentityResolver` para transformar `msisdn`, `invoice_id`, `asset_id`, `ura_call_id` etc. em `BusinessContext` canônico.
- `AgentProfileRegistry` para carregar o agente `contas_first`.
- `create_llm`, `create_memory`, `create_conversation_summary_memory`, `create_session_repository`, `create_checkpoint_repository`, `create_cache` e `create_usage_repository`.
- `create_mcp_tool_router` para carregar `config/mcp_servers.yaml`, `config/tools.yaml` e `config/mcp_parameter_mapping.yaml`.
- `AgentWorkflow` para executar o pipeline LangGraph corporativo.
## Integração com o framework
O projeto usa o Agent Framework OCI nos seguintes pontos:
| Recurso do framework | Como é usado no projeto |
|---|---|
| LangGraph workflow | `app/workflows/agent_graph.py` orquestra o pipeline corporativo. |
| AgentRuntimeMixin | `ContasAgent` herda de `AgentRuntimeMixin` para usar RAG, MCP, memória, cache e montagem de prompt. |
| Enterprise Router | `config/routing.yaml` define intents, prioridades, keywords, exemplos e tools por intent. |
| Guardrails | `config/guardrails.yaml` ativa MSK/VLOOP na entrada e REVPREC/PINJ/DLEX_OUT na saída. |
| Judges | `config/judges.yaml` define `response_quality`, `groundedness`, `sentiment` e `tone`. |
| Output Supervisor | Controlado por `ENABLE_OUTPUT_SUPERVISOR` e `OUTPUT_SUPERVISOR_MAX_RETRIES`. |
| IdentityResolver | `config/identity.yaml` converte aliases de canal/legado em chaves canônicas. |
| BusinessContext | Chaves `customer_key`, `contract_key`, `interaction_key`, `account_key`, `resource_key`, `session_key` acompanham todo o fluxo. |
| MCPToolRouter | Executa tools declaradas em `config/tools.yaml` no servidor `legacy_tim`. |
| MCP Parameter Mapping | `config/mcp_parameter_mapping.yaml` converte chaves canônicas em argumentos como `msisdn`, `invoice_id`, `customer_id`, `asset_id`. |
| Cache MCP | Queries têm cache configurado por tool em `config/tools.yaml`. |
| RAG | Usa o RAG nativo do framework, com namespace `contas_first` na metadata do agente. |
| Memória | Usa histórico e summary memory do framework. |
| Observabilidade | Usa Telemetry, Langfuse, IC/NOC/GRL e analytics publisher. |
| SSE | `SSEHub` permite eventos de fluxo quando o endpoint SSE estiver em uso. |
## Customizações específicas do Contas
As customizações de domínio são intencionais e concentradas:
1. **Intents de contas** em `config/routing.yaml`:
- `billing_invoice_explanation`
- `billing_payment_status`
- `billing_second_copy`
- `billing_contestation`
- `service_plan_information`
- `vas_services_information`
- `generic_billing_rag`
- `billing_protocol_status`
- `billing_protocol_register`
- `vas_cancel_or_block`
- `send_billing_sms`
- `tracking_activity`
2. **Tools de telecom** em `config/tools.yaml`:
- consultas com cache: `consultar_fatura`, `consultar_pagamentos`, `consultar_plano`, `listar_servicos`, `recuperar_pdf_fatura`, `consultar_status_sr`;
- ações sem cache e com confirmação quando sensíveis: `registrar_protocolo`, `abrir_contestacao`, `atualizar_status_sr`, `cancelar_vas`, `bloquear_vas`, `enviar_sms`, `registrar_tracking`.
3. **MCP Server legado** em `mcp_servers/legacy_tim_mcp/main.py`, que adapta endpoints TIM/FIRST ao contrato MCP:
```http
GET /mcp/tools/list
POST /mcp/tools/call
```
4. **Mock de backend externo** em `backend_mock/app.py`, útil para testar o MCP sem depender das APIs reais.
5. **Prompt específico do agente**, em `ContasAgent.run()`, instruindo o modelo a não inventar dados de fatura, pagamento, plano, VAS, protocolo ou contestação e a usar resultados MCP/RAG como evidência.
## Tecnologias utilizadas
- Python 3.11/3.12 recomendado.
- FastAPI e Uvicorn.
- Pydantic e pydantic-settings.
- LangGraph e LangChain Core.
- Agent Framework OCI.
- OCI Generative AI / OCI SDK.
- Oracle Autonomous Database / `oracledb`, quando usado para persistência/RAG.
- Redis, opcional para cache distribuído.
- Langfuse, opcional para traces.
- OpenTelemetry, opcional para exportação OTEL.
- HTTPX para chamadas HTTP do MCP legado.
- YAML para configuração declarativa.
- Docker Compose opcional.
## Requisitos para subir a aplicação
### Obrigatórios
- Python 3.11 ou 3.12.
- `pip` e `venv`.
- Agent Framework OCI instalado no mesmo ambiente Python do backend.
- Portas livres:
- `8000`: backend do agente;
- `5173`: frontend;
- `8100`: MCP Server legado;
- `8001`: mock das APIs externas.
### Para execução real com OCI/ADB
- Acesso ao OCI Generative AI ou endpoint OCI configurado.
- `OCI_CONFIG_FILE`, `OCI_PROFILE`, `OCI_REGION`, `OCI_COMPARTMENT_ID` e modelo/endpoint válidos.
- Acesso ao Autonomous Database se `VECTOR_STORE_PROVIDER=autonomous` ou repositórios forem configurados para ADB.
- Wallet/DSN/senha conforme o modo de conexão.
### Para observabilidade
- Langfuse local ou remoto se `ENABLE_LANGFUSE=true`.
- Redis se `ENABLE_REDIS_CACHE=true`.
- OTEL collector se `ENABLE_OTEL=true`.
## Instalação inicial
No diretório raiz do projeto:
```bash
cd first_contas
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
```
Instale o framework no mesmo ambiente. Exemplo em modo editable:
```bash
pip install -e "../agent_framework"
```
Ajuste o caminho para o local real do seu `agent_framework_oci`.
## Configuração recomendada para teste local com mock externo
Para testar o fluxo completo com **backend + MCP + mock server**, deixe o MCP chamando o mock HTTP local. No `.env` do backend e no `.env` do MCP, ajuste endpoints para `localhost:8001`:
```env
TIM_MCP_USE_MOCK=false
TIM_COMPLETE_INVOICES_URL=http://localhost:8001/customers/v1/completeInvoices
TIM_PROFILE_BILL_URL=http://localhost:8001/customers/v1/completeInvoices/profile
TIM_CONTRATO_URL=http://localhost:8001/access/v1/contractInformation
TIM_QUERY_VAS_URL=http://localhost:8001/access/v1/productsSingleVas
TIM_CANCEL_VAS_URL=http://localhost:8001/access/v1/singleVas
TIM_BLOCK_VAS_URL=http://localhost:8001/customers/v1/createPartialBlocking
TIM_BILL_PDF_URL=http://localhost:8001/invoices/v1/invoiceRecover
TIM_PROTOCOL_URL=http://localhost:8001/customers/v1/backOfficeSRopening
TIM_CUSTOMER_CONTESTATION_URL=http://localhost:8001/interactions/v1/customerContestation
TIM_SERVICE_REQUEST_STATUS_URL=http://localhost:8001/interactions/v1/statusServiceRequest
TIM_SMS_URL=http://localhost:8001/customers/v1/smsSend
TIM_TRACKING_ACTIVITIES_URL=http://localhost:8001/customers/v1/trackingActivities
```
Também é possível usar `TIM_MCP_USE_MOCK=true`; nesse modo o MCP usa mock interno e não precisa do `backend_mock`. Como você pediu a subida dos quatro terminais, o modo recomendado acima mantém o `backend_mock` participando do fluxo.
## Como abrir os 4 terminais para subir a aplicação toda
### Terminal 1 — MCP Server legado TIM/FIRST
```bash
cd first_contas
./scripts/start_legacy_tim_mcp.sh
```
Alternativa manual:
```bash
cd first_contas/mcp_servers/legacy_tim_mcp
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
uvicorn main:app --host 0.0.0.0 --port 8100 --reload
```
Teste:
```bash
curl -s http://localhost:8100/health | python -m json.tool
curl -s http://localhost:8100/mcp/tools/list | python -m json.tool
```
### Terminal 2 — Mock server das APIs externas
```bash
cd first_contas/backend_mock
python -m venv .venv
source .venv/bin/activate
pip install fastapi 'uvicorn[standard]'
uvicorn app:app --host 0.0.0.0 --port 8001 --reload
```
Teste:
```bash
curl -s http://localhost:8001/health | python -m json.tool
```
### Terminal 3 — Backend do agente
```bash
cd first_contas
./scripts/start_backend.sh
```
Alternativa manual:
```bash
cd first_contas
source .venv/bin/activate
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
Teste:
```bash
curl -s http://localhost:8000/health | python -m json.tool
curl -s http://localhost:8000/debug/mcp/call/consultar_fatura -H 'Content-Type: application/json' -d '{"business_context":{"customer_key":"11999999999","contract_key":"FAT-2026-06","account_key":"CUST-001","interaction_key":"URA-001"}}' | python -m json.tool
```
### Terminal 4 — Frontend
Este pacote não traz o código do frontend. Use o frontend do `agent_framework_oci` ou o projeto de frontend que você já usa com o backend do framework. Exemplo:
```bash
cd "/agent_framework_oci/agent_frontend"
npm install
npm run dev -- --host 0.0.0.0 --port 5173
```
No frontend, configure a URL do backend como:
```text
http://localhost:8000
```
E use `agent_id`:
```text
contas_first
```
Payload mínimo esperado pelo backend:
```json
{
"channel": "web",
"tenant_id": "default",
"agent_id": "contas_first",
"payload": {
"text": "Quero entender por que minha fatura veio mais alta",
"session_id": "sess-contas-001",
"user_id": "11999999999",
"customer_key": "11999999999",
"contract_key": "FAT-2026-06",
"account_key": "CUST-001",
"interaction_key": "URA-001",
"channel": "web"
}
}
```
## Testes por curl incluídos no pacote
Depois de subir os serviços:
```bash
cd first_contas
./tests/curl_mcp_health.sh
./tests/curl_mcp_consultar_fatura.sh
./tests/curl_backend_health.sh
./tests/curl_backend_debug_mcp.sh
./tests/curl_gateway_fatura.sh
```
## Endpoints principais
| Endpoint | Serviço | Descrição |
|---|---|---|
| `GET /health` | Backend | Health check do backend do agente. |
| `POST /gateway/message` | Backend | Entrada principal de mensagens normalizadas ou payloads de canal. |
| `POST /debug/mcp/call/{tool}` | Backend | Diagnóstico direto de chamada MCP com `business_context`. |
| `GET /health` | MCP | Health check do MCP legado. |
| `GET /mcp/tools/list` | MCP | Lista tools disponíveis no servidor MCP. |
| `POST /mcp/tools/call` | MCP | Executa uma tool MCP. |
| `GET /health` | Mock | Health check do backend mock. |
## BusinessContext e mapeamento MCP
O framework trabalha com chaves canônicas:
```json
{
"customer_key": "11999999999",
"contract_key": "FAT-2026-06",
"account_key": "CUST-001",
"interaction_key": "URA-001",
"resource_key": "ASSET-001",
"session_key": "sess-contas-001"
}
```
O `MCPToolRouter` converte essas chaves para os parâmetros reais de cada tool. Exemplos:
| Tool | Mapeamento principal |
|---|---|
| `consultar_fatura` | `customer_key → msisdn`, `contract_key → invoice_id`, `account_key → customer_id`, `interaction_key → ura_call_id`, `session_key → session_id` |
| `consultar_pagamentos` | `customer_key → msisdn`, `account_key → customer_id`, `interaction_key → ura_call_id` |
| `consultar_plano` | `customer_key → msisdn`, `resource_key → asset_id`, `account_key → customer_id` |
| `listar_servicos` | `customer_key → msisdn`, `resource_key → asset_id` |
| `abrir_contestacao` | `customer_key → msisdn`, `contract_key → invoice_id`, `account_key → customer_id` |
| `cancelar_vas`/`bloquear_vas` | `customer_key → msisdn`, `resource_key → app_id` |
## Arquivos mais importantes
| Arquivo | Descrição |
|---|---|
| `README.md` | README original do pacote, com visão geral da migração. |
| `README_MIGRACAO_CONTAS_FIRST.md` | Decisões de migração e rastreabilidade. |
| `.env.example` | Exemplo de configuração completa. |
| `app/main.py` | Bootstrap FastAPI e inicialização dos componentes do framework. |
| `app/agents/contas_agent.py` | Agente específico de Contas. |
| `app/agents/runtime.py` | Mixin/apoio runtime do agente. |
| `app/workflows/agent_graph.py` | Workflow LangGraph corporativo. |
| `config/agents.yaml` | Registro do agente `contas_first`. |
| `config/routing.yaml` | Intents, priorities, keywords e tools por intent. |
| `config/tools.yaml` | Catálogo declarativo de tools MCP. |
| `config/mcp_servers.yaml` | Servidor MCP `legacy_tim` em `http://localhost:8100/mcp`. |
| `config/mcp_parameter_mapping.yaml` | Mapeamento BusinessContext -> args das tools. |
| `config/identity.yaml` | Resolução de aliases de identidade. |
| `config/guardrails.yaml` | Guardrails globais. |
| `config/judges.yaml` | Judges globais. |
| `mcp_servers/legacy_tim_mcp/main.py` | Servidor MCP legado adaptador TIM/FIRST. |
| `backend_mock/app.py` | Mock de endpoints externos TIM/FIRST. |
| `scripts/start_backend.sh` | Script para subir backend. |
| `scripts/start_legacy_tim_mcp.sh` | Script para subir MCP legado. |
| `tests/*.sh` | Curls de validação. |
## Docker Compose opcional
O pacote inclui `docker-compose.yml` com dois serviços:
- `legacy-tim-mcp`, porta `8100`;
- `agent-contas`, porta `8000`.
Execução:
```bash
cd first_contas
cp .env.example .env
cp mcp_servers/legacy_tim_mcp/.env.example mcp_servers/legacy_tim_mcp/.env
docker compose up --build
```
O compose não inclui o frontend nem o `backend_mock`; para testar com mock externo no Docker, adicione um serviço adicional ou rode o mock localmente e ajuste as URLs conforme a rede do container.
## Lista de parâmetros `.env`
Abaixo estão os parâmetros encontrados em `.env.example`, explicados por finalidade. Os valores concretos devem ser preenchidos no `.env` local ou por secrets. Não copie tokens/senhas reais para documentação ou repositório.
### Geral
| Parâmetro | Para que serve |
|---|---|
| `APP_NAME` | Nome lógico da aplicação usado em logs, telemetria e identificação operacional. |
| `APP_ENV` | Ambiente de execução, por exemplo local, dev, hml ou prod. |
| `LOG_LEVEL` | Nível de log da aplicação Python/FastAPI. |
| `API_HOST` | Host/IP usado pelo Uvicorn para publicar o backend. |
| `API_PORT` | Porta HTTP do backend do agente. |
| `CORS_ORIGINS` | Origens permitidas para chamadas do frontend/browser. |
### Mapeado a partir das variáveis TIM_LLM_* do legado.
| Parâmetro | Para que serve |
|---|---|
| `LLM_PROVIDER` | Provider principal de LLM usado pelo Agent Framework OCI. |
| `LLM_TEMPERATURE` | Temperatura padrão do modelo para controlar criatividade/variação. |
| `LLM_MAX_TOKENS` | Limite máximo padrão de tokens de resposta. |
| `LLM_TIMEOUT_SECONDS` | Timeout padrão para chamadas ao LLM; zero normalmente significa sem timeout específico. |
| `OCI_GENAI_BASE_URL` | Endpoint/base URL do serviço OCI Generative AI ou endpoint dedicado. |
| `OCI_GENAI_MODEL` | Modelo ou endpoint OCID usado pelo OCI Generative AI. |
| `OCI_GENAI_API_KEY` | Chave de API quando o provider usa modo compatível com API key. |
| `OCI_CONFIG_FILE` | Caminho do arquivo de configuração OCI local. |
| `OCI_PROFILE` | Profile OCI dentro do arquivo config. |
| `OCI_COMPARTMENT_ID` | OCID do compartment onde ficam os recursos de GenAI/embedding. |
| `OCI_REGION` | Região OCI usada pelo SDK/serviços. |
### Variáveis OCI legadas mantidas para compatibilidade com adapters/scripts.
| Parâmetro | Para que serve |
|---|---|
| `TIM_LLM_PROVIDER` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
| `TIM_LLM_MODEL` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
| `TIM_LLM_API_KEY` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
| `TIM_LLM_BASE_URL` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
| `TIM_LLM_API_VERSION` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
| `TIM_LLM_TEMPERATURE` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
| `TIM_LLM_MAX_TOKENS` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
| `TIM_LLM_REQUEST_TIMEOUT` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
| `TIM_LLM_EXTRA` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
| `TIM_LLM_OCI_VARIANT` | Configuração legada OCI usada por adapters/scripts do Agent Contas FIRST. |
| `TIM_LLM_OCI_MODEL_ID_20B` | OCID/model id legado para variante específica do LLM OCI. |
| `TIM_LLM_OCI_MODEL_ID_120B` | OCID/model id legado para variante específica do LLM OCI. |
| `TIM_LLM_OCI_SERVICE_ENDPOINT` | Configuração legada OCI usada por adapters/scripts do Agent Contas FIRST. |
| `TIM_LLM_OCI_MODEL_KWARGS` | JSON com parâmetros do modelo OCI legado, como temperature, top_p, max_tokens e reasoning_effort. |
| `TIM_LLM_OCI_MODEL_KWARGS_20B` | JSON com parâmetros do modelo OCI legado, como temperature, top_p, max_tokens e reasoning_effort. |
| `TIM_LLM_OCI_MODEL_KWARGS_120B` | JSON com parâmetros do modelo OCI legado, como temperature, top_p, max_tokens e reasoning_effort. |
| `TIM_LLM_OCI_COMPARTMENT_ID` | Configuração legada OCI usada por adapters/scripts do Agent Contas FIRST. |
| `TIM_OCI_USER` | Credencial/configuração OCI legada usada pelos adapters do legado. |
| `TIM_OCI_FINGERPRINT` | Credencial/configuração OCI legada usada pelos adapters do legado. |
| `TIM_OCI_TENANCY` | Credencial/configuração OCI legada usada pelos adapters do legado. |
| `TIM_OCI_REGION` | Credencial/configuração OCI legada usada pelos adapters do legado. |
| `TIM_OCI_KEY_FILE` | Credencial/configuração OCI legada usada pelos adapters do legado. |
| `TIM_LLM_OCI_AUTH_FILE_LOCATION` | Configuração legada OCI usada por adapters/scripts do Agent Contas FIRST. |
### Repositórios / persistência do framework
| Parâmetro | Para que serve |
|---|---|
| `SESSION_REPOSITORY_PROVIDER` | Provider do repositório de sessões do framework, como memory ou autonomous. |
| `MEMORY_REPOSITORY_PROVIDER` | Provider da memória conversacional/histórico. |
| `CHECKPOINT_REPOSITORY_PROVIDER` | Provider de checkpoints LangGraph. |
| `USAGE_REPOSITORY_PROVIDER` | Provider para gravação de uso/tokens/custo. |
| `TIM_STATE_BACKEND` | Backend legado de estado mantido por compatibilidade. |
### Autonomous DB / Oracle Store
| Parâmetro | Para que serve |
|---|---|
| `ADB_USER` | Usuário do Autonomous Database para persistência/RAG. |
| `ADB_PASSWORD` | Senha do Autonomous Database. Valor sensível; não commitar. |
| `ADB_DSN` | DSN/connect string do Autonomous Database. |
| `ADB_WALLET_LOCATION` | Diretório do wallet ADB, quando aplicável. |
| `ADB_WALLET_PASSWORD` | Senha do wallet ADB. Valor sensível; não commitar. |
| `ADB_TABLE_PREFIX` | Prefixo das tabelas criadas/usadas pelo framework. |
| `TIM_DB_TNS_NAME` | Configuração de banco Oracle/ADB legada TIM mantida para compatibilidade. |
| `TIM_DB_CONNECT_STRING` | Configuração de banco Oracle/ADB legada TIM mantida para compatibilidade. |
| `TIM_DB_USER` | Configuração de banco Oracle/ADB legada TIM mantida para compatibilidade. |
| `TIM_DB_PASSWORD` | Configuração de banco Oracle/ADB legada TIM mantida para compatibilidade. |
| `TIM_DB_WALLET_PASSWORD` | Configuração de banco Oracle/ADB legada TIM mantida para compatibilidade. |
| `TIM_DB_USE_WALLET` | Configuração de banco Oracle/ADB legada TIM mantida para compatibilidade. |
### RAG / Vector Store do framework
| Parâmetro | Para que serve |
|---|---|
| `VECTOR_STORE_PROVIDER` | Provider do vector store/RAG, como memory ou autonomous. |
| `GRAPH_STORE_PROVIDER` | Provider do graph store, quando habilitado. |
| `RAG_TOP_K` | Quantidade máxima de documentos/chunks recuperados por consulta RAG. |
| `EMBEDDING_PROVIDER` | Provider de embeddings, como mock ou oci. |
| `OCI_EMBEDDING_MODEL` | Modelo OCI de embedding usado para indexação/consulta. |
| `RAG_FILE_GLOBS` | Extensões/padrões de arquivos aceitos na ingestão RAG. |
| `TIM_RAG_ENABLED` | Liga/desliga compatibilidade RAG do legado TIM. |
| `TIM_RAG_OCI_SERVICE_ENDPOINT` | Configuração RAG legada TIM mantida para compatibilidade com adapters/scripts. |
| `TIM_RAG_DB_USER` | Configuração RAG legada TIM mantida para compatibilidade com adapters/scripts. |
| `TIM_RAG_DB_PASSWORD` | Configuração RAG legada TIM mantida para compatibilidade com adapters/scripts. |
| `TIM_RAG_DB_DSN` | Configuração RAG legada TIM mantida para compatibilidade com adapters/scripts. |
### Langfuse / observabilidade
| Parâmetro | Para que serve |
|---|---|
| `ENABLE_LANGFUSE` | Liga/desliga envio de traces para Langfuse. |
| `LANGFUSE_PUBLIC_KEY` | Chave pública Langfuse. Valor sensível operacional. |
| `LANGFUSE_SECRET_KEY` | Chave secreta Langfuse. Valor sensível; não commitar. |
| `LANGFUSE_HOST` | URL do servidor Langfuse. |
| `ENABLE_OTEL` | Liga/desliga exportação OpenTelemetry. |
| `TIM_LLM_GATEWAY_LANGFUSE_PUBLIC_KEY` | Configuração legada Langfuse do LLM Gateway (public_key). |
| `TIM_LLM_GATEWAY_LANGFUSE_SECRET_KEY` | Configuração legada Langfuse do LLM Gateway (secret_key). |
| `TIM_LLM_GATEWAY_LANGFUSE_HOST` | Configuração legada Langfuse do LLM Gateway (host). |
| `TIM_LLM_GATEWAY_LANGFUSE_DEFAULT_LABEL` | Configuração legada Langfuse do LLM Gateway (default_label). |
| `TIM_LLM_GATEWAY_LANGFUSE_CACHE_TTL_SECONDS` | Configuração legada Langfuse do LLM Gateway (cache_ttl_seconds). |
| `TIM_LLM_GATEWAY_LANGFUSE_MAX_RETRIES` | Configuração legada Langfuse do LLM Gateway (max_retries). |
| `TIM_LLM_GATEWAY_LANGFUSE_FETCH_TIMEOUT_SECONDS` | Configuração legada Langfuse do LLM Gateway (fetch_timeout_seconds). |
| `TIM_LLM_GATEWAY_LANGFUSE_MASK_SENSITIVE_DATA` | Configuração legada Langfuse do LLM Gateway (mask_sensitive_data). |
| `TIM_AGENT_FRAMEWORK_LOG_EXPORT_ENABLED` | Parâmetro de compatibilidade do projeto; revisar uso específico no código/configuração antes de remover. |
| `ENABLE_ANALYTICS` | Liga/desliga publisher analítico do framework. |
| `ANALYTICS_PROVIDERS` | Lista/provider de analytics, por exemplo noop ou oci_streaming. |
| `ENABLE_OCI_STREAMING` | Liga/desliga integração com OCI Streaming. |
### Guardrails / Supervisor / Judges do framework
| Parâmetro | Para que serve |
|---|---|
| `ENABLE_INPUT_GUARDRAILS` | Ativa guardrails de entrada antes do roteamento/agente. |
| `ENABLE_OUTPUT_GUARDRAILS` | Ativa guardrails de saída depois da resposta do agente. |
| `ENABLE_JUDGES` | Ativa judges pós-resposta. |
| `ENABLE_SUPERVISOR` | Ativa supervisor/validação de fluxo do framework. |
| `ENABLE_OUTPUT_SUPERVISOR` | Ativa revisão/retry da resposta antes da entrega. |
| `ENABLE_PARALLEL_GUARDRAILS` | Executa guardrails em paralelo quando suportado. |
| `GUARDRAILS_FAIL_FAST` | Interrompe no primeiro guardrail bloqueante. |
| `OUTPUT_SUPERVISOR_MAX_RETRIES` | Número máximo de tentativas de correção do Output Supervisor. |
| `GUARDRAILS_CONFIG_PATH` | Caminho do YAML de guardrails. |
| `JUDGES_CONFIG_PATH` | Caminho do YAML de judges. |
| `PROMPT_POLICY_PATH` | Caminho da política de prompt. |
| `USE_MOCK_LLM` | Força LLM mock para teste local sem provider real. |
| `GUARDRAIL_LLM` | Perfil/modelo usado pelos guardrails LLM. |
### Channel / Routing / MCP framework
| Parâmetro | Para que serve |
|---|---|
| `DEFAULT_CHANNEL` | Canal padrão quando não informado na request. |
| `FRAMEWORK_CHANNEL_INPUT_MODE` | Modo do Channel Gateway: embedded ou external. |
| `ENABLE_VOICE_ADAPTER` | Habilita adapter de voz. |
| `ENABLE_WHATSAPP_ADAPTER` | Habilita adapter WhatsApp. |
| `ENABLE_TEXT_ADAPTER` | Habilita adapter texto/web. |
| `ROUTING_CONFIG_PATH` | Caminho do YAML de roteamento/intents. |
| `ENABLE_LLM_ROUTER` | Liga router por LLM além das regras/YAML. |
| `ROUTING_MODE` | Modo de roteamento do framework. |
| `ENABLE_MCP_TOOLS` | Liga/desliga execução de tools MCP. |
| `MCP_SERVERS_CONFIG_PATH` | Caminho do YAML com servidores MCP. |
| `TOOLS_CONFIG_PATH` | Caminho do catálogo de tools MCP. |
| `MCP_PARAMETER_MAPPING_PATH` | Caminho do mapeamento BusinessContext -> argumentos das tools. |
| `MCP_TOOL_TIMEOUT_SECONDS` | Timeout padrão das chamadas MCP. |
| `IDENTITY_CONFIG_PATH` | Caminho do YAML do IdentityResolver. |
| `ENABLE_REDIS_CACHE` | Liga Redis para cache distribuído. |
| `REDIS_URL` | URL de conexão Redis. |
| `ENABLE_CONVERSATION_SUMMARY_MEMORY` | Liga memória sumarizada de conversa. |
| `MEMORY_CONTEXT_STRATEGY` | Estratégia de contexto de memória, por exemplo summary. |
| `MEMORY_HISTORY_LIMIT` | Limite de mensagens mantidas no histórico. |
| `MEMORY_RECENT_MESSAGES_LIMIT` | Quantidade de mensagens recentes injetadas no prompt. |
| `MEMORY_SUMMARY_TRIGGER_MESSAGES` | Número de mensagens que dispara sumarização. |
| `MEMORY_MAX_SUMMARY_CHARS` | Tamanho máximo do resumo de memória. |
| `MEMORY_SUMMARY_USE_LLM` | Usa LLM para gerar resumo de memória. |
| `MEMORY_INJECT_RECENT_MESSAGES` | Injeta mensagens recentes no contexto do agente. |
| `MEMORY_INJECT_SUMMARY` | Injeta resumo da conversa no contexto do agente. |
### MCP legacy TIM/FIRST - integração real por padrão
| Parâmetro | Para que serve |
|---|---|
| `TIM_MCP_USE_MOCK` | Liga mock interno do MCP. Se false, MCP chama endpoints externos configurados. |
| `TIM_MCP_TIMEOUT_SECONDS` | Timeout padrão do MCP legado. |
| `TIM_GATEWAY_RETRY_MAX_RETRIES` | Número máximo de retries nas chamadas externas TIM. |
| `TIM_GATEWAY_RETRY_BACKOFF_FACTOR` | Fator de backoff entre retries. |
| `TIM_GATEWAY_DEFAULT_TIMEOUT` | Timeout padrão do gateway HTTP externo. |
| `TIM_DEFAULT_AUTH` | Authorization default para endpoints TIM quando não há auth específico. |
| `TIM_CLIENT_ID` | Client ID padrão enviado às APIs TIM/FIRST. |
| `TIM_USER_ID` | Usuário técnico/default enviado em payloads/headers. |
| `TIM_DEFAULT_CLIENT_ID` | Client ID fallback para chamadas externas. |
| `TIM_DEFAULT_CSP_ID` | CSP ID padrão para VAS/serviços. |
| `TIM_DEFAULT_CHANNEL` | Canal default moderno para APIs TIM. |
| `TIM_DEFAULT_CHANNEL_LEGACY` | Canal default compatível com legado. |
| `TIM_AUTHORIZATION_OAM` | Token/header OAM quando a API exigir. |
| `TIM_CN_FIELD` | Campo CN legado opcional para headers/payloads. |
| `TIM_TYPE_FIELD` | Campo type legado opcional para headers/payloads. |
### Consulta VAS
| Parâmetro | Para que serve |
|---|---|
| `TIM_QUERY_VAS_URL` | URL do endpoint externo para query vas. |
| `TIM_QUERY_VAS_AUTH` | Header Authorization específico para query vas. Valor sensível. |
| `TIM_QUERY_VAS_TIMEOUT` | Timeout em segundos para query vas. |
| `TIM_URL_CONSULTA_VAS` | Alias legado de URL para endpoint TIM; mantido para compatibilidade com nomes antigos. |
| `TIM_CONSULTA_AUTH` | Header Authorization específico para consulta. Valor sensível. |
| `TIM_CONSULTA_TIMEOUT` | Timeout em segundos para consulta. |
### Bloqueio VAS
| Parâmetro | Para que serve |
|---|---|
| `TIM_BLOCK_VAS_URL` | URL do endpoint externo para block vas. |
| `TIM_BLOCK_VAS_AUTH` | Header Authorization específico para block vas. Valor sensível. |
| `TIM_BLOCK_VAS_TIMEOUT` | Timeout em segundos para block vas. |
| `TIM_BLOCK_VAS_CLIENT_ID` | Client ID específico usado em block vas. |
| `TIM_BLOCK_VAS_OPERATION_TYPE` | Tipo de operação enviado para block vas. |
| `TIM_BLOCK_VAS_PAYLOAD_MODE` | Modo de montagem do payload para block vas. |
| `TIM_BLOCK_VAS_ACCEPT_ENCODING` | Header Accept-Encoding usado em block vas. |
| `TIM_URL_BLOQUEIO_VAS` | Alias legado de URL para endpoint TIM; mantido para compatibilidade com nomes antigos. |
| `TIM_BLOQUEIO_AUTH` | Header Authorization específico para bloqueio. Valor sensível. |
| `TIM_BLOQUEIO_TIMEOUT` | Timeout em segundos para bloqueio. |
| `TIM_BLOQUEIO_OPERATION_TYPE` | Tipo de operação enviado para bloqueio. |
| `TIM_BLOQUEIO_ACCEPT_ENCODING` | Header Accept-Encoding usado em bloqueio. |
### Cancelamento VAS
| Parâmetro | Para que serve |
|---|---|
| `TIM_CANCEL_VAS_URL` | URL do endpoint externo para cancel vas. |
| `TIM_CANCEL_VAS_AUTH` | Header Authorization específico para cancel vas. Valor sensível. |
| `TIM_CANCEL_VAS_TIMEOUT` | Timeout em segundos para cancel vas. |
| `TIM_CANCEL_VAS_CLIENT_ID` | Client ID específico usado em cancel vas. |
| `TIM_CANCELAMENTO_URL` | URL do endpoint externo para cancelamento. |
| `TIM_CANCELAMENTO_AUTH` | Header Authorization específico para cancelamento. Valor sensível. |
| `TIM_CANCELAMENTO_TIMEOUT` | Timeout em segundos para cancelamento. |
### Contrato
| Parâmetro | Para que serve |
|---|---|
| `TIM_CONTRATO_URL` | URL do endpoint externo para contrato. |
| `TIM_CONTRATO_AUTH` | Header Authorization específico para contrato. Valor sensível. |
| `TIM_CONTRATO_TIMEOUT` | Timeout em segundos para contrato. |
### Complete invoices / fatura completa
| Parâmetro | Para que serve |
|---|---|
| `TIM_COMPLETE_INVOICES_URL` | URL do endpoint externo para complete invoices. |
| `TIM_COMPLETE_INVOICES_AUTH` | Header Authorization específico para complete invoices. Valor sensível. |
| `TIM_COMPLETE_INVOICES_TIMEOUT` | Timeout em segundos para complete invoices. |
| `TIM_COMPLETE_INVOICES_CLIENT_ID` | Client ID específico usado em complete invoices. |
### Perfil de fatura
| Parâmetro | Para que serve |
|---|---|
| `TIM_PROFILE_BILL_URL` | URL do endpoint externo para profile bill. |
| `TIM_PROFILE_BILL_AUTH` | Header Authorization específico para profile bill. Valor sensível. |
| `TIM_PROFILE_BILL_TIMEOUT` | Timeout em segundos para profile bill. |
| `TIM_URL_PERFIL_FATURA` | Alias legado de URL para endpoint TIM; mantido para compatibilidade com nomes antigos. |
| `TIM_PROFILE_FULL_URL` | URL do endpoint externo para profile full. |
### Divergência / billing analysis
| Parâmetro | Para que serve |
|---|---|
| `TIM_DIVERGENCIA_URL` | URL do endpoint externo para divergencia. |
| `TIM_DIVERGENCIA_AUTH` | Header Authorization específico para divergencia. Valor sensível. |
| `TIM_INVOICE_EXPLANATION_URL` | URL do endpoint externo para invoice explanation. |
| `TIM_INVOICE_EXPLANATION_AUTH` | Header Authorization específico para invoice explanation. Valor sensível. |
### Bill/PDF / invoice recover
| Parâmetro | Para que serve |
|---|---|
| `TIM_BILL_PDF_URL` | URL do endpoint externo para bill pdf. |
| `TIM_BILL_PDF_AUTH` | Header Authorization específico para bill pdf. Valor sensível. |
| `TIM_BILL_PDF_TIMEOUT` | Timeout em segundos para bill pdf. |
| `TIM_BILL_PDF_CLIENT_ID` | Client ID específico usado em bill pdf. |
| `TIM_URL_INVOICE_RECOVER` | Alias legado de URL para endpoint TIM; mantido para compatibilidade com nomes antigos. |
| `TIM_INVOICE_RECOVER_AUTH` | Header Authorization específico para invoice recover. Valor sensível. |
| `TIM_INVOICE_RECOVER_TIMEOUT` | Timeout em segundos para invoice recover. |
| `TIM_INVOICE_RECOVER_CLIENT_ID` | Client ID específico usado em invoice recover. |
### Protocolo / backOfficeSRopening
| Parâmetro | Para que serve |
|---|---|
| `TIM_PROTOCOL_URL` | URL do endpoint externo para protocol. |
| `TIM_PROTOCOL_AUTH` | Header Authorization específico para protocol. Valor sensível. |
| `TIM_PROTOCOL_TIMEOUT` | Timeout em segundos para protocol. |
### Contestação/SR
| Parâmetro | Para que serve |
|---|---|
| `TIM_CUSTOMER_CONTESTATION_URL` | URL do endpoint externo para customer contestation. |
| `TIM_CUSTOMER_CONTESTATION_AUTH` | Header Authorization específico para customer contestation. Valor sensível. |
| `TIM_CUSTOMER_CONTESTATION_TIMEOUT` | Timeout em segundos para customer contestation. |
| `TIM_CUSTOMER_CONTESTATION_CLIENT_ID` | Client ID específico usado em customer contestation. |
| `TIM_CUSTOMER_CONTESTATION_USER_ID` | Usuário técnico específico usado em customer contestation. |
### Status SR
| Parâmetro | Para que serve |
|---|---|
| `TIM_SERVICE_REQUEST_STATUS_URL` | URL do endpoint externo para service request status. |
| `TIM_SERVICE_REQUEST_STATUS_AUTH` | Header Authorization específico para service request status. Valor sensível. |
| `TIM_SERVICE_REQUEST_STATUS_TIMEOUT` | Timeout em segundos para service request status. |
### SMS
| Parâmetro | Para que serve |
|---|---|
| `TIM_SMS_URL` | URL do endpoint externo para sms. |
| `TIM_SMS_AUTH` | Header Authorization específico para sms. Valor sensível. |
| `TIM_SMS_TIMEOUT` | Timeout em segundos para sms. |
| `TIM_SMS_CLIENT_ID` | Client ID específico usado em sms. |
| `TIM_SMS_SENDER_ADDRESS` | Endereço/remetente usado no envio de SMS. |
| `TIM_SMS_SENDER_NAME` | Nome do remetente usado no envio de SMS. |
| `TIM_SMS_TOKEN_URL` | Configuração para obtenção de token OAuth/serviço do envio de SMS. |
| `TIM_SMS_TOKEN_CLIENT_ID` | Configuração para obtenção de token OAuth/serviço do envio de SMS. |
| `TIM_SMS_TOKEN_CLIENT_SECRET` | Configuração para obtenção de token OAuth/serviço do envio de SMS. |
| `TIM_SMS_TOKEN_SCOPE` | Configuração para obtenção de token OAuth/serviço do envio de SMS. |
| `TIM_SMS_TOKEN_AUDIENCE` | Configuração para obtenção de token OAuth/serviço do envio de SMS. |
| `TIM_SMS_TOKEN_TIMEOUT` | Configuração para obtenção de token OAuth/serviço do envio de SMS. |
| `SMS_BARCODE_AUTH` | Authorization alternativo para envio de SMS com código de barras. |
### Tracking activities
| Parâmetro | Para que serve |
|---|---|
| `TIM_TRACKING_ACTIVITIES_URL` | URL do endpoint externo para tracking activities. |
| `TIM_TRACKING_ACTIVITIES_AUTH` | Header Authorization específico para tracking activities. Valor sensível. |
| `TIM_TRACKING_ACTIVITIES_TIMEOUT` | Timeout em segundos para tracking activities. |
| `TIM_TRACKING_ACTIVITIES_USER_LOGIN` | Login técnico usado em tracking activities. |
| `TIM_TRACKING_ACTIVITIES_CHANNEL` | Canal enviado nas chamadas de tracking activities. |
| `TIM_TRACKING_ACTIVITIES_CLIENT_ID` | Client ID específico usado em tracking activities. |
### Legacy app / workflows
| Parâmetro | Para que serve |
|---|---|
| `TIM_WORKFLOWS_DIR` | Diretório de workflows legados mantido como referência. |
| `TIM_WORKFLOW_POSTGRES_DSN` | DSN Postgres legado, se algum workflow antigo exigir. |
| `TIM_APP_HOST` | Host legado da aplicação TIM. |
| `TIM_APP_PORT` | Porta legada da aplicação TIM. |
| `TIM_APP_RELOAD` | Reload legado do app TIM. |
| `TIM_APP_LOG_LEVEL` | Log level legado do app TIM. |
### LLM Gateway legado
| Parâmetro | Para que serve |
|---|---|
| `TIM_LLM_GATEWAY_SOURCES` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
| `TIM_LLM_GATEWAY_LOCAL_DIR` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
| `TIM_LLM_GATEWAY_CAPABILITIES_DIR` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
| `TIM_LLM_GATEWAY_LANGGRAPH_URL` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
| `TIM_LLM_GATEWAY_LANGGRAPH_TIMEOUT` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
| `TIM_LLM_GATEWAY_FALLBACK_PROMPT_ID` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
| `TIM_LLM_GATEWAY_FALLBACK_PROMPT_CONTENT` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
### Pub/Sub / export legado
| Parâmetro | Para que serve |
|---|---|
| `GCP_PROJECT_ID` | Projeto GCP legado usado em Pub/Sub/exportações. |
| `AGENT_PUBSUB_TOPIC` | Tópico Pub/Sub legado para curadoria/ingestão. |
| `TIM_PUBSUB_DIRECT_ENABLED` | Liga publicação direta no Pub/Sub legado. |
| `GOOGLE_APPLICATION_CREDENTIALS` | Caminho do arquivo de credenciais GCP. |
### Mock legado explicitamente desligado
| Parâmetro | Para que serve |
|---|---|
| `TIM_MOCK_LATENCY_MS` | Latência artificial do mock legado. |
| `TIM_MOCK_FAILURES` | Lista/configuração de falhas simuladas no mock legado. |
| `TIM_MOCK_FIXTURES_DIR` | Diretório de fixtures do mock legado. |
## Recomendações de operação
1. Para teste local completo, prefira `TIM_MCP_USE_MOCK=false` com URLs apontando para `http://localhost:8001`, pois assim você valida backend, MCP e mock externo no mesmo fluxo.
2. Para smoke test rápido apenas do MCP, use `TIM_MCP_USE_MOCK=true` no MCP e dispense o mock server.
3. Para produção/homologação, substitua URLs do mock pelas APIs reais TIM/FIRST e revise todos os parâmetros `*_AUTH`, `*_CLIENT_ID`, timeouts e retry.
4. Não deixe `USE_MOCK_LLM=true` em ambiente que precise validar chamada real ao LLM.
5. Se `VECTOR_STORE_PROVIDER=autonomous`, valide `ADB_*`, `EMBEDDING_PROVIDER=oci` e `OCI_EMBEDDING_MODEL` antes de testar RAG.
6. Se `ENABLE_LANGFUSE=true`, confirme `LANGFUSE_HOST`, `LANGFUSE_PUBLIC_KEY` e `LANGFUSE_SECRET_KEY` antes de esperar traces.
7. Reinicie MCP e backend sempre que alterar `.env`, `config/tools.yaml`, `config/mcp_servers.yaml` ou `config/mcp_parameter_mapping.yaml`.
## Troubleshooting rápido
| Sintoma | Causa provável | Ação |
|---|---|---|
| `Endpoint externo não configurado para TIM_COMPLETE_INVOICES` | URL da API externa não preenchida no `.env` do MCP. | Configure `TIM_COMPLETE_INVOICES_URL` ou use `TIM_MCP_USE_MOCK=true`. |
| Backend não chama MCP | `ENABLE_MCP_TOOLS=false`, `MCP_SERVERS_CONFIG_PATH` errado ou MCP fora do ar. | Verifique `/debug/mcp/call/consultar_fatura` e `http://localhost:8100/health`. |
| Frontend recebe erro CORS | Origem não está em `CORS_ORIGINS`. | Inclua `http://localhost:5173` no `.env`. |
| Não aparecem traces no Langfuse | Langfuse desabilitado ou chaves/host incorretos. | Verifique `ENABLE_LANGFUSE`, `LANGFUSE_HOST`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`. |
| RAG não retorna documentos | Vector store/embedding não configurados ou base não ingerida. | Teste `VECTOR_STORE_PROVIDER=memory`/`EMBEDDING_PROVIDER=mock` ou configure ADB/OCI. |
| Tool retorna `ok=false` sem erro | MCP antigo/rota errada ou backend não reiniciado. | Reinicie MCP e backend; confira `config/mcp_servers.yaml`. |
## Resumo executivo
Este projeto coloca o Agent Contas FIRST sobre o Agent Framework OCI com uma arquitetura mais limpa: o framework cuida das capacidades transversais corporativas e o código do Contas fica restrito ao domínio de negócio. A integração operacional passa por MCP, com mapeamento canônico de identidade, cache declarativo, RAG nativo, guardrails, judges e observabilidade. Para subir localmente o fluxo completo, use quatro terminais: MCP na porta `8100`, mock externo na porta `8001`, backend na porta `8000` e frontend na porta `5173`.

View File

@@ -0,0 +1,59 @@
# Agent Contas FIRST migrado para agent_framework_oci
Este backend foi gerado a partir do `agent_template_backend` e remodelado para executar o `agent_contas_first` com máximo uso das capacidades nativas do `agent_framework_oci`.
## Decisões principais
- **Não foi portada a camada própria de workflow/guardrails/judges/RAG do legado** para evitar duplicação e regras paralelas.
- O agente específico ficou concentrado em `app/agents/contas_agent.py`.
- O fluxo LangGraph fica em `app/workflows/agent_graph.py` e usa componentes do framework para input guardrails, router, agente, output supervisor, output guardrails, judges, supervisor review e persistência.
- O MCP é chamado somente via `agent_framework.mcp.tool_router` através do `AgentRuntimeMixin.execute_tools_for_intent()`.
- Identity e BusinessContext são resolvidos em `app/main.py` com `IdentityResolver` e `BusinessContext` do framework.
- RAG é consumido via `RagService` do framework usando namespace `contas_first`.
- Eventos IC/NOC/GRL são emitidos no workflow e no agente, sem criar uma camada de observabilidade paralela.
## Arquivos principais
- `app/agents/contas_agent.py`: comportamento de domínio, seleção declarativa de intents/tools, uso de memória, RAG, MCP e LLM cache do framework.
- `app/workflows/agent_graph.py`: pipeline LangGraph corporativo.
- `config/agents.yaml`: registra somente `contas_first`.
- `config/routing.yaml`: intents de fatura, pagamento, segunda via, contestação, plano e VAS.
- `config/agents/contas_first/guardrails.yaml`: ativa MSK, VLOOP, PINJ, DLEX_IN, REVPREC, DLEX_OUT e RAGSEC no framework.
- `config/tools.yaml`: catálogo MCP telecom com cache por `args_schema`.
- `config/mcp_parameter_mapping.yaml`: mapeamento canônico BusinessContext -> argumentos MCP.
- `config/identity.yaml`: aliases de msisdn, invoice_id, asset_id, ura_call_id e session.
- `legacy_reference/`: workflows originais guardados apenas como referência de rastreabilidade.
## Como rodar
```bash
cd agent_contas_migrated
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
```
Exemplo:
```bash
curl -X POST http://localhost:8000/gateway/message \
-H 'Content-Type: application/json' \
-d '{
"channel":"web",
"agent_id":"contas_first",
"tenant_id":"default",
"payload":{
"text":"quero entender minha fatura",
"session_id":"sess-contas-001",
"user_id":"11999999999",
"msisdn":"11999999999",
"invoice_id":"INV-EXEMPLO-001",
"ura_call_id":"URA-001"
}
}'
```
## Observações
O pacote não altera o `agent_framework_oci`; ele consome suas APIs nativas. Caso o framework esteja instalado em modo editable, rode com `PYTHONPATH` apontando para o `src` do framework ou instale o pacote via `pip install -e ../agent_framework_oci`.

BIN
app/.DS_Store vendored Normal file

Binary file not shown.

0
app/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

3
app/agents/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .contas_agent import ContasAgent
__all__ = ["ContasAgent"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

218
app/agents/contas_agent.py Normal file
View File

@@ -0,0 +1,218 @@
from __future__ import annotations
from typing import Any
from app.agents.runtime import AgentRuntimeMixin
_CONTAS_TOOLS_BY_INTENT: dict[str, list[str]] = {
"billing_invoice_explanation": ["consultar_fatura", "consultar_pagamentos"],
"billing_payment_status": ["consultar_pagamentos", "consultar_fatura"],
"billing_second_copy": ["consultar_fatura", "recuperar_pdf_fatura"],
"billing_contestation": ["consultar_fatura", "consultar_pagamentos", "consultar_status_sr"],
"service_plan_information": ["consultar_plano", "listar_servicos"],
"vas_services_information": ["listar_servicos", "consultar_plano", "consultar_status_sr"],
"billing_protocol_status": ["consultar_status_sr"],
"billing_protocol_register": ["registrar_protocolo"],
"vas_cancel_or_block": ["listar_servicos", "cancelar_vas", "bloquear_vas", "registrar_protocolo"],
"send_billing_sms": ["enviar_sms"],
"tracking_activity": ["registrar_tracking"],
"generic_billing_rag": [],
}
class ContasAgent(AgentRuntimeMixin):
"""Agent Contas FIRST migrado para o agent_framework_oci.
Esta classe mantém a lógica específica de negócio apenas como prompt e
seleção declarativa de intents/tools. Guardrails, judges, RAG, memória,
cache MCP, Identity/BusinessContext, telemetria IC/NOC/GRL e chamada MCP são
executados pelos componentes nativos do framework.
"""
name = "contas_agent"
def __init__(
self,
llm,
telemetry=None,
tool_router=None,
rag_service=None,
cache=None,
settings=None,
observer=None,
memory=None,
summary_memory=None,
):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
self.memory = memory
self.summary_memory = summary_memory
async def run(self, state: dict[str, Any]) -> dict[str, Any]:
state = self.normalize_tools_by_intent(
state,
default_tools_by_intent=_CONTAS_TOOLS_BY_INTENT,
default_intent="billing_invoice_explanation",
route=self.name,
)
await self._emit_ic(
"IC.CONTAS_AGENT_STARTED",
state,
{
"framework_native": True,
"uses_native_guardrails": True,
"uses_native_rag": True,
"uses_native_mcp_router": True,
"legacy_business_layer": False,
},
component="agent.contas.start",
)
runtime = self.get_runtime_context(state)
missing = []
if not runtime.pick("customer_key", "msisdn", "customer_id"):
missing.append("customer_key/msisdn")
if missing:
await self._emit_noc(
"NOC.CONTAS_IDENTITY_INCOMPLETE",
state,
{"missing": missing, "business_context": runtime.business_context},
component="agent.contas.identity",
)
await self.prepare_memory_context(state)
rag_context, rag_metadata = await self._retrieve_rag_context(state)
await self._emit_ic(
"IC.CONTAS_RAG_CONTEXT_RETRIEVED",
state,
rag_metadata,
component="agent.contas.rag",
)
mcp_results = await self.execute_tools_for_intent(
state,
aliases={
"customer_key": ["msisdn", "customer_id", "ani", "from"],
"contract_key": ["invoice_id", "current_invoice_number", "asset_id"],
"interaction_key": ["ura_call_id", "message_id", "call_id"],
"resource_key": ["asset_id", "product_id"],
"account_key": ["billing_account_id", "account_id"],
},
)
mcp_results = self._normalize_mcp_diagnostics(mcp_results)
state["mcp_results"] = mcp_results
await self._emit_ic(
"IC.CONTAS_MCP_CONTEXT_COLLECTED",
state,
{
"tool_count": len(state.get("mcp_tools") or []),
"result_count": len(mcp_results),
"ok_count": sum(1 for r in mcp_results if r.get("ok")),
},
component="agent.contas.mcp",
)
system_prompt = """
Você é o Agent Contas FIRST migrado para o agent_framework_oci.
Regras de arquitetura:
- Use os resultados MCP recebidos do framework como fonte operacional.
- Use o contexto RAG nativo como fonte de política/conhecimento.
- Não invente dados de fatura, pagamento, plano, VAS, protocolo ou contestação.
- Não crie regras de negócio paralelas; quando faltar evidência, diga exatamente o que falta.
- Mantenha a resposta curta, auditável e adequada para atendimento de telecom.
- Para ações sensíveis como contestação/cancelamento/bloqueio, explique o próximo passo e solicite confirmação quando a tool/política exigir.
Coberturas vindas do agent_contas_first reaproveitadas como comportamento, não como camada legada:
- explicação de fatura e divergência de valor;
- consulta de pagamentos e segunda via;
- plano/serviços/VAS;
- contestação e fluxos com confirmação;
- uso de protocolo apenas quando existir ferramenta/evidência.
""".strip()
messages = self.build_messages(
state,
system_prompt=system_prompt,
mcp_results=mcp_results,
rag_context=rag_context,
rag_metadata=rag_metadata,
extra_sections={
"Diretriz de migração": "Máximo uso do framework; legado apenas como referência funcional.",
"Intents suportadas": sorted(_CONTAS_TOOLS_BY_INTENT.keys()),
},
)
try:
answer = await self._invoke_llm_cached(state, self.name, messages)
except Exception as exc:
await self._emit_noc(
"NOC.CONTAS_LLM_FAILED",
state,
{"error": str(exc)},
component="agent.contas.llm",
)
answer = self.build_llm_fallback_answer(state, mcp_results, agent_label="ContasAgent")
await self._emit_ic(
"IC.CONTAS_AGENT_COMPLETED",
state,
{
"answer_chars": len(str(answer or "")),
"mcp_result_count": len(mcp_results),
"rag_document_count": rag_metadata.get("document_count"),
},
component="agent.contas.completed",
)
return {
"answer": str(answer or ""),
"mcp_results": mcp_results,
"rag_metadata": rag_metadata,
"active_agent": self.name,
"route": self.name,
"next_state": self._next_state_for_intent(state),
}
def _normalize_mcp_diagnostics(self, results: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Preserva evidência MCP mesmo quando versões antigas do router retornam erro vazio.
A integração correta agora retorna metadata rica do MCP server legado.
Esta proteção evita que o prompt/telemetria recebam `ok=False` sem
diagnóstico quando o ToolRouter instalado for anterior ou quando o
servidor MCP antigo ainda estiver rodando em outra janela.
"""
normalized: list[dict[str, Any]] = []
for item in results or []:
if not isinstance(item, dict):
normalized.append({"ok": False, "error": f"Resultado MCP inválido: {type(item).__name__}", "metadata": {"exception_type": "InvalidMCPResult"}})
continue
current = dict(item)
metadata = current.get("metadata")
if not isinstance(metadata, dict):
metadata = {}
if current.get("ok") is False and not str(current.get("error") or "").strip():
current["error"] = "MCP retornou ok=False sem mensagem de erro; confirme se o legacy_tim_mcp v7 está em execução e reinicie o backend."
metadata.setdefault("exception_type", "EmptyMCPError")
metadata.setdefault("diagnostic_hint", "O servidor MCP antigo ou o ToolRouter instalado pode estar descartando error/metadata.")
current["metadata"] = metadata
normalized.append(current)
return normalized
def _next_state_for_intent(self, state: dict[str, Any]) -> str:
intent = state.get("intent") or ""
if intent in {"billing_contestation", "billing_second_copy"}:
return "WAITING_BILLING_CONFIRMATION"
if intent in {"service_plan_information", "vas_services_information"}:
return "WAITING_SERVICE_CONFIRMATION"
return "CONTAS_ACTIVE"

15
app/agents/prompting.py Normal file
View File

@@ -0,0 +1,15 @@
from __future__ import annotations
def apply_agent_profile_prompt(state: dict, default_prompt: str) -> str:
"""Adiciona o prefixo de prompt configurado para o agent_template selecionado.
Cada agent_id pode definir metadata.system_prefix em config/agents.yaml. Isso
mantém prompts isolados sem duplicar o código dos agentes especializados.
"""
profile = state.get("agent_profile") or (state.get("context") or {}).get("agent_profile") or {}
metadata = profile.get("metadata") or {}
prefix = (metadata.get("system_prefix") or "").strip()
if not prefix:
return default_prompt
return f"{prefix}\n\n{default_prompt}"

7
app/agents/runtime.py Normal file
View File

@@ -0,0 +1,7 @@
from __future__ import annotations
# Compatibilidade local do template/backend.
# A implementação oficial agora fica no framework para evitar duplicação entre agentes.
from agent_framework.runtime import AgentRuntimeMixin, MessageBuilder, RuntimeContext
__all__ = ["AgentRuntimeMixin", "MessageBuilder", "RuntimeContext"]

1
app/examples/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Exemplos de uso do template backend enterprise."""

View File

@@ -0,0 +1,37 @@
"""Exemplos de GRL.
GRL representa eventos de guardrails. Em regra, GRL.001..GRL.009 são emitidos
pelo pipeline de guardrails e pelo OutputSupervisor do framework. Use emissão
manual apenas para validações customizadas do agente.
"""
from typing import Any
async def exemplo_guardrail_observado(observer: Any, state: dict[str, Any], rail_code: str, reason: str) -> None:
await observer.emit_grl(
"OBSERVE",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"rail_code": rail_code,
"reason": reason,
},
component="examples.grl",
)
async def exemplo_guardrail_block(observer: Any, state: dict[str, Any], rail_code: str, reason: str) -> None:
await observer.emit_grl(
"004",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"rail_code": rail_code,
"reason": reason,
"action": "block",
},
component="examples.grl",
)

View File

@@ -0,0 +1,34 @@
"""Exemplos de IC - Item de Controle.
ICs representam eventos de negócio. Eles alimentam Informacional, Curadoria,
analytics, BigQuery ou qualquer publisher configurado no framework.
"""
from typing import Any
async def exemplo_fatura_consultada(observer: Any, state: dict[str, Any], invoice_id: str) -> None:
await observer.emit_ic(
"IC.FATURA_CONSULTADA",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"invoice_id": invoice_id,
},
component="examples.ic",
)
async def exemplo_acao_concluida(observer: Any, state: dict[str, Any], action_name: str, ok: bool) -> None:
await observer.emit_ic(
"IC.ACAO_CONCLUIDA",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"action_name": action_name,
"ok": ok,
},
component="examples.ic",
)

View File

@@ -0,0 +1,43 @@
"""Exemplos de MCP + IC.
O AgentRuntimeMixin já possui _collect_mcp_context(), mas este arquivo mostra o
padrão para chamadas explícitas ao tool_router quando necessário.
"""
from typing import Any
async def exemplo_chamada_mcp(tool_router: Any, observer: Any, state: dict[str, Any], tool_name: str, payload: dict[str, Any]) -> Any:
session_id = state.get("conversation_key") or state.get("session_id")
await observer.emit_ic(
"IC.MCP_TOOL_CALLED",
{
"session_id": session_id,
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"tool_name": tool_name,
},
component="examples.mcp",
)
result = await tool_router.call(
tool_name,
payload,
business_context=(state.get("context") or {}).get("business_context") or {},
original_context=state.get("context") or {},
)
await observer.emit_ic(
"IC.TOOL_CALLED",
{
"session_id": session_id,
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"tool_name": tool_name,
"ok": getattr(result, "ok", None),
},
component="examples.mcp",
)
return result

View File

@@ -0,0 +1,37 @@
"""Exemplos de NOC.
NOC representa telemetria operacional. O workflow do template já emite NOC.001,
NOC.005 e NOC.006. Estes exemplos mostram eventos adicionais que a squad pode
emitir em pontos críticos.
"""
from typing import Any
async def exemplo_api_invalida(observer: Any, state: dict[str, Any], api_url: str, status_code: int, latency_ms: int) -> None:
await observer.emit_noc(
"002",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"apiUrl": api_url,
"statusCode": status_code,
"latencyMs": latency_ms,
},
component="examples.noc",
)
async def exemplo_latencia_banco(observer: Any, state: dict[str, Any], resource_name: str, latency_ms: int) -> None:
await observer.emit_noc(
"003",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"resourceName": resource_name,
"latencyMs": latency_ms,
},
component="examples.noc",
)

View File

@@ -0,0 +1,28 @@
"""Resumo prático do Observer corporativo.
Use este arquivo como cola rápida para IC, NOC e GRL.
"""
from typing import Any
async def emitir_eventos_basicos(observer: Any, state: dict[str, Any]) -> None:
session_id = state.get("conversation_key") or state.get("session_id")
await observer.emit_ic(
"IC.EXEMPLO_NEGOCIO",
{"session_id": session_id, "agent_id": state.get("agent_id")},
component="examples.observer",
)
await observer.emit_noc(
"EXEMPLO_OPERACIONAL",
{"session_id": session_id, "agent_id": state.get("agent_id")},
component="examples.observer",
)
await observer.emit_grl(
"OBSERVE",
{"session_id": session_id, "agent_id": state.get("agent_id"), "rail_code": "CUSTOM"},
component="examples.observer",
)

476
app/main.py Normal file
View File

@@ -0,0 +1,476 @@
from __future__ import annotations
import logging
from uuid import uuid4
import time
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from agent_framework.channels.base import ChannelResponse
from agent_framework.channels.gateway import ChannelGateway
from agent_framework.config.agent_registry import AgentProfileRegistry
from agent_framework.config.settings import settings
from agent_framework.analytics.factory import create_analytics_publisher
from agent_framework.observer import configure as configure_global_observer
from agent_framework.llm.providers import create_llm
from agent_framework.memory.message_history import create_memory
from agent_framework.memory.summary_memory import create_conversation_summary_memory
from agent_framework.mcp.tool_router import create_mcp_tool_router
from agent_framework.models.identity import AgentIdentity
from agent_framework.identity import IdentityResolver, BusinessContext
from agent_framework.models.session import ChatMessage, SessionContext
from agent_framework.observability.telemetry import Telemetry
from agent_framework.observability.context import set_observability_context, clear_observability_context
from agent_framework.repositories.session_repository import create_session_repository
from agent_framework.checkpoints.checkpoint_repository import create_checkpoint_repository
from agent_framework.cache.cache import create_cache
from agent_framework.billing.usage_repository import create_usage_repository
from agent_framework.sse.events import SSEHub
from app.workflows.agent_graph import AgentWorkflow
from app.observability.telemetry_observer import TelemetryBackedAgentObserver
logging.basicConfig(level=settings.LOG_LEVEL)
logger = logging.getLogger("agent_contas_first_migrated")
app = FastAPI(title="Agent Contas FIRST Migrado")
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",")],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
telemetry = Telemetry(settings)
usage_repository = create_usage_repository(settings)
llm = create_llm(settings, telemetry=telemetry, usage_repository=usage_repository)
memory = create_memory(settings)
summary_memory = create_conversation_summary_memory(settings, message_history=memory, llm=llm, telemetry=telemetry)
sessions = create_session_repository(settings)
checkpoints = create_checkpoint_repository(settings)
cache = create_cache(settings, telemetry=telemetry)
gateway = ChannelGateway(input_mode=settings.FRAMEWORK_CHANNEL_INPUT_MODE)
analytics = create_analytics_publisher(settings)
observer = TelemetryBackedAgentObserver(telemetry=telemetry)
configure_global_observer({
"enabled": getattr(settings, "ENABLE_ANALYTICS", False),
"providers": getattr(settings, "ANALYTICS_PROVIDERS", "oci_streaming"),
"topic_path": getattr(settings, "GCP_PUBSUB_TOPIC_PATH", None) or getattr(settings, "AGENT_PUBSUB_TOPIC", None),
})
tool_router = create_mcp_tool_router(settings, telemetry=telemetry)
identity_resolver = IdentityResolver.from_yaml(settings.IDENTITY_CONFIG_PATH)
agent_profiles = AgentProfileRegistry(settings)
sse_hub = SSEHub(settings, telemetry=telemetry)
workflow = AgentWorkflow(llm, memory, telemetry, analytics, settings, observer=observer, tool_router=tool_router, summary_memory=summary_memory)
logger.info("LLM provider carregado: %s", llm.__class__.__name__)
logger.info("Langfuse habilitado: %s host=%s", telemetry.is_enabled(), settings.LANGFUSE_HOST)
logger.info("Analytics habilitado: %s providers=%s", getattr(settings, "ENABLE_ANALYTICS", False), getattr(settings, "ANALYTICS_PROVIDERS", ""))
logger.info("Agentes disponíveis: %s", [p.agent_id for p in agent_profiles.list_profiles()])
logger.info("Framework channel input mode: %s", gateway.input_mode)
@app.middleware("http")
async def observability_context_middleware(request: Request, call_next):
request_id = request.headers.get("x-request-id") or str(uuid4())
set_observability_context(
request_id=request_id,
channel=request.headers.get("x-channel") or "http",
ura_call_id=request.headers.get("x-ura-call-id"),
)
started = time.time()
try:
response = await call_next(request)
response.headers["x-request-id"] = request_id
await telemetry.event("http.request.completed", {
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": int((time.time() - started) * 1000),
}, kind="http")
return response
except Exception as exc:
await telemetry.event("http.request.failed", {
"method": request.method,
"path": request.url.path,
"error": str(exc),
"duration_ms": int((time.time() - started) * 1000),
}, kind="http")
raise
class GatewayRequest(BaseModel):
channel: str = "web"
payload: dict
agent_id: str | None = None
tenant_id: str | None = None
def _resolve_identity(req: GatewayRequest, msg) -> tuple[AgentIdentity, dict, BusinessContext, list[str]]:
payload = req.payload or {}
context = dict(msg.context or {})
tenant_id = req.tenant_id or payload.get("tenant_id") or context.get("tenant_id") or "default"
agent_id = req.agent_id or payload.get("agent_id") or context.get("agent_id") or agent_profiles.default_agent_id
profile = agent_profiles.get(agent_id)
# 1) Identidade técnica do framework: isola tenant/agente/sessão.
context.update({"tenant_id": tenant_id, "agent_id": profile.agent_id, "agent_profile": profile.__dict__})
identity = AgentIdentity.from_context(context, session_id=msg.session_id)
# 2) Identidade de negócio: chaves canônicas vindas do front/canal.
# Estas chaves são estáveis na sessão e seguem até agentes e MCP Router.
previous_business_context = context.get("business_context") or context.get("identity") or {}
business_context = identity_resolver.resolve(
{**payload, **context},
session_id=identity.conversation_key(),
previous=previous_business_context,
)
missing_identity_keys = identity_resolver.validate(business_context)
context.update({
"business_context": business_context.model_dump(),
"business_keys": business_context.to_context_dict(),
"identity_missing": missing_identity_keys,
"conversation_key": identity.conversation_key(),
"original_session_id": msg.session_id,
})
return identity, context, business_context, missing_identity_keys
async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False) -> dict:
try:
msg = await gateway.normalize(req.channel, req.payload)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
identity, normalized_context, business_context, missing_identity_keys = _resolve_identity(req, msg)
agent_session_id = identity.conversation_key()
message_id = (req.payload or {}).get("message_id") or str(uuid4())
set_observability_context(
session_id=agent_session_id,
user_id=msg.user_id,
tenant_id=identity.tenant_id,
agent_id=identity.agent_id,
channel=msg.channel,
message_id=message_id,
ura_call_id=(req.payload or {}).get("ura_call_id") or normalized_context.get("ura_call_id") or business_context.interaction_key,
)
stream = sse_hub.stream_for(agent_session_id)
async with stream.lock:
await sse_hub.emit(agent_session_id, "flow.start", {"session_id": agent_session_id, "message_id": message_id, "agent_id": identity.agent_id}) if emit_sse else None
session = await sessions.get(agent_session_id)
if not session:
context_fields = {
k: v
for k, v in normalized_context.items()
if k in SessionContext.model_fields
and k not in {"tenant_id", "agent_id", "session_id", "user_id", "channel", "channel_id"}
}
session = SessionContext(
tenant_id=identity.tenant_id,
agent_id=identity.agent_id,
session_id=agent_session_id,
user_id=msg.user_id,
channel=msg.channel,
channel_id=msg.channel_id,
**context_fields,
)
session.tenant_id = identity.tenant_id
session.agent_id = identity.agent_id
session.channel = msg.channel
session.channel_id = msg.channel_id or session.channel_id
await sessions.upsert(session)
session.metadata = {
**(session.metadata or {}),
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
"original_context": normalized_context,
}
await sse_hub.emit(agent_session_id, "session.upserted", {"session_id": agent_session_id, "business_context": business_context.model_dump()}) if emit_sse else None
await memory.append(
agent_session_id,
ChatMessage(
role="user",
content=msg.text,
metadata={
**normalized_context,
"agent_id": identity.agent_id,
"tenant_id": identity.tenant_id,
"message_id": message_id,
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
},
),
)
await sse_hub.emit(agent_session_id, "message.received", {"session_id": agent_session_id, "role": "user"}) if emit_sse else None
history = [m.model_dump(mode="json") for m in await memory.list(agent_session_id)]
trace_input = {
"text": msg.text,
"channel": msg.channel,
"channel_id": msg.channel_id,
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"conversation_key": agent_session_id,
"message_id": message_id,
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
}
async with telemetry.span(
"agent.gateway_message",
session_id=agent_session_id,
user_id=session.user_id,
channel=msg.channel,
input=trace_input,
tags=["agent-template", msg.channel, f"agent:{identity.agent_id}", f"tenant:{identity.tenant_id}"],
):
await telemetry.event("gateway.message.received", trace_input)
await sse_hub.emit(agent_session_id, "workflow.started", trace_input) if emit_sse else None
result = await workflow.ainvoke(
{
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"session_id": agent_session_id,
"conversation_key": agent_session_id,
"agent_profile": normalized_context["agent_profile"],
"user_text": msg.text,
"history": history,
"context": {
**normalized_context,
"session": session.model_dump(mode="json"),
"original_session_id": msg.session_id,
"session_id": agent_session_id,
"conversation_key": agent_session_id,
"user_id": session.user_id,
"channel": msg.channel,
"message_id": message_id,
"business_context": business_context.model_dump(),
"business_keys": business_context.to_context_dict(),
"identity_missing": missing_identity_keys,
},
}
)
await checkpoints.put(agent_session_id, {"state": result, "message_id": message_id})
await sse_hub.emit(agent_session_id, "workflow.completed", {"session_id": agent_session_id, "route": result.get("route"), "intent": result.get("intent")}) if emit_sse else None
answer = result.get("final_answer") or result.get("answer") or ""
await memory.append(
agent_session_id,
ChatMessage(
role="assistant",
content=answer,
metadata={
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"message_id": f"assistant-{message_id}",
"route": result.get("route"),
"intent": result.get("intent"),
"route_decision": result.get("route_decision"),
"judges": result.get("judge_results"),
},
),
)
await telemetry.event(
"gateway.message.responded",
{
"session_id": agent_session_id,
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"route": result.get("route"),
"intent": result.get("intent"),
"answer_chars": len(answer),
},
)
response = ChannelResponse(
channel=msg.channel,
session_id=agent_session_id,
text=answer,
metadata={
"channel_id": msg.channel_id,
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"original_session_id": msg.session_id,
"conversation_key": agent_session_id,
"message_id": message_id,
"route": result.get("route"),
"intent": result.get("intent"),
"route_decision": result.get("route_decision"),
"domain": result.get("domain"),
"mcp_tools": result.get("mcp_tools"),
"mcp_results": result.get("mcp_results"),
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
"judges": result.get("judge_results"),
"guardrails": result.get("guardrail_decisions"),
},
)
rendered = await gateway.render(response)
await sse_hub.emit(agent_session_id, "message.responded", rendered) if emit_sse else None
await sse_hub.emit(agent_session_id, "flow.end", {"session_id": agent_session_id, "message_id": message_id}) if emit_sse else None
return rendered
@app.get("/health")
async def health():
return {
"status": "ok",
"llm_provider": settings.LLM_PROVIDER,
"llm_class": llm.__class__.__name__,
"langfuse_enabled": telemetry.is_enabled(),
"agents": [p.agent_id for p in agent_profiles.list_profiles()],
"default_agent_id": agent_profiles.default_agent_id,
"routing_mode": settings.ROUTING_MODE,
"sse_enabled": settings.ENABLE_SSE,
"session_repository": settings.SESSION_REPOSITORY_PROVIDER,
"memory_repository": settings.MEMORY_REPOSITORY_PROVIDER,
"checkpoint_repository": settings.CHECKPOINT_REPOSITORY_PROVIDER,
"usage_repository": settings.USAGE_REPOSITORY_PROVIDER,
"identity_config_path": settings.IDENTITY_CONFIG_PATH,
"mcp_parameter_mapping_path": settings.MCP_PARAMETER_MAPPING_PATH,
"framework_channel_input_mode": settings.FRAMEWORK_CHANNEL_INPUT_MODE,
"legacy_channel_gateway_mode": settings.CHANNEL_GATEWAY_MODE,
}
@app.get("/agents")
async def list_agents():
return {"default_agent_id": agent_profiles.default_agent_id, "agents": [p.__dict__ for p in agent_profiles.list_profiles()]}
@app.get("/debug/env")
async def debug_env():
return {
"APP_ENV": settings.APP_ENV,
"LLM_PROVIDER": settings.LLM_PROVIDER,
"ENABLE_LANGFUSE": settings.ENABLE_LANGFUSE,
"LANGFUSE_HOST": settings.LANGFUSE_HOST,
"TELEMETRY_ENABLED": telemetry.is_enabled(),
"SQLITE_DB_PATH": settings.SQLITE_DB_PATH,
"SESSION_REPOSITORY_PROVIDER": settings.SESSION_REPOSITORY_PROVIDER,
"MEMORY_REPOSITORY_PROVIDER": settings.MEMORY_REPOSITORY_PROVIDER,
"CHECKPOINT_REPOSITORY_PROVIDER": settings.CHECKPOINT_REPOSITORY_PROVIDER,
"AGENTS_CONFIG_PATH": settings.AGENTS_CONFIG_PATH,
"ROUTING_CONFIG_PATH": settings.ROUTING_CONFIG_PATH,
"ROUTING_MODE": settings.ROUTING_MODE,
"FRAMEWORK_CHANNEL_INPUT_MODE": settings.FRAMEWORK_CHANNEL_INPUT_MODE,
"CHANNEL_GATEWAY_MODE": settings.CHANNEL_GATEWAY_MODE,
}
@app.get("/test-llm")
async def test_llm():
async with telemetry.span("debug.test_llm", input={"message": "Diga apenas OK"}):
answer = await llm.ainvoke([
{"role": "system", "content": "Responda de forma curta."},
{"role": "user", "content": "Diga apenas OK"},
])
telemetry.flush()
return {"provider": llm.__class__.__name__, "answer": answer}
@app.post("/debug/route")
async def debug_route(req: GatewayRequest):
msg = await gateway.normalize(req.channel, req.payload)
identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg)
state = {
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"session_id": msg.session_id or "debug-session",
"conversation_key": identity.conversation_key(),
"agent_profile": context["agent_profile"],
"user_text": msg.text,
"sanitized_input": msg.text,
"history": [],
"context": {**context, "session": context.get("session", {}), "channel": msg.channel, "business_context": business_context.model_dump()},
}
if settings.ROUTING_MODE == "supervisor":
plan = await workflow.supervisor.route_plan(state)
return {"mode": "supervisor", "route": "supervisor_agent", "agents": plan.agents, "intent": plan.intent, "confidence": plan.confidence, "reason": plan.reason, "metadata": plan.metadata}
decision = await workflow.router.route(state)
data = decision.model_dump(mode="json")
data["mode"] = "router"
return data
@app.post("/debug/identity")
async def debug_identity(req: GatewayRequest):
msg = await gateway.normalize(req.channel, req.payload)
identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg)
return {
"technical_identity": {
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"conversation_key": identity.conversation_key(),
"original_session_id": msg.session_id,
},
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
"context_keys": sorted(context.keys()),
}
@app.get("/debug/usage")
async def debug_usage(tenant_id: str | None = None, session_id: str | None = None):
return await usage_repository.summarize(tenant_id=tenant_id, session_id=session_id)
@app.get("/debug/mcp/tools")
async def debug_mcp_tools():
return {"enabled": tool_router.enabled, "tools": tool_router.describe_tools()}
@app.post("/debug/mcp/call/{tool_name}")
async def debug_mcp_call(tool_name: str, arguments: dict | None = None):
arguments = arguments or {}
ctx = arguments.get("business_context") or arguments.get("identity") or {}
result = await tool_router.call(
tool_name,
arguments,
business_context=ctx,
original_context=arguments,
)
return result.model_dump(mode="json")
@app.post("/gateway/message")
async def gateway_message(req: GatewayRequest):
return await _process_gateway_message(req, emit_sse=False)
@app.post("/gateway/message/sse")
async def gateway_message_sse(req: GatewayRequest):
return await _process_gateway_message(req, emit_sse=True)
@app.get("/gateway/events/{session_id}")
async def gateway_events(session_id: str, request: Request):
last = request.headers.get("last-event-id") or request.query_params.get("last_event_id") or "0"
return StreamingResponse(
sse_hub.subscribe(session_id, int(last)),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
)
@app.get("/sessions/{session_id}/messages")
async def get_session_messages(session_id: str, limit: int = 50):
return {"session_id": session_id, "messages": [m.model_dump(mode="json") for m in await memory.list(session_id, limit)]}
@app.get("/sessions/{session_id}/checkpoint")
async def get_session_checkpoint(session_id: str):
return {"session_id": session_id, "checkpoint": await checkpoints.get_latest(session_id)}
@app.on_event("shutdown")
async def shutdown():
telemetry.shutdown()

View File

Binary file not shown.

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
"""Observer adapter that emits IC/NOC/GRL through framework Telemetry only.
This avoids a second Langfuse root trace created by AgentObserver ->
AnalyticsPublisher while preserving the events inside the active request span.
"""
from datetime import datetime, timezone
from typing import Any
def _normalize_ic_code(code: str) -> str:
code = str(code or "UNKNOWN").strip()
return code if code.startswith(("IC.", "AGA.", "NOC.", "GRL.")) else f"IC.{code}"
def _normalize_noc_code(code: str) -> str:
code = str(code or "UNKNOWN").strip()
return code if code.startswith("NOC.") else f"NOC.{code}"
def _normalize_grl_code(code: str) -> str:
code = str(code or "UNKNOWN").strip()
return code if code.startswith("GRL.") else f"GRL.{code}"
def _kind_for(event_type: str) -> str:
if event_type.startswith(("IC.", "AGA.")):
return "ic"
if event_type.startswith("NOC."):
return "noc"
if event_type.startswith("GRL."):
return "grl"
return "event"
class TelemetryBackedAgentObserver:
"""Drop-in subset of AgentObserver backed by Telemetry.event.
Do not publish through AnalyticsPublisher here. Analytics publishing may be
configured with a Langfuse provider, and that path creates an extra root
trace for business events such as IC.AGENT_COMPLETED/NOC.006. Telemetry.event
uses the active span/trace context, so these events appear inside the single
request trace.
"""
def __init__(self, telemetry: Any, *, source: str = "agent_framework") -> None:
self.telemetry = telemetry
self.source = source
async def emit(
self,
event_type: str,
payload: dict[str, Any] | None = None,
*,
metadata: dict[str, Any] | None = None,
source: str | None = None,
) -> dict[str, Any]:
body = dict(payload or {})
meta = dict(metadata or {})
body.setdefault("tag", event_type)
event = {
"eventType": event_type,
"source": source or self.source,
"eventDate": datetime.now(timezone.utc).isoformat(),
"body": body,
"metadata": meta,
}
try:
await self.telemetry.event(event_type, event, kind=_kind_for(event_type))
except TypeError:
# Compatibility with older Telemetry.event signatures.
await self.telemetry.event(event_type, event)
return event
async def emit_ic(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
return await self.emit(_normalize_ic_code(code), payload, metadata={**metadata, "ic": True})
async def emit_noc(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
return await self.emit(_normalize_noc_code(code), payload, metadata={**metadata, "noc": True})
async def emit_grl(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
return await self.emit(_normalize_grl_code(code), payload, metadata={**metadata, "grl": True})

34
app/state.py Normal file
View File

@@ -0,0 +1,34 @@
from typing import Any, TypedDict
class AgentState(TypedDict, total=False):
tenant_id: str
agent_id: str
session_id: str
conversation_key: str
agent_profile: dict[str, Any]
user_text: str
sanitized_input: str
route: str
intent: str
route_decision: dict[str, Any]
answer: str
final_answer: str
history: list[dict[str, Any]]
context: dict[str, Any]
guardrail_decisions: list[dict[str, Any]]
judge_results: list[dict[str, Any]]
next_state: str
domain: str
mcp_tools: list[str]
mcp_results: list[dict[str, Any]]
supervisor_plan: dict[str, Any]
supervisor_results: list[dict[str, Any]]
active_agent: str
blocked: bool
supervisor_action: str
supervisor_guidance: str
supervisor_attempt: int
supervisor_handover_reason: str
output_supervisor_results: list[dict[str, Any]]
output_guardrails_already_applied: bool

BIN
app/workflows/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,192 @@
from __future__ import annotations
from typing import Any
from langgraph.graph import END, START, StateGraph
from agent_framework.cache.cache import create_cache
from agent_framework.checkpoints.langgraph_saver import create_langgraph_checkpointer
from agent_framework.guardrails.pipeline import GuardrailPipeline
from agent_framework.guardrails.output_supervisor import OutputSupervisor
from agent_framework.guardrails.rail_action import RailAction
from agent_framework.judges.judge import JudgePipeline
from agent_framework.observability.guardrail_events import GuardrailTelemetry
from agent_framework.observability.judge_events import JudgeTelemetry
from agent_framework.observability.langgraph_telemetry import LangGraphDeepTelemetry
from agent_framework.observability.workflow_events import WorkflowTelemetry
from agent_framework.rag.embedding_provider import create_embedding_provider
from agent_framework.rag.rag_service import RagService
from agent_framework.routing.enterprise_router import EnterpriseRouter
from agent_framework.supervisor.supervisor import Supervisor
from agent_framework.observer import AgentObserver
from app.agents.contas_agent import ContasAgent
from app.state import AgentState
class AgentWorkflow:
"""Workflow LangGraph do agent_contas_first migrado.
O desenho é propositalmente fino: a orquestração corporativa fica no
framework. O agente executa apenas o domínio Contas; guardrails, output
supervisor, judges, RAG, MCP, checkpoints e observabilidade são nativos.
"""
def __init__(self, llm, memory, telemetry, analytics, settings, observer: AgentObserver | None = None, tool_router=None, summary_memory=None):
self.llm = llm
self.memory = memory
self.telemetry = telemetry
self.analytics = analytics
self.observer = observer or AgentObserver(analytics=analytics)
self.settings = settings
self.tool_router = tool_router
self.summary_memory = summary_memory
self.guardrails = GuardrailPipeline(
observer=self.observer,
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
)
self.output_supervisor_engine = OutputSupervisor(
observer=self.observer,
max_retries=int(getattr(settings, "OUTPUT_SUPERVISOR_MAX_RETRIES", 3)),
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
)
self.judges = JudgePipeline(llm=llm, settings=settings)
self.supervisor = Supervisor()
self.router = EnterpriseRouter(settings, llm=llm, telemetry=telemetry)
self.workflow_telemetry = WorkflowTelemetry(telemetry)
self.guardrail_telemetry = GuardrailTelemetry(telemetry)
self.judge_telemetry = JudgeTelemetry(telemetry)
self.langgraph_telemetry = LangGraphDeepTelemetry(telemetry)
self.cache = create_cache(settings)
self.embedding_provider = create_embedding_provider(settings)
self.rag_service = RagService(settings, embedding_provider=self.embedding_provider, telemetry=telemetry, llm=llm)
agent_kwargs = {
"telemetry": telemetry,
"tool_router": tool_router,
"rag_service": self.rag_service,
"cache": self.cache,
"settings": settings,
"observer": self.observer,
"memory": memory,
"summary_memory": summary_memory,
}
self.contas = ContasAgent(llm, **agent_kwargs)
self.graph = self._build_graph()
def _node(self, name, fn):
async def _wrapped(state):
async with self.langgraph_telemetry.node(name, state):
return await fn(state)
return _wrapped
def _build_graph(self):
builder = StateGraph(AgentState)
builder.add_node("input_guardrails", self._node("input_guardrails", self.input_guardrails))
builder.add_node("routing_decision", self._node("routing_decision", self.routing_decision))
builder.add_node("contas_agent", self._node("contas_agent", self.contas_agent))
builder.add_node("output_supervisor", self._node("output_supervisor", self.output_supervisor))
builder.add_node("output_guardrails", self._node("output_guardrails", self.output_guardrails))
builder.add_node("judge", self._node("judge", self.judge))
builder.add_node("supervisor_review", self._node("supervisor_review", self.supervisor_review))
builder.add_node("persist", self._node("persist", self.persist))
builder.add_edge(START, "input_guardrails")
builder.add_conditional_edges("input_guardrails", lambda s: "blocked" if s.get("blocked") else "continue", {"blocked": "persist", "continue": "routing_decision"})
builder.add_edge("routing_decision", "contas_agent")
builder.add_edge("contas_agent", "output_supervisor")
builder.add_edge("output_supervisor", "output_guardrails")
builder.add_edge("output_guardrails", "judge")
builder.add_edge("judge", "supervisor_review")
builder.add_edge("supervisor_review", "persist")
builder.add_edge("persist", END)
return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings))
async def input_guardrails(self, state: AgentState) -> dict[str, Any]:
session_id = state.get("conversation_key") or state.get("session_id")
async with self.telemetry.span("workflow.input_guardrails", session_id=session_id, input=state.get("user_text")):
history_texts = [m.get("content", "") for m in state.get("history", [])]
await self.observer.emit_grl("001", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "phase": "input"}, component="workflow.input_guardrails.start")
sanitized, decisions = await self.guardrails.run_input(
state["user_text"],
{**(state.get("context") or {}), "history_texts": history_texts, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "agent_profile": state.get("agent_profile") or {}},
)
for decision in decisions:
await self.guardrail_telemetry.evaluated("input", decision)
await self.observer.emit_grl("002" if decision.allowed else "004", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "phase": "input", "rail_code": getattr(decision, "code", None), "allowed": bool(decision.allowed), "reason": getattr(decision, "reason", None)}, component="workflow.input_guardrails.decision")
if not decision.allowed:
await self.guardrail_telemetry.blocked("input", decision)
blocked = any(not d.allowed for d in decisions)
await self.observer.emit_grl("009", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "phase": "input", "blocked": blocked, "decision_count": len(decisions)}, component="workflow.input_guardrails.final")
if blocked:
return {"sanitized_input": sanitized, "answer": "Não consegui seguir com essa mensagem por regra de segurança.", "final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", "guardrail_decisions": [d.model_dump() for d in decisions], "route": "blocked", "blocked": True}
return {"sanitized_input": sanitized, "guardrail_decisions": [d.model_dump() for d in decisions], "blocked": False}
async def routing_decision(self, state: AgentState) -> dict[str, Any]:
session_id = state.get("conversation_key") or state.get("session_id")
async with self.telemetry.span("workflow.routing_decision", session_id=session_id, input={"text": state.get("sanitized_input") or state.get("user_text")}):
decision = await self.router.route({**state, "route": "contas_agent"})
await self.langgraph_telemetry.edge("routing_decision", "contas_agent", state, {"method": getattr(decision, "method", None), "intent": decision.intent, "confidence": decision.confidence})
await self.observer.emit_ic("IC.CONTAS_ROUTE_SELECTED", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "route": "contas_agent", "intent": decision.intent, "confidence": decision.confidence, "method": getattr(decision, "method", None), "mcp_tools": decision.mcp_tools}, component="workflow.routing_decision")
return {"route": "contas_agent", "intent": decision.intent, "route_decision": decision.model_dump(mode="json"), "domain": decision.domain, "mcp_tools": decision.mcp_tools, "next_state": decision.next_state}
async def contas_agent(self, state: AgentState) -> dict[str, Any]:
async with self.telemetry.span("workflow.agent.contas", session_id=state.get("conversation_key") or state.get("session_id"), input={"intent": state.get("intent")}):
return await self.contas.run(state)
async def output_supervisor(self, state: AgentState) -> dict[str, Any]:
if not bool(getattr(self.settings, "ENABLE_OUTPUT_SUPERVISOR", True)):
return {"output_guardrails_already_applied": False, "supervisor_action": "disabled", "supervisor_attempt": int(state.get("supervisor_attempt", 0))}
session_id = state.get("conversation_key") or state.get("session_id")
candidate = state.get("answer") or ""
context = {**(state.get("context") or {}), "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "session_id": session_id, "route": state.get("route"), "intent": state.get("intent")}
async with self.telemetry.span("workflow.output_supervisor", session_id=session_id, input=candidate):
decision = await self.output_supervisor_engine.evaluate(candidate, context)
action = decision.action.value
await self.observer.emit_ic("IC.OUTPUT_SUPERVISOR_COMPLETED", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "route": state.get("route"), "intent": state.get("intent"), "action": action, "approved": decision.approved, "result_count": len(decision.results)}, component="workflow.output_supervisor")
if decision.action in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}:
final_answer = decision.candidate
elif decision.action == RailAction.HANDOVER:
final_answer = "Vou encaminhar seu atendimento para continuidade com um especialista."
else:
final_answer = decision.fallback_message
return {"answer": final_answer, "final_answer": final_answer, "supervisor_action": action, "supervisor_guidance": decision.guidance, "supervisor_attempt": int(state.get("supervisor_attempt", 0)) + (1 if decision.action == RailAction.RETRY else 0), "supervisor_handover_reason": decision.handover_reason, "output_supervisor_results": [{"code": r.code, "action": r.action.value, "reason": r.reason, "guidance": r.guidance, "metadata": r.metadata} for r in decision.results], "output_guardrails_already_applied": True}
async def output_guardrails(self, state: AgentState) -> dict[str, Any]:
if state.get("output_guardrails_already_applied"):
return {"final_answer": state.get("final_answer") or state.get("answer") or ""}
session_id = state.get("conversation_key") or state.get("session_id")
async with self.telemetry.span("workflow.output_guardrails", session_id=session_id, input=state.get("answer")):
await self.observer.emit_grl("001", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "phase": "output", "route": state.get("route"), "intent": state.get("intent")}, component="workflow.output_guardrails.start")
final, decisions = await self.guardrails.run_output(state["answer"], state.get("context", {}))
for decision in decisions:
await self.guardrail_telemetry.evaluated("output", decision)
await self.observer.emit_grl("002" if decision.allowed else "004", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "phase": "output", "rail_code": getattr(decision, "code", None), "allowed": bool(decision.allowed), "reason": getattr(decision, "reason", None)}, component="workflow.output_guardrails.decision")
if not decision.allowed:
await self.guardrail_telemetry.blocked("output", decision)
await self.observer.emit_grl("009", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "phase": "output", "blocked": any(not d.allowed for d in decisions), "decision_count": len(decisions)}, component="workflow.output_guardrails.final")
return {"final_answer": final, "guardrail_decisions": state.get("guardrail_decisions", []) + [d.model_dump() for d in decisions]}
async def judge(self, state: AgentState) -> dict[str, Any]:
async with self.telemetry.span("workflow.judge", session_id=state.get("conversation_key") or state.get("session_id"), input={"question": state.get("user_text"), "answer": state.get("final_answer")}):
results = await self.judges.evaluate_all(state["user_text"], state["final_answer"], {**(state.get("context") or {}), "mcp_results": state.get("mcp_results", []), "rag_context": state.get("rag_context"), "route": state.get("route"), "intent": state.get("intent")})
for result in results:
await self.judge_telemetry.evaluated(result)
return {"judge_results": [r.model_dump() for r in results]}
async def supervisor_review(self, state: AgentState) -> dict[str, Any]:
async with self.telemetry.span("workflow.supervisor_review", session_id=state.get("conversation_key") or state.get("session_id"), input=state.get("final_answer")):
ok, answer = await self.supervisor.review(state["final_answer"], state.get("context", {}))
return {"final_answer": answer if ok else answer}
async def persist(self, state: AgentState) -> dict[str, Any]:
session_id = state.get("conversation_key") or state["session_id"]
async with self.telemetry.span("workflow.persist", session_id=session_id, input={"route": state.get("route"), "intent": state.get("intent")}):
await self.observer.emit_ic("IC.CONTAS_AGENT_PERSISTED", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "route": state.get("route"), "intent": state.get("intent"), "route_decision": state.get("route_decision"), "judges": state.get("judge_results", []), "mcp_tools": state.get("mcp_tools", []), "mcp_results": state.get("mcp_results", [])}, component="workflow.persist")
await self.observer.emit_noc("006", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "route": state.get("route"), "intent": state.get("intent"), "answer_chars": len(state.get("final_answer") or "")}, component="workflow.persist")
await self.telemetry.event("agent.completed", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "route": state.get("route"), "intent": state.get("intent"), "answer_chars": len(state.get("final_answer") or "")})
return {"final_answer": state.get("final_answer") or state.get("answer") or ""}
async def ainvoke(self, state: AgentState, config: dict[str, Any] | None = None):
return await self.graph.ainvoke(state, config=config or {"configurable": {"thread_id": state.get("conversation_key") or state.get("session_id")}})

7
backend_mock/README.md Normal file
View File

@@ -0,0 +1,7 @@
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app:app --host 0.0.0.0 --port 8001 --reload

Binary file not shown.

178
backend_mock/app.py Normal file
View File

@@ -0,0 +1,178 @@
from fastapi import FastAPI, Header
from typing import Optional
app = FastAPI(title="TIM Mock Backend")
# ------------------------------------------------------------------
# COMPLETE INVOICES
# ------------------------------------------------------------------
@app.post("/customers/v1/completeInvoices")
async def complete_invoices(
payload: dict,
authorization: Optional[str] = Header(None),
):
return {
"ok": True,
"msisdn": payload.get("msisdn"),
"invoices": [
{
"invoiceId": "FAT123456",
"amount": 189.90,
"dueDate": "2026-06-25",
"status": "OPEN"
}
]
}
# ------------------------------------------------------------------
# PERFIL FATURA
# ------------------------------------------------------------------
@app.post("/customers/v1/completeInvoices/profile")
async def profile_bill(payload: dict):
return {
"ok": True,
"customer": {
"name": "Cliente Teste",
"msisdn": payload.get("msisdn")
}
}
# ------------------------------------------------------------------
# CONTRATO
# ------------------------------------------------------------------
@app.post("/access/v1/contractInformation")
async def contract_information(payload: dict):
return {
"ok": True,
"contractId": "CTR001",
"plan": "TIM Black Família"
}
# ------------------------------------------------------------------
# CONSULTA VAS
# ------------------------------------------------------------------
@app.post("/access/v1/productsSingleVas")
async def query_vas(payload: dict):
return {
"ok": True,
"products": [
{
"id": "VAS001",
"name": "TIM Banca",
"status": "ACTIVE"
},
{
"id": "VAS002",
"name": "Babbel",
"status": "ACTIVE"
}
]
}
# ------------------------------------------------------------------
# CANCELAMENTO VAS
# ------------------------------------------------------------------
@app.delete("/access/v1/singleVas")
async def cancel_vas(payload: dict):
return {
"ok": True,
"message": "VAS cancelado com sucesso"
}
# ------------------------------------------------------------------
# BLOQUEIO VAS
# ------------------------------------------------------------------
@app.post("/customers/v1/createPartialBlocking")
async def block_vas(payload: dict):
return {
"ok": True,
"operation": "block",
"status": "SUCCESS"
}
# ------------------------------------------------------------------
# RECUPERAR PDF FATURA
# ------------------------------------------------------------------
@app.post("/invoices/v1/invoiceRecover")
async def invoice_recover(payload: dict):
return {
"ok": True,
"url": "https://mock.tim.com/faturas/FAT123456.pdf"
}
# ------------------------------------------------------------------
# PROTOCOLO / SR
# ------------------------------------------------------------------
@app.post("/customers/v1/backOfficeSRopening")
async def protocol(payload: dict):
return {
"ok": True,
"protocol": "202606150001"
}
# ------------------------------------------------------------------
# CONTESTAÇÃO
# ------------------------------------------------------------------
@app.post("/interactions/v1/customerContestation")
async def contestation(payload: dict):
return {
"ok": True,
"protocol": "CNT987654",
"status": "OPEN"
}
# ------------------------------------------------------------------
# STATUS SR
# ------------------------------------------------------------------
@app.post("/interactions/v1/statusServiceRequest")
async def sr_status(payload: dict):
return {
"ok": True,
"protocol": payload.get("protocol"),
"status": "IN_PROGRESS"
}
# ------------------------------------------------------------------
# SMS
# ------------------------------------------------------------------
@app.post("/customers/v1/smsSend")
async def sms_send(payload: dict):
return {
"ok": True,
"messageId": "SMS001"
}
# ------------------------------------------------------------------
# TRACKING
# ------------------------------------------------------------------
@app.post("/customers/v1/trackingActivities")
async def tracking(payload: dict):
return {
"ok": True,
"activities": [
{
"date": "2026-06-15",
"description": "Solicitação registrada"
}
]
}
# ------------------------------------------------------------------
# HEALTH
# ------------------------------------------------------------------
@app.get("/health")
async def health():
return {"status": "UP"}

18
config/agents.yaml Normal file
View File

@@ -0,0 +1,18 @@
default_agent_id: contas_first
agents:
- agent_id: contas_first
name: Agent Contas FIRST Migrado
description: Agente de contas/faturas FIRST reimplementado para usar ao máximo o agent_framework_oci.
prompt_policy_path: ./config/agents/contas_first/prompt_policy.yaml
routing_config_path: ./config/routing.yaml
guardrails_config_path: ./config/agents/contas_first/guardrails.yaml
judges_config_path: ./config/agents/contas_first/judges.yaml
mcp_servers_config_path: ./config/mcp_servers.yaml
tools_config_path: ./config/tools.yaml
metadata:
domain: telecom_contas
rag_namespace: contas_first
system_prefix: |
Você está executando o Agent Contas FIRST migrado para o agent_framework_oci.
Use isolamento por tenant/agent/session, BusinessContext canônico,
guardrails, judges, memória, RAG, MCP Router e observabilidade do framework.

View File

@@ -0,0 +1,20 @@
# Guardrails do contas usando o pipeline calibrado nativo do framework.
# Não importar pipeline legado do agent_contas_first.
input:
- code: MSK
enabled: true
- code: VLOOP
enabled: true
- code: PINJ
enabled: true
- code: DLEX_IN
enabled: true
output:
- code: REVPREC
enabled: true
- code: PINJ
enabled: true
- code: DLEX_OUT
enabled: true
- code: RAGSEC
enabled: true

View File

@@ -0,0 +1,16 @@
enabled: true
fail_closed: true
profile: judge
judges:
- name: response_quality
enabled: true
threshold: 0.7
- name: groundedness
enabled: true
threshold: 0.6
- name: sentiment
enabled: true
fail_on_negative: false
- name: tone
enabled: true
fail_closed: true

View File

@@ -0,0 +1,7 @@
id: contas_first_prompt_policy
version: 1
description: Prompt base isolado do agent_contas_first migrado.
system_prefix: |
Você é um agente corporativo de telecom especializado em contas, faturas,
pagamentos, segunda via, contestação, planos e serviços VAS.
Use dados MCP e RAG como evidência. Não invente informação operacional.

12
config/guardrails.yaml Normal file
View File

@@ -0,0 +1,12 @@
input:
- code: MSK
enabled: true
- code: VLOOP
enabled: true
output:
- code: REVPREC
enabled: true
- code: PINJ
enabled: true
- code: DLEX_OUT
enabled: true

52
config/identity.yaml Normal file
View File

@@ -0,0 +1,52 @@
identity:
version: "2"
required:
- session_key
keys:
customer_key:
description: Cliente/assinante canônico. Para telecom, normalmente msisdn.
sources:
- business_context.customer_key
- customer_key
- msisdn
- ani
- from
- user_id
contract_key:
description: Fatura, contrato ou asset principal.
sources:
- business_context.contract_key
- contract_key
- invoice_id
- current_invoice_number
- past_invoice_number
- asset_id
interaction_key:
description: Chave externa da interação/call/chat.
sources:
- business_context.interaction_key
- interaction_key
- ura_call_id
- call_id
- message_id
account_key:
description: Conta de cobrança/conta comercial.
sources:
- business_context.account_key
- account_key
- account_id
- billing_account_id
resource_key:
description: Linha/produto/asset específico.
sources:
- business_context.resource_key
- resource_key
- asset_id
- product_id
session_key:
description: Sessão técnica estável escopada por tenant e agente.
sources:
- business_context.session_key
- session_key
- conversation_key
- session_id

20
config/judges.yaml Normal file
View File

@@ -0,0 +1,20 @@
enabled: true
fail_closed: true
profile: judge
judges:
- name: response_quality
enabled: true
threshold: 0.7
- name: groundedness
enabled: true
threshold: 0.6
- name: sentiment
enabled: true
fail_on_negative: false
- name: tone
enabled: true
fail_closed: true

View File

@@ -0,0 +1,76 @@
mcp_parameter_mapping:
defaults:
use_mock: false
tools:
consultar_fatura:
map:
customer_key: msisdn
contract_key: invoice_id
account_key: customer_id
interaction_key: ura_call_id
session_key: session_id
consultar_pagamentos:
map:
customer_key: msisdn
account_key: customer_id
interaction_key: ura_call_id
session_key: session_id
consultar_plano:
map:
customer_key: msisdn
resource_key: asset_id
account_key: customer_id
session_key: session_id
listar_servicos:
map:
customer_key: msisdn
resource_key: asset_id
session_key: session_id
recuperar_pdf_fatura:
map:
customer_key: msisdn
contract_key: invoice_id
account_key: customer_id
session_key: session_id
registrar_protocolo:
map:
customer_key: msisdn
interaction_key: interaction_call_id
session_key: session_id
abrir_contestacao:
map:
customer_key: msisdn
contract_key: invoice_id
account_key: customer_id
interaction_key: ura_call_id
session_key: session_id
consultar_status_sr:
map:
customer_key: msisdn
interaction_key: protocol_number
session_key: session_id
atualizar_status_sr:
map:
customer_key: msisdn
interaction_key: protocol_number
session_key: session_id
cancelar_vas:
map:
customer_key: msisdn
resource_key: app_id
session_key: session_id
bloquear_vas:
map:
customer_key: msisdn
resource_key: app_id
session_key: session_id
enviar_sms:
map:
customer_key: msisdn
session_key: session_id
registrar_tracking:
map:
customer_key: msisdn
contract_key: invoice_id
interaction_key: protocol_number
session_key: session_id

View File

@@ -0,0 +1,6 @@
servers:
legacy_tim:
transport: http
endpoint: http://legacy-tim-mcp:8100/mcp
enabled: true
description: MCP Server adaptador dos serviços externos legados do Agent Contas FIRST/TIM.

6
config/mcp_servers.yaml Normal file
View File

@@ -0,0 +1,6 @@
servers:
legacy_tim:
transport: http
endpoint: http://localhost:8100/mcp
enabled: true
description: MCP Server adaptador dos serviços externos legados do Agent Contas FIRST/TIM.

View File

@@ -0,0 +1,8 @@
prompt_policy:
default_profile: contas_agent
profiles:
contas_agent:
system_prefix: |
Agent Contas FIRST migrado. Use BusinessContext, MCP, RAG, memória,
guardrails e judges do framework. Não duplique regras legadas.
tone: objetivo, claro, seguro e auditável

143
config/routing.yaml Normal file
View File

@@ -0,0 +1,143 @@
router:
mode: router
fallback_agent: contas_agent
confidence_threshold: 0.55
allow_handoff: false
state_policies:
- state: WAITING_BILLING_CONFIRMATION
agent: contas_agent
description: Mantém respostas curtas de confirmação dentro do fluxo de contas.
- state: WAITING_SERVICE_CONFIRMATION
agent: contas_agent
description: Mantém confirmações de plano/serviço/VAS dentro do Agent Contas.
intents:
- name: billing_invoice_explanation
domain: telecom_contas
agent: contas_agent
description: Explicação de fatura, divergência de valor, cobrança proporcional, itens da conta e dúvidas de vencimento.
priority: 10
mcp_tools: [consultar_fatura, consultar_pagamentos]
keywords: [fatura, conta, cobrança, valor, alto, divergente, proporcional, pro rata, vencimento, boleto, explicação]
examples:
- Minha fatura veio alta.
- Quero entender uma cobrança proporcional.
- Por que minha conta mudou de valor?
- name: billing_payment_status
domain: telecom_contas
agent: contas_agent
description: Consulta pagamentos, baixa, status de pagamento e histórico.
priority: 20
mcp_tools: [consultar_pagamentos, consultar_fatura]
keywords: [pagamento, paguei, baixa, comprovante, histórico, aberto, quitado]
examples:
- Meu pagamento já caiu?
- Quero consultar meus pagamentos.
- name: billing_second_copy
domain: telecom_contas
agent: contas_agent
description: Segunda via, código de barras e dados da fatura atual.
priority: 30
mcp_tools: [consultar_fatura]
keywords: [segunda via, boleto, código de barras, barcode, copiar linha digitável]
examples:
- Preciso da segunda via da conta.
- Me envie o boleto da fatura.
- name: billing_contestation
domain: telecom_contas
agent: contas_agent
description: Contestação de cobrança, análise de item de fatura e abertura de fluxo com confirmação.
priority: 40
mcp_tools: [consultar_fatura, consultar_pagamentos]
keywords: [contestar, contestação, cobrança indevida, não reconheço, reclamar, ajustar, crédito]
examples:
- Quero contestar essa cobrança.
- Não reconheço um item da fatura.
- name: service_plan_information
domain: telecom_contas
agent: contas_agent
description: Consulta plano, pacote, asset, internet, roaming e benefícios.
priority: 50
mcp_tools: [consultar_plano, listar_servicos]
keywords: [plano, pacote, internet, roaming, benefício, asset, produto]
examples:
- Qual é meu plano?
- Tenho roaming incluso?
- name: vas_services_information
domain: telecom_contas
agent: contas_agent
description: Serviços adicionais, VAS, assinaturas, bloqueio/cancelamento informativo.
priority: 60
mcp_tools: [listar_servicos, consultar_plano]
keywords: [vas, serviço adicional, assinatura, serviços digitais, cancelar serviço, bloquear serviço]
examples:
- Quais serviços VAS estão ativos?
- Quero entender serviços digitais na minha conta.
- name: generic_billing_rag
domain: telecom_contas
agent: contas_agent
description: Perguntas de política, regras de atendimento e conhecimento sem necessidade inicial de tool operacional.
priority: 90
mcp_tools: []
keywords: [política, regra, procedimento, anatel, prazo, atendimento]
examples:
- Qual é a regra para cobrança proporcional?
- name: billing_protocol_status
domain: telecom_contas
agent: contas_agent
description: Consulta status de protocolo/SR/contestação.
priority: 35
mcp_tools: [consultar_status_sr]
keywords: [protocolo, sr, status da contestação, acompanhamento, andamento]
examples:
- Quero saber o status do meu protocolo.
- Como está minha contestação?
- name: billing_protocol_register
domain: telecom_contas
agent: contas_agent
description: Registro de protocolo de atendimento quando o fluxo exigir e houver confirmação.
priority: 75
mcp_tools: [registrar_protocolo]
keywords: [gerar protocolo, registrar protocolo, abrir protocolo]
examples:
- Gere um protocolo para este atendimento.
- name: vas_cancel_or_block
domain: telecom_contas
agent: contas_agent
description: Cancelamento/bloqueio de VAS/SVA/serviço adicional após confirmação explícita.
priority: 45
mcp_tools: [listar_servicos, cancelar_vas, bloquear_vas, registrar_protocolo]
keywords: [cancelar vas, cancelar serviço, bloquear vas, bloquear serviço, remover assinatura]
examples:
- Quero cancelar esse serviço adicional.
- Bloqueie esse VAS.
- name: send_billing_sms
domain: telecom_contas
agent: contas_agent
description: Envio de SMS com link ou informação de atendimento quando autorizado.
priority: 80
mcp_tools: [enviar_sms]
keywords: [enviar sms, mandar sms, envia por mensagem, link por sms]
examples:
- Envie o boleto por SMS.
- name: tracking_activity
domain: telecom_contas
agent: contas_agent
description: Registro de tracking/activity operacional.
priority: 85
mcp_tools: [registrar_tracking]
keywords: [registrar atividade, tracking, registrar acompanhamento]
examples:
- Registre a atividade do atendimento.

167
config/tools.yaml Normal file
View File

@@ -0,0 +1,167 @@
tools:
consultar_fatura:
description: Consulta dados de fatura, valores, vencimento, itens, status e linha digitável.
mcp_server: legacy_tim
enabled: true
tool_type: query
cache: { enabled: true, ttl_seconds: 300 }
args_schema:
msisdn: string
invoice_id: string
customer_id: string
consultar_pagamentos:
description: Consulta histórico/status de pagamentos do cliente.
mcp_server: legacy_tim
enabled: true
tool_type: query
cache: { enabled: true, ttl_seconds: 300 }
args_schema:
msisdn: string
customer_id: string
consultar_plano:
description: Consulta contrato/plano ativo e atributos comerciais.
mcp_server: legacy_tim
enabled: true
tool_type: query
cache: { enabled: true, ttl_seconds: 600 }
args_schema:
msisdn: string
asset_id: string
customer_id: string
listar_servicos:
description: Lista serviços ativos, VAS, SVA, bundles e benefícios da linha.
mcp_server: legacy_tim
enabled: true
tool_type: query
cache: { enabled: true, ttl_seconds: 600 }
args_schema:
msisdn: string
asset_id: string
recuperar_pdf_fatura:
description: Recupera PDF/segunda via da fatura quando disponível.
mcp_server: legacy_tim
enabled: true
tool_type: query
cache: { enabled: true, ttl_seconds: 300 }
args_schema:
msisdn: string
invoice_id: string
customer_id: string
output: string
registrar_protocolo:
description: Registra protocolo de atendimento nos serviços legados/Siebel quando aplicável.
mcp_server: legacy_tim
enabled: true
tool_type: action
confirmation_required: true
requires: [msisdn]
cache: { enabled: false }
args_schema:
msisdn: string
protocol_number: string
interaction_call_id: string
reason: string
channel: string
abrir_contestacao:
description: Abre contestação de cobrança/SR para itens de fatura após confirmação.
mcp_server: legacy_tim
enabled: true
tool_type: action
confirmation_required: true
requires: [msisdn, invoice_id]
cache: { enabled: false }
args_schema:
msisdn: string
invoice_id: string
customer_id: string
sr: string
items: list
reason: string
amount: number
consultar_status_sr:
description: Consulta status de SR/protocolo de contestação ou atendimento.
mcp_server: legacy_tim
enabled: true
tool_type: query
cache: { enabled: true, ttl_seconds: 180 }
args_schema:
msisdn: string
sr: string
protocol_number: string
atualizar_status_sr:
description: Atualiza status de SR/protocolo nos serviços externos.
mcp_server: legacy_tim
enabled: true
tool_type: action
confirmation_required: true
requires: [msisdn, protocol_number]
cache: { enabled: false }
args_schema:
msisdn: string
protocol_number: string
status: string
notes: string
cancelar_vas:
description: Solicita cancelamento de VAS/SVA/serviço adicional após confirmação explícita.
mcp_server: legacy_tim
enabled: true
tool_type: action
confirmation_required: true
requires: [msisdn]
cache: { enabled: false }
args_schema:
msisdn: string
app_id: string
service_id: string
csp_id: string
bloquear_vas:
description: Solicita bloqueio de VAS/SVA/serviço adicional após confirmação explícita.
mcp_server: legacy_tim
enabled: true
tool_type: action
confirmation_required: true
requires: [msisdn]
cache: { enabled: false }
args_schema:
msisdn: string
app_id: string
service_id: string
csp_id: string
enviar_sms:
description: Envia SMS ao cliente com link/informação quando o fluxo exigir.
mcp_server: legacy_tim
enabled: true
tool_type: action
confirmation_required: true
requires: [msisdn]
cache: { enabled: false }
args_schema:
msisdn: string
message: string
long_url: string
notify_url: string
registrar_tracking:
description: Registra tracking/activity de atendimento ou fatura nos serviços externos.
mcp_server: legacy_tim
enabled: true
tool_type: action
confirmation_required: false
cache: { enabled: false }
args_schema:
msisdn: string
protocol_number: string
invoice_id: string
activity_type: string
activity_status: string

28
docker-compose.yml Normal file
View File

@@ -0,0 +1,28 @@
services:
legacy-tim-mcp:
image: python:3.12-slim
working_dir: /app
volumes:
- ./mcp_servers/legacy_tim_mcp:/app
env_file:
- ./mcp_servers/legacy_tim_mcp/.env.example
command: bash -lc "pip install -r requirements.txt && uvicorn main:app --host 0.0.0.0 --port 8100"
ports:
- "8100:8100"
agent-contas:
image: python:3.12-slim
working_dir: /app
volumes:
- .:/app
# Monte o framework local se quiser usar pip install -e:
# - /mnt/d/MSI Projects/agent_framework_oci/agent_framework:/framework/agent_framework
env_file:
- ./.env.example
environment:
MCP_SERVERS_CONFIG_PATH: ./config/mcp_servers.docker.yaml
command: bash -lc "pip install -r requirements.txt && uvicorn app.main:app --host 0.0.0.0 --port 8000"
ports:
- "8000:8000"
depends_on:
- legacy-tim-mcp

View File

@@ -0,0 +1,95 @@
# Atualização do Template Backend — Analytics, Observer, NOC/GRL e OutputSupervisor
Esta versão do `agent_template_backend` foi atualizada para consumir as novidades transportadas para o `agent_framework`.
## 1. Analytics e Pub/Sub
O backend não chama mais diretamente apenas o publisher antigo de eventos. Agora ele cria um `AnalyticsPublisher`:
```python
from agent_framework.analytics.factory import create_analytics_publisher
from agent_framework.observability.observer import AgentObserver
analytics = create_analytics_publisher(settings)
observer = AgentObserver(analytics=analytics)
```
Com isso, o mesmo backend pode publicar em:
- OCI Streaming
- GCP Pub/Sub
- CompositePublisher, quando `ANALYTICS_PROVIDERS=oci_streaming,pubsub`
- Noop, quando analytics estiver desligado
## 2. Configuração mínima
```env
ENABLE_ANALYTICS=true
ANALYTICS_PROVIDERS=pubsub
GCP_PUBSUB_TOPIC_PATH=projects/<project-id>/topics/<topic-name>
GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json
```
Para publicar simultaneamente em OCI Streaming e GCP Pub/Sub:
```env
ENABLE_ANALYTICS=true
ANALYTICS_PROVIDERS=oci_streaming,pubsub
ENABLE_OCI_STREAMING=true
OCI_STREAM_ENDPOINT=<endpoint>
OCI_STREAM_OCID=<stream-ocid>
GCP_PUBSUB_TOPIC_PATH=projects/<project-id>/topics/<topic-name>
```
## 3. Observer corporativo
O workflow recebeu emissão automática dos principais eventos corporativos:
- `NOC.001`: início do workflow
- `NOC.005`: exceção fatal no workflow
- `NOC.006`: fim do workflow antes da resposta final
- `IC.AGENT_COMPLETED`: evento informacional de conclusão
- `GRL.001` a `GRL.009`: emitidos pelo `OutputSupervisor`
## 4. OutputSupervisor
Foi inserido um novo nó LangGraph:
```text
agent -> output_supervisor -> output_guardrails -> judge -> supervisor_review -> persist
```
O `OutputSupervisor` não substitui o supervisor de roteamento. Ele valida a saída candidata do agente usando o contrato corporativo:
- `allow`
- `sanitize`
- `retry`
- `block`
- `handover`
- `observe`
Para compatibilidade com os guardrails já existentes, o template inclui o adapter `LegacyOutputGuardrailRail`, que converte decisões antigas `allowed=True/False` para `RailAction`.
## 5. Campos adicionados ao AgentState
```python
supervisor_action: str
supervisor_guidance: str
supervisor_attempt: int
supervisor_handover_reason: str
output_supervisor_results: list[dict]
output_guardrails_already_applied: bool
```
## 6. Arquivos alterados
- `agent_template_backend/app/main.py`
- `agent_template_backend/app/workflows/agent_graph.py`
- `agent_template_backend/app/state.py`
- `agent_template_backend/.env`
- `agent_template_backend/requirements.txt`
- `agent_framework/src/agent_framework/config/settings.py`
## 7. Observação importante
O `OutputSupervisor` roda os guardrails de saída por meio do adapter legado e marca `output_guardrails_already_applied=True`. Assim o nó `output_guardrails` permanece no grafo para compatibilidade, mas evita reexecutar a mesma validação quando o supervisor já aplicou os rails.

View File

@@ -0,0 +1,45 @@
# Como usar IC, NOC e GRL no Template Backend
## IC — Item de Controle
Use IC para registrar eventos de negócio relevantes.
```python
await observer.emit_ic(
"IC.FATURA_CONSULTADA",
{"session_id": session_id, "invoice_id": invoice_id},
component="billing_agent",
)
```
## NOC — Evento operacional
Use NOC para saúde técnica, latência, erros e checkpoints operacionais.
```python
await observer.emit_noc(
"003",
{"session_id": session_id, "resourceName": "ADB", "latencyMs": 120},
component="repository",
)
```
## GRL — Evento de guardrail
Normalmente o framework emite GRL automaticamente. Use manualmente apenas para
rails customizados dentro do agente.
```python
await observer.emit_grl(
"OBSERVE",
{"session_id": session_id, "rail_code": "CUSTOM_POLICY"},
component="custom_rail",
)
```
## Onde já existe no template
- `app/workflows/agent_graph.py` emite IC/NOC no ciclo do workflow.
- `app/agents/runtime.py` emite IC para MCP/tools.
- `app/agents/*_agent.py` contém exemplos dentro do método `run()`.
- `app/examples/` contém exemplos isolados.

View File

@@ -0,0 +1,48 @@
# Backends atualizados para ConversationSummaryMemory
Esta versão dos backends foi compatibilizada com a versão do framework que adiciona `ConversationSummaryMemory`.
## O que mudou
- `app/main.py` agora inicializa `create_conversation_summary_memory(...)` junto com `create_memory(...)`.
- `AgentWorkflow` recebe `summary_memory` e repassa para os agentes.
- Os agentes não montam mais prompts manuais para o LLM; agora usam `build_messages()` do framework.
- Antes da chamada ao LLM, os agentes executam `await self.prepare_memory_context(state)`.
- Quando habilitado por `.env`, o prompt passa a receber:
- resumo acumulado da conversa;
- últimas mensagens completas;
- mensagem atual;
- BusinessContext;
- MCP results;
- RAG context e metadata.
## Configuração
```env
ENABLE_CONVERSATION_SUMMARY_MEMORY=true
MEMORY_CONTEXT_STRATEGY=summary
MEMORY_HISTORY_LIMIT=80
MEMORY_RECENT_MESSAGES_LIMIT=8
MEMORY_SUMMARY_TRIGGER_MESSAGES=20
MEMORY_MAX_SUMMARY_CHARS=6000
MEMORY_SUMMARY_USE_LLM=true
MEMORY_INJECT_RECENT_MESSAGES=true
MEMORY_INJECT_SUMMARY=true
```
## Backends alterados
- `backoffice_convertido_framework`
- `agent_template_backend`
- `agent_template_backend_day_zero`
## Observação importante
Estes backends esperam que o pacote `agent_framework` instalado/conectado seja a versão com os módulos:
- `agent_framework.memory.summary_memory`
- `agent_framework.memory.summary_store`
- `AgentRuntimeMixin.prepare_memory_context()`
- `AgentRuntimeMixin.build_messages()` com injeção de memória
Use junto com o ZIP `agent_framework_conversation_summary_memory.zip`.

View File

@@ -0,0 +1,51 @@
# Status real do `.env` legado TIM/FIRST
Esta versão removeu a duplicidade do `.env.example` e do `mcp_servers/legacy_tim_mcp/.env.example`.
## Decisão aplicada
- `TIM_MCP_USE_MOCK=false` por padrão.
- Mock só é usado quando você definir explicitamente `TIM_MCP_USE_MOCK=true`.
- Os defaults reais encontrados no `agent_contas_first` original foram copiados.
- Secrets e URLs que não existiam no original continuam vazios de propósito.
## Defaults efetivamente encontrados no original
| Variável nova | Valor preenchido | Origem no legado |
|---|---|---|
| `TIM_PROFILE_BILL_URL` | `http://10.151.3.100:8000` | `TIM_URL_PERFIL_FATURA` / `tim_profile_bill_url` |
| `TIM_QUERY_VAS_URL` | `http://10.151.3.100:8000` | `TIM_URL_CONSULTA_VAS` / `TIM_CONSULTA_URL` / `tim_query_url` |
| `TIM_BLOCK_VAS_URL` | `http://10.151.3.100:8000/customers/v1/partialServiceBlocking` | `TIM_URL_BLOQUEIO_VAS` / `TIM_BLOQUEIO_URL` / `tim_block_url` |
| `TIM_CLIENT_ID` | `AIAGENTCR` | `tim_default_client_id` |
| `TIM_USER_ID` | `AIAGENTCR` | usado como default nos commands/SR |
| `TIM_DEFAULT_CSP_ID` | `740` | `tim_default_csp_id` |
| `TIM_DEFAULT_CHANNEL` | `APP` | `tim_default_channel` |
| `TIM_BLOCK_VAS_OPERATION_TYPE` | `block` | `tim_block_operation_type` |
| `TIM_BLOCK_VAS_PAYLOAD_MODE` | `auto` | `tim_block_payload_mode` |
| `TIM_BLOCK_VAS_ACCEPT_ENCODING` | `gzip,deflate` | `tim_block_accept_encoding` |
## Valores que permanecem vazios
Permanecem vazios porque o pacote original não trouxe valores reais para eles:
- `TIM_COMPLETE_INVOICES_URL`
- `TIM_CONTRATO_URL`
- `TIM_BILL_PDF_URL`
- `TIM_PROTOCOL_URL`
- `TIM_CUSTOMER_CONTESTATION_URL`
- `TIM_SERVICE_REQUEST_STATUS_URL`
- `TIM_CANCEL_VAS_URL`
- `TIM_SMS_URL`
- `TIM_TRACKING_ACTIVITIES_URL`
- todos os `*_AUTH`, `TIM_AUTHORIZATION_OAM`, `TIM_CN_FIELD`, `TIM_TYPE_FIELD`
## Onde alterar
Para executar localmente:
```bash
cp .env.example .env
cp mcp_servers/legacy_tim_mcp/.env.example mcp_servers/legacy_tim_mcp/.env
```
Depois preencha apenas os endpoints/secrets que existirem no seu ambiente.

View File

@@ -0,0 +1,84 @@
# FRAMEWORK_CHANNEL_INPUT_MODE
This backend setting controls what kind of channel input the Agent Framework backend accepts.
It replaces the ambiguous use of `CHANNEL_GATEWAY_MODE` inside the backend.
## Values
```env
FRAMEWORK_CHANNEL_INPUT_MODE=embedded
```
The backend may use internal channel adapters to interpret simple/native channel payloads. This is useful for demos, labs, local frontend, curl tests, and simple environments.
```env
FRAMEWORK_CHANNEL_INPUT_MODE=external
```
The backend accepts only a normalized `GatewayRequest` produced by an external Channel Gateway. It does not parse native WhatsApp, Voice, Teams, or other channel payloads.
## Recommended enterprise setup
In the external channel gateway service:
```env
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
```
In this backend:
```env
FRAMEWORK_CHANNEL_INPUT_MODE=external
```
Flow:
```text
External channel / browser / customer adapter
channel_gateway:7000
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
↓ GatewayRequest
agent_template_backend:8000
FRAMEWORK_CHANNEL_INPUT_MODE=external
LangGraph / Agents / MCP / Guardrails
```
## Valid direct request to backend in external mode
```bash
curl -s -X POST "http://localhost:8000/gateway/message" \
-H "Content-Type: application/json" \
-d '{
"channel": "web",
"tenant_id": "default",
"agent_id": "telecom_contas",
"payload": {
"message": "Quero consultar minha fatura",
"session_id": "backend-external-ok-001"
}
}' | jq
```
## Invalid direct request to backend in external mode
```bash
curl -i -s -X POST "http://localhost:8000/gateway/message" \
-H "Content-Type: application/json" \
-d '{
"message": "Quero consultar minha fatura",
"session_id": "raw-payload-error-001"
}'
```
Expected result: HTTP 422.
## Legacy compatibility
`CHANNEL_GATEWAY_MODE` is still present as a legacy alias for older environments, but new deployments should use:
```env
FRAMEWORK_CHANNEL_INPUT_MODE=embedded|external
```

View File

@@ -0,0 +1,127 @@
# Guardrails paralelos fail-fast e Observer IC
## O que foi implementado
### 1. ParallelRailExecutor
Arquivo principal:
```text
agent_framework/src/agent_framework/guardrails/parallel_executor.py
```
Também foi criado um alias de compatibilidade:
```text
agent_framework/src/agent_framework/guardrails/executor.py
```
Esse alias evita erro quando algum código antigo importar:
```python
from agent_framework.guardrails.executor import ParallelRailExecutor
```
### 2. Execução paralela no GuardrailPipeline
Arquivo alterado:
```text
agent_framework/src/agent_framework/guardrails/pipeline.py
```
O pipeline continua retornando o contrato antigo:
```python
(texto_final, list[RailDecision])
```
mas internamente pode executar rails em paralelo com fail-fast.
### 3. Execução paralela no OutputSupervisor
Arquivo alterado:
```text
agent_framework/src/agent_framework/guardrails/output_supervisor.py
```
O `OutputSupervisor` agora usa `ParallelRailExecutor` quando habilitado.
### 4. Configuração
Novas configurações:
```env
ENABLE_PARALLEL_GUARDRAILS=true
GUARDRAILS_FAIL_FAST=true
```
Também foram adicionadas em:
```text
agent_framework/src/agent_framework/config/settings.py
.env
.env.example
agent_template_backend/.env
agent_template_backend_day_zero/.env
```
### 5. Observer IC
O `AgentObserver` já tinha `emit_ic()`.
Foi complementada a API global compatível com FIRST/TIM:
```python
from agent_framework.observer import ic, aic, noc, anoc, grl, agrl
```
Exemplos:
```python
ic("AGENT_COMPLETED", data={"session_id": "..."})
await aic("MCP_TOOL_CALLED", data={"tool_name": "consultar_fatura"})
```
### 6. ICs automáticos no template backend
O backend emite agora:
```text
IC.AGENT_STARTED
IC.ROUTE_SELECTED
IC.MCP_TOOL_CALLED
IC.TOOL_CALLED
IC.AGENT_COMPLETED
```
Além dos eventos já existentes:
```text
NOC.001
NOC.005
NOC.006
GRL.001 ... GRL.009
```
## Validações executadas
Foram executadas validações locais com `PYTHONPATH=agent_framework/src`:
```bash
python3 -m compileall -q agent_framework/src/agent_framework agent_template_backend/app agent_template_backend_day_zero/app
```
Smoke tests executados:
```text
1. Import de ParallelRailExecutor via agent_framework.guardrails
2. Import de ParallelRailExecutor via agent_framework.guardrails.executor
3. Execução fail-fast: FastBlock cancela SlowAllow
4. GuardrailPipeline paralelo retorna RailDecision legado
5. OutputSupervisor paralelo retorna RailAction.BLOCK
6. API global observer.ic/noc/grl/aic/anoc/agrl
```
Observação: o import completo do `agent_template_backend.app.workflows.agent_graph` depende de `langgraph`, que não está instalado no sandbox de validação. O arquivo foi validado por `compileall`, e a dependência já consta em `agent_template_backend/requirements.txt`.

View File

@@ -0,0 +1,42 @@
# Implementação IC/NOC/GRL preservando lógica existente
Esta versão mantém a lógica original dos agentes do `agent_template_backend` e adiciona observabilidade corporativa.
## IC adicionados nos agentes
Cada agente agora emite eventos de negócio sem alterar a resposta final:
- `IC.BILLING_AGENT_STARTED` / `IC.BILLING_AGENT_COMPLETED`
- `IC.ORDERS_AGENT_STARTED` / `IC.ORDERS_AGENT_COMPLETED`
- `IC.PRODUCT_AGENT_STARTED` / `IC.PRODUCT_AGENT_COMPLETED`
- `IC.SUPPORT_AGENT_STARTED` / `IC.SUPPORT_AGENT_COMPLETED`
- `IC.<AGENT>_MCP_CONTEXT_COLLECTED` quando houver dados MCP
- `IC.<AGENT>_RAG_CONTEXT_RETRIEVED` quando RAG estiver habilitado
O mixin `AgentRuntimeMixin` também emite:
- `IC.MCP_TOOL_CALLED` antes da chamada MCP
- `IC.TOOL_CALLED` após a chamada MCP
## NOC
O workflow já emite eventos operacionais principais:
- `NOC.001` no início da execução
- `NOC.005` em exceção fatal
- `NOC.006` na persistência/finalização
## GRL
O backend agora também exemplifica emissão GRL no workflow:
- `GRL.001` início do pipeline de guardrails
- `GRL.002` decisão allow
- `GRL.004` decisão block
- `GRL.009` decisão final agregada
Quando `OutputSupervisor` está habilitado, ele continua sendo o principal mecanismo corporativo de supervisão de saída.
## Garantia
A lógica original dos agentes não foi substituída por stubs. As chamadas LLM, MCP, RAG, cache e os retornos originais foram preservados.

View File

@@ -0,0 +1,5 @@
# Langfuse single trace observer fix
This backend now uses `TelemetryBackedAgentObserver` instead of publishing IC/NOC/GRL through `AgentObserver(analytics=...)`.
Why: when analytics includes the Langfuse provider, observer events such as `IC.AGENT_COMPLETED` and `NOC.006` may create a second root trace with little detail. Emitting those events through `Telemetry.event(...)` keeps them inside the active request/workflow trace.

View File

@@ -0,0 +1,129 @@
# Correção V6 — Diagnóstico MCP e Judges
## Problema observado
No teste, o `agent_framework_oci` acionava o MCP Router, mas os resultados das tools vinham como:
```text
ok: False
result: None
error: ''
metadata: {}
cached: False
```
Isso impedia saber se a falha era URL, autenticação, payload, timeout, status HTTP, rede ou bug no adapter.
Também apareceu:
```text
Judge calibrado declarado em judges.yaml, mas nenhum LLM foi fornecido ao pipeline.
```
## Correções aplicadas
### 1. MCP server preserva erro real
Arquivo alterado:
```text
mcp_servers/legacy_tim_mcp/main.py
```
Agora toda falha HTTP ou de rede retorna `metadata` com:
- `server`
- `tool`
- `mock`
- `prefix`
- `method`
- `url`
- `status_code`, quando houver resposta HTTP
- `response_content_type`
- `response_body_preview`
- `exception_type`
- `timeout_seconds`
- `request_payload_keys`
- headers sanitizados, sem segredo
Exemplo esperado:
```json
{
"ok": false,
"result": null,
"error": "TIM_COMPLETE_INVOICES falhou: status=400 body=...",
"metadata": {
"server": "legacy_tim",
"tool": "consultar_fatura",
"mock": false,
"method": "POST",
"url": "http://10.151.3.100:8000/customers/v1/completeInvoices",
"status_code": 400,
"exception_type": "HTTPStatusError",
"response_body_preview": "..."
}
}
```
### 2. Judges recebem LLM do framework
Arquivo alterado:
```text
app/workflows/agent_graph.py
```
Antes:
```python
self.judges = JudgePipeline()
```
Depois:
```python
self.judges = JudgePipeline(llm=llm, settings=settings)
```
Assim o `judges.yaml` usa o LLM criado pelo backend e o profile `judge` do `llm_profiles.yaml`.
### 3. `.env` corrigido para uso com `source .env`
Valores com espaço, como `Basic ...` e `Bearer ...`, foram colocados entre aspas.
Exemplo:
```env
TIM_COMPLETE_INVOICES_AUTH="Basic ..."
```
## Como validar
Suba o MCP:
```bash
./scripts/start_legacy_tim_mcp.sh
```
Teste direto:
```bash
./tests/curl_mcp_consultar_fatura.sh
```
Depois teste pelo backend:
```bash
./tests/curl_gateway_fatura.sh
```
No log, procure por:
```text
mcp_results
metadata
status_code
response_body_preview
mock: false
```

View File

@@ -0,0 +1,56 @@
# MCP ENV Loading Fix - V8
## Problema encontrado
No pacote V7, o MCP Server podia retornar:
```text
Endpoint externo não configurado para TIM_COMPLETE_INVOICES
```
mesmo quando `.env` continha:
```env
TIM_COMPLETE_INVOICES_URL=http://10.151.3.100:8000/customers/v1/completeInvoices
```
A causa era que o MCP dependia do ambiente já estar carregado pelo shell (`source .env`) ou pelo Docker `env_file`. Quando o `uvicorn main:app` era iniciado diretamente, o arquivo `.env` não era carregado automaticamente.
## Correção aplicada
O MCP Server agora carrega automaticamente:
1. `.env` do diretório corrente.
2. `.env` local do MCP Server: `mcp_servers/legacy_tim_mcp/.env`.
3. `.env` da raiz do projeto.
A carga preenche variáveis ausentes ou exportadas como string vazia.
## Novos endpoints de diagnóstico
```bash
curl http://localhost:8100/health | jq
curl http://localhost:8100/debug/config | jq
```
O retorno agora mostra:
- arquivos `.env` carregados;
- endpoints configurados;
- URLs resolvidas;
- presença de autenticação sem expor o secret.
## Validação esperada
Para `TIM_COMPLETE_INVOICES`, deve aparecer:
```json
{
"url": "http://10.151.3.100:8000/customers/v1/completeInvoices",
"has_auth": true,
"timeout": 30.0,
"client_id": "AIAGENTCR"
}
```
Se a URL aparecer vazia, o MCP não está lendo o `.env` correto ou o arquivo não contém a variável.

View File

@@ -0,0 +1,44 @@
# Implementação MCP dos serviços externos
Esta versão adiciona um MCP Server `legacy_tim_mcp` para adaptar os comandos externos do legado ao contrato do `agent_framework_oci`.
## Contrato
O framework chama:
```text
POST /mcp/tools/call
```
Payload:
```json
{"tool_name":"consultar_fatura","arguments":{"msisdn":"11999999999"}}
```
## Onde configurar
- Backend: `config/mcp_servers.yaml`
- Tools: `config/tools.yaml`
- Mapeamento: `config/mcp_parameter_mapping.yaml`
- MCP Server: `mcp_servers/legacy_tim_mcp/.env`
## Serviços cobertos
- Fatura completa / perfil de fatura
- Pagamentos
- Contrato/plano
- VAS/SVA/listagem de serviços
- PDF/segunda via
- Protocolo
- Contestação/SR
- Status/atualização de SR
- Cancelamento/bloqueio VAS
- SMS
- Tracking activities
## Modo mock vs real
`TIM_MCP_USE_MOCK=true` devolve respostas simuladas.
`TIM_MCP_USE_MOCK=false` exige endpoints reais `TIM_*_URL`.

View File

@@ -0,0 +1,104 @@
# V7 — MCP legado TIM/FIRST com padrão Command + ApiGateway
Esta versão corrige a camada MCP do `agent_contas_first` para ficar mais próxima do projeto original.
## O que mudou
A versão anterior ainda fazia chamadas HTTP muito genéricas. A V7 implementa no MCP Server local uma camada inspirada no legado:
```text
MCP Tool
-> LegacyTimCommand
-> EndpointConfig
-> HttpApiGateway
-> GatewayError rico
-> retorno MCP com error/metadata preservados
```
## Evidência esperada nos logs
Em caso de erro real do backend TIM, o `mcp_results` deve aparecer assim:
```json
{
"tool_name": "consultar_fatura",
"server_name": "legacy_tim",
"ok": false,
"error": "TIM_COMPLETE_INVOICES falhou: status=400 body=...",
"metadata": {
"server": "legacy_tim",
"tool": "consultar_fatura",
"mock": false,
"endpoint": "TIM_COMPLETE_INVOICES",
"method": "POST",
"url": "http://10.151.3.100:8000/customers/v1/completeInvoices",
"status_code": 400,
"error_code": "HTTP_400",
"provider_service": "...",
"provider_error_code": "...",
"provider_error_message": "...",
"message_id": "...",
"kong_request_id": "...",
"latency_ms": 123,
"exception_type": "HTTPStatusError",
"response_body_preview": "..."
},
"cached": false
}
```
Se ainda aparecer:
```json
{"ok": false, "error": "", "metadata": {}}
```
então provavelmente uma janela antiga do `legacy_tim_mcp` ainda está rodando ou o backend não foi reiniciado depois da troca do pacote.
## Tools migradas para Commands
| MCP Tool | Command V7 | Endpoint |
|---|---|---|
| `consultar_fatura` | `CompleteInvoicesCommand` | `TIM_COMPLETE_INVOICES` |
| `consultar_pagamentos` | `ProfileBillCommand` | `TIM_PROFILE_BILL` |
| `consultar_plano` | `ContractInformationCommand` | `TIM_CONTRATO` |
| `listar_servicos` | `QueryVasCommand` | `TIM_QUERY_VAS` |
| `recuperar_pdf_fatura` | `InvoiceRecoverCommand` | `TIM_BILL_PDF` |
| `registrar_protocolo` | `ProtocolCommand` | `TIM_PROTOCOL` |
| `abrir_contestacao` | `CustomerContestationCommand` | `TIM_CUSTOMER_CONTESTATION` |
| `consultar_status_sr` | `ServiceRequestStatusCommand` | `TIM_SERVICE_REQUEST_STATUS` |
| `cancelar_vas` | `CancelVasCommand` | `TIM_CANCEL_VAS` |
| `bloquear_vas` | `BlockVasCommand` | `TIM_BLOCK_VAS` |
| `enviar_sms` | `SmsCommand` | `TIM_SMS` |
| `registrar_tracking` | `TrackingActivitiesCommand` | `TIM_TRACKING_ACTIVITIES` |
## Como testar só o MCP
```bash
cd mcp_servers/legacy_tim_mcp
set -a
source .env
set +a
uvicorn main:app --host 0.0.0.0 --port 8100 --reload
```
Em outro terminal:
```bash
curl -s http://localhost:8100/health | jq
curl -s -X POST http://localhost:8100/mcp/tools/call \
-H 'Content-Type: application/json' \
-d '{
"tool_name":"consultar_fatura",
"arguments":{"msisdn":"11999999999","invoice_id":"3000131180"}
}' | jq
```
## Checklist de execução
1. Pare qualquer processo antigo do MCP na porta `8100`.
2. Suba o MCP V7.
3. Reinicie o backend do agente.
4. Rode o curl do gateway.
5. Verifique se `mcp_results[].metadata.endpoint`, `status_code`, `url` e `response_body_preview` aparecem.

View File

@@ -0,0 +1,49 @@
# MCP Legacy TIM/FIRST — integração real
Esta versão troca o MCP puramente mockado por um adapter HTTP real baseado nos contratos encontrados no projeto original `agent_contas_first`.
## Modo de execução
Por padrão:
```env
TIM_MCP_USE_MOCK=false
```
Com isso, as tools chamam os backends externos configurados no `.env`. Para testes locais sem rede TIM, use:
```env
TIM_MCP_USE_MOCK=true
```
## Contratos implementados a partir do legado
| Tool MCP | Endpoint/env | Método | Contrato legado usado |
|---|---|---:|---|
| `consultar_fatura` | `TIM_COMPLETE_INVOICES_URL` | POST | `CompleteInvoicesCommand`: `{"msisdn": "..."}` |
| `consultar_pagamentos` | `TIM_PROFILE_BILL_URL` | POST | `QueryProfileBillCommand`: `{"msisdn": "..."}` |
| `consultar_plano` | `TIM_CONTRATO_URL/{msisdn}` | GET | `ContratoCommand` |
| `listar_servicos` | `TIM_QUERY_VAS_URL/{msisdn}` | GET | `QueryVasCommand` |
| `recuperar_pdf_fatura` | `TIM_BILL_PDF_URL?invoiceId=&msisdn=&customerId=` | GET | `InvoiceRecoverCommand` / `BillPdfCommand` |
| `explicar_fatura` | `TIM_INVOICE_EXPLANATION_URL/{msisdn}?channel=APP` | GET | `InvoiceExplanationCommand` |
| `registrar_protocolo` | `TIM_PROTOCOL_URL` | POST | `RegisterProtocolV2Command` |
| `abrir_contestacao` | `TIM_CUSTOMER_CONTESTATION_URL` | POST | `CustomerContestationCommand` |
| `consultar_status_sr` / `atualizar_status_sr` | `TIM_SERVICE_REQUEST_STATUS_URL` | POST | `UpdateServiceRequestStatusCommand` |
| `cancelar_vas` | `TIM_CANCEL_VAS_URL` | DELETE | `CancellationVasCommand` |
| `bloquear_vas` | `TIM_BLOCK_VAS_URL` | POST | `BlockVasCommand` |
| `enviar_sms` | `TIM_SMS_URL` | POST | `SendSmsCommand` |
| `registrar_tracking` | `TIM_TRACKING_ACTIVITIES_URL` | POST | `TrackingActivitiesCommand` |
## Observação importante
O projeto original não trazia secrets reais. O adapter usa os nomes de variáveis do legado e aliases compatíveis, mas os valores de autenticação precisam ser preenchidos no `.env` do ambiente.
## Validação rápida
```bash
./scripts/start_legacy_tim_mcp.sh
curl http://localhost:8100/health
curl -s -X POST http://localhost:8100/mcp/tools/call \
-H 'Content-Type: application/json' \
-d '{"tool_name":"consultar_fatura","arguments":{"msisdn":"11999999999"}}' | jq
```

View File

@@ -0,0 +1,55 @@
# TIM/FIRST Legacy Environment Mapping
Este arquivo documenta o que foi recuperado do `agent_contas_first` original para configurar o MCP server adaptado.
## Resultado da auditoria
O pacote original não trouxe `.env`, Secret, ConfigMap ou arquivo com credenciais reais. A fonte disponível foi `agente_contas_tim/config.py`, que define aliases e alguns defaults. Por isso:
- URLs com default no código foram preenchidas.
- URLs que no legado tinham default vazio permaneceram vazias.
- Tokens/Authorization permaneceram vazios por segurança e porque não estavam no pacote.
- O MCP server agora aceita tanto os nomes novos quanto os aliases antigos.
## Tabela de mapeamento
| Variável nova | Alias/variável no legado | Valor inferido | Status |
|---|---|---:|---|
| `TIM_QUERY_VAS_URL` | `TIM_URL_CONSULTA_VAS`, `TIM_CONSULTA_URL` | `http://10.151.3.100:8000` | preenchido |
| `TIM_QUERY_VAS_AUTH` | `TIM_QUERY_AUTH`, `TIM_CONSULTA_AUTH` | vazio | requer secret |
| `TIM_BLOCK_VAS_URL` | `TIM_URL_BLOQUEIO_VAS`, `TIM_BLOQUEIO_URL` | `http://10.151.3.100:8000/customers/v1/partialServiceBlocking` | preenchido |
| `TIM_BLOCK_VAS_AUTH` | `TIM_BLOCK_AUTH`, `TIM_BLOQUEIO_AUTH` | vazio | requer secret |
| `TIM_CANCEL_VAS_URL` | `TIM_CANCELLATION_URL`, `TIM_CANCELAMENTO_URL` | vazio | não encontrado no pacote |
| `TIM_CANCEL_VAS_AUTH` | `TIM_CANCELLATION_AUTH`, `TIM_CANCELAMENTO_AUTH` | vazio | requer secret |
| `TIM_AUTHORIZATION_OAM` | `TIM_CANCELLATION_AUTH_OAM`, `TIM_CANCELAMENTO_AUTH_OAM`, `TIM_CANCELAMENTO_AUTHORIZATION_OAM` | vazio | requer secret |
| `TIM_CN_FIELD` | `TIM_CANCELLATION_CN_FIELD`, `TIM_CANCELAMENTO_CN_FIELD` | vazio | não encontrado no pacote |
| `TIM_TYPE_FIELD` | `TIM_CANCELLATION_TYPE_FIELD`, `TIM_CANCELAMENTO_TYPE_FIELD` | vazio | não encontrado no pacote |
| `TIM_PROFILE_BILL_URL` | `TIM_URL_PERFIL_FATURA` | `http://10.151.3.100:8000` | preenchido |
| `TIM_COMPLETE_INVOICES_URL` | `TIM_COMPLETE_INVOICES_URL` | vazio | não encontrado no pacote |
| `TIM_CONTRATO_URL` | `TIM_CONTRATO_URL` | vazio | não encontrado no pacote |
| `TIM_BILL_PDF_URL` | `TIM_URL_INVOICE_RECOVER` | vazio | não encontrado no pacote |
| `TIM_BILL_PDF_AUTH` | `TIM_INVOICE_RECOVER_AUTH` | vazio | requer secret |
| `TIM_PROTOCOL_URL` | `TIM_PROTOCOL_URL` | vazio | não encontrado no pacote |
| `TIM_CUSTOMER_CONTESTATION_URL` | `TIM_CUSTOMER_CONTESTATION_URL` | vazio | não encontrado no pacote |
| `TIM_SERVICE_REQUEST_STATUS_URL` | `TIM_SERVICE_REQUEST_STATUS_URL` | vazio | não encontrado no pacote |
| `TIM_SMS_AUTH` | `SMS_BARCODE_AUTH`, `TIM_SMS_AUTH` | vazio | requer secret |
| `TIM_TRACKING_ACTIVITIES_URL` | `TIM_TRACKING_ACTIVITIES_URL` | vazio | não encontrado no pacote |
| `TIM_TRACKING_ACTIVITIES_USER_LOGIN` | igual | vazio | obrigatório no legado, não encontrado |
| `TIM_TRACKING_ACTIVITIES_CHANNEL` | igual | vazio | obrigatório no legado, não encontrado |
| `TIM_TRACKING_ACTIVITIES_CLIENT_ID` | igual | vazio | obrigatório no legado, não encontrado |
## Implicação para teste
Para teste local sem rede TIM, use:
```env
TIM_MCP_USE_MOCK=true
```
Para teste integrado real, altere para:
```env
TIM_MCP_USE_MOCK=false
```
e preencha as URLs/secrets vazias acima.

View File

@@ -0,0 +1,62 @@
# Validação da versão com IC/NOC/GRL
Validações executadas nesta geração:
1. `python -m compileall -q agent_template_backend/app`
- Resultado: OK.
2. Smoke test dos agentes com LLM fake e Observer fake:
- `BillingAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
- `OrdersAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
- `ProductAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
- `SupportAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
3. Verificação de regressão:
- Nenhum agente retorna `Template Enterprise ativo`.
- A lógica LLM/MCP/RAG/cache existente foi preservada.
## Eventos adicionados
### IC
Nos agentes:
- `IC.BILLING_AGENT_STARTED`
- `IC.BILLING_MCP_CONTEXT_COLLECTED`
- `IC.BILLING_RAG_CONTEXT_RETRIEVED`
- `IC.BILLING_AGENT_COMPLETED`
- `IC.ORDERS_AGENT_STARTED`
- `IC.ORDERS_MCP_CONTEXT_COLLECTED`
- `IC.ORDERS_RAG_CONTEXT_RETRIEVED`
- `IC.ORDERS_AGENT_COMPLETED`
- `IC.PRODUCT_AGENT_STARTED`
- `IC.PRODUCT_MCP_CONTEXT_COLLECTED`
- `IC.PRODUCT_RAG_CONTEXT_RETRIEVED`
- `IC.PRODUCT_AGENT_COMPLETED`
- `IC.SUPPORT_AGENT_STARTED`
- `IC.SUPPORT_MCP_CONTEXT_COLLECTED`
- `IC.SUPPORT_RAG_CONTEXT_RETRIEVED`
- `IC.SUPPORT_AGENT_COMPLETED`
No runtime MCP:
- `IC.MCP_TOOL_CALLED`
- `IC.TOOL_CALLED`
### NOC
Já integrados no workflow:
- `NOC.001` início da execução
- `NOC.005` erro fatal
- `NOC.006` finalização/persistência
### GRL
No workflow de guardrails:
- `GRL.001` início da avaliação
- `GRL.002` allow
- `GRL.004` block
- `GRL.009` decisão final

View File

@@ -0,0 +1,3 @@
compileall app: OK
Arquivos de exemplos IC/NOC/GRL adicionados.
Agentes preservam implementação original comentada.

BIN
legacy_reference/.DS_Store vendored Normal file

Binary file not shown.

File diff suppressed because it is too large Load Diff

BIN
legacy_reference/workflows/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,3 @@
from agente_contas_tim.workflows.service import WorkflowService
__all__ = ["WorkflowService"]

View File

@@ -0,0 +1,15 @@
from agente_contas_tim.workflows.actions.discovery import ensure_actions_loaded
from agente_contas_tim.workflows.actions.registry import (
DEFAULT_ACTION_REGISTRY,
ActionRegistry,
WorkflowRuntimeContext,
workflow_action,
)
__all__ = [
"ActionRegistry",
"DEFAULT_ACTION_REGISTRY",
"WorkflowRuntimeContext",
"ensure_actions_loaded",
"workflow_action",
]

View File

@@ -0,0 +1,304 @@
from __future__ import annotations
import base64
import logging
from typing import Any
from agente_contas_tim.constants.ic_tags_enum import SADTag, TMDTag
from agente_contas_tim.integrations.rct_policy import RCTOperation, rct_tags_for_attempt
from agente_contas_tim.workflows.actions.registry import (
WorkflowRuntimeContext,
workflow_action,
)
from agente_contas_tim.workflows.runtime_types import ActionResult
from agente_contas_tim.workflows.actions.common.helpers import (
_emit_ic,
_normalize_bool,
_result_failed_or_missing_data,
_runtime_llm_callbacks,
_runtime_llm_metadata,
_to_dict,
)
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
@workflow_action("finalizar_atendimento_action")
def finalizar_atendimento_action(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
input_state = state.get("input", {}) if isinstance(state, dict) else {}
if not isinstance(input_state, dict):
input_state = {}
_emit_ic(SADTag.FINALIZACAO, input_state)
status = str(params.get("status", "")).strip()
summary = str(params.get("summary", "")).strip()
return ActionResult.ok(
{
"success": True,
"status": status,
"summary": summary,
}
)
@workflow_action("formatar_capability_resposta")
def formatar_capability_resposta(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
source_node = str(params.get("source_node", "resolve"))
payload = vars_state.get(source_node, {})
if not isinstance(payload, dict):
payload = {}
input_state = state.get("input", {}) if isinstance(state, dict) else {}
msisdn = str(params.get("msisdn", input_state.get("msisdn", "")))
servico = str(params.get("servico", input_state.get("servico", "")))
nome_plano = str(params.get("nome_plano", input_state.get("nome_plano", "")))
tipo = str(params.get("tipo", "")).strip().lower()
instrucoes = str(payload.get("content", "")).strip()
mensagem = ""
if tipo == "vas_estrategico":
mensagem = (
f"O serviço {servico} já está incluso no seu plano "
f"na linha de final {msisdn[-2:]} e não gera "
"cobrança adicional na fatura."
)
elif tipo == "valor_divergente":
mensagem = (
f"Identificamos uma alteração no valor do plano "
f"na linha de final {msisdn[-2:]}. Vou verificar "
"os detalhes para você."
)
elif tipo == "pro_rata":
mensagem = (
f"A fatura da linha de final {msisdn[-2:]} possui uma "
"cobrança proporcional (pro rata) porque houve mudança "
"de plano durante o ciclo de faturamento. Na próxima "
"fatura o valor volta ao normal do novo plano."
)
elif tipo == "termino_desconto":
_emit_ic(TMDTag.CHEGADA_FLUXO, input_state)
plan_info = f" do plano {nome_plano}" if nome_plano else ""
mensagem = (
f"O desconto de fidelidade{plan_info} vinculado à "
f"linha de final {msisdn[-2:]} chegou ao fim. "
"O período promocional contratado expirou e o valor "
"do plano volta ao preço original."
)
_emit_ic(TMDTag.FIM_FLUXO, input_state)
response: dict[str, Any] = {
"success": True,
"instrucoes": instrucoes,
"mensagem": mensagem,
"msisdn": msisdn,
}
if servico:
response["servico"] = servico
if nome_plano:
response["nome_plano"] = nome_plano
return ActionResult.ok(response)
@workflow_action("combinar_divergencia")
def combinar_divergencia(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
divergence = vars_state.get("consultar_divergencia", {})
explanation = vars_state.get("invoice_explanation", {})
if not isinstance(divergence, dict):
divergence = {}
if not isinstance(explanation, dict):
explanation = {}
input_state = state.get("input", {}) if isinstance(state, dict) else {}
msisdn = str(input_state.get("msisdn", ""))
resumo = str(
divergence.get("resumo")
or divergence.get("mensagem")
or ""
).strip()
detalhes = divergence.get("detalhes", {})
explicacao = str(explanation.get("mensagem", "")).strip()
success = bool(resumo or explicacao)
mensagem = resumo or explicacao
return ActionResult.ok(
{
"success": success,
"resumo": resumo,
"detalhes": detalhes if isinstance(detalhes, dict) else {},
"mensagem": mensagem,
"explicacao": explicacao,
"msisdn": msisdn,
}
)
@workflow_action("consultar_divergencia")
def query_divergence(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Consulta resumo de divergência da fatura."""
result = runtime.factory.create_divergence(
msisdn=str(params["msisdn"]),
).execute()
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha na consulta de divergencia",
**result.metadata,
)
return ActionResult.ok(_to_dict(result.data), **result.metadata)
@workflow_action("resolve_capability")
def resolve_capability_action(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Resolve uma capability via LLM Gateway (sem invocar LLM)."""
if runtime.llm_gateway is None:
return ActionResult.ok({"content": "", "source": "unavailable"})
capability_id = str(params.get("capability_id", "")).strip()
if not capability_id:
return ActionResult.fail(
"capability_id obrigatorio para resolve_capability",
)
try:
resolved = runtime.llm_gateway.resolve_capability(
capability_id=capability_id,
)
return ActionResult.ok({
"capability_id": resolved.capability_id,
"prompt_id": resolved.prompt_id,
"content": resolved.content,
"source": resolved.source,
"version": resolved.version,
})
except Exception as exc:
return ActionResult.ok(
{"content": "", "source": "fallback", "error": str(exc)},
)
@workflow_action("no_op")
def no_op(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
return ActionResult.ok({})
@workflow_action("llm_capability")
def llm_capability(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
if runtime.llm_gateway is None:
return ActionResult.fail("LLM gateway nao configurado")
capability_id = str(params.get("capability_id", "")).strip()
if not capability_id:
return ActionResult.fail("capability_id obrigatorio para llm_capability")
variables = params.get("variables", {})
if not isinstance(variables, dict):
return ActionResult.fail("variables deve ser um objeto")
user_text_raw = params.get("user_text")
user_text = None if user_text_raw is None else str(user_text_raw)
result = runtime.llm_gateway.execute(
capability_id=capability_id,
variables=variables,
user_text=user_text,
callbacks=_runtime_llm_callbacks(runtime),
tags=["workflow_action"],
metadata=_runtime_llm_metadata(runtime),
)
return ActionResult.ok(
{
"capability_id": result.capability_id,
"prompt_id": result.prompt_id,
"content": result.content,
"source": result.source,
"version": result.version,
"metadata": dict(result.metadata),
}
)
@workflow_action("buscar_fatura")
def buscar_fatura(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Busca fatura (PDF) do cliente ou dados interpretados."""
input_state = state.get("input", {}) if isinstance(state, dict) else {}
if not isinstance(input_state, dict):
input_state = {}
tentativa = int(params.get("tentativa_anterior") or 0) + 1
result = runtime.factory.create_bill_pdf(
invoice_id=str(params["invoice_id"]),
msisdn=str(params["msisdn"]),
customer_id=str(params["customer_id"]),
output=str(params.get("output", "")),
include_danfe=_normalize_bool(params.get("include_danfe", False)),
).execute()
if _result_failed_or_missing_data(result, state=state):
for tag in rct_tags_for_attempt(RCTOperation.PDF_FATURA, tentativa, success=False):
_emit_ic(tag, input_state)
return ActionResult.fail(
result.error or "Falha ao buscar fatura",
**result.metadata,
)
for tag in rct_tags_for_attempt(RCTOperation.PDF_FATURA, tentativa, success=True):
_emit_ic(tag, input_state)
payload = _to_dict(result.data)
if isinstance(payload, dict):
file_content = payload.get("file_content")
if isinstance(file_content, (bytes, bytearray)):
payload["file_content_b64"] = base64.b64encode(
bytes(file_content)
).decode("ascii")
payload["file_content"] = None
return ActionResult.ok(payload, **result.metadata)
__all__ = [
'finalizar_atendimento_action',
'formatar_capability_resposta',
'combinar_divergencia',
'query_divergence',
'resolve_capability_action',
'no_op',
'llm_capability',
'buscar_fatura',
]

View File

@@ -0,0 +1,574 @@
from __future__ import annotations
import logging
import os
import re
import time
import unicodedata as ud
from dataclasses import (
asdict,
is_dataclass,
)
from datetime import (
datetime,
timezone,
)
from decimal import (
Decimal,
InvalidOperation,
)
from typing import Any
from pydantic import BaseModel
from agente_contas_tim.integrations import agent_framework_bridge
from agente_contas_tim.integrations.noc_events import emit_api_content_mismatch
from agente_contas_tim.models.service_info import ServiceInfo
from agente_contas_tim.observability import get_session_id
_IDEMPOTENCY_TTL_SECONDS = int(os.getenv("TIM_IDEMPOTENCY_TTL_SECONDS", "3600"))
_IDEMPOTENCY_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
logger = logging.getLogger(__name__)
def _emit_vaa(
tag: str,
ic_base: dict[str, Any],
*,
gsm: str = "",
extra_metadata: dict[str, Any] | None = None,
) -> None:
"""Emite um IC do fluxo VAS Avulso (VAA.001VAA.017)."""
try:
metadata = {
**ic_base,
"tag": tag,
"eventDate": int(datetime.now(timezone.utc).timestamp() * 1000),
}
if isinstance(extra_metadata, dict):
metadata.update(extra_metadata)
if gsm:
metadata["gsm"] = gsm
agent_framework_bridge.event(tag, metadata=metadata)
except Exception:
logger.warning("_emit_vaa tag=%s falhou silenciosamente", tag)
def _build_ic_context(
input_state: dict[str, Any] | None,
*,
gsm: str = "",
) -> dict[str, Any]:
if not isinstance(input_state, dict):
input_state = {}
return {
"sessionId": str(get_session_id() or ""),
"gsm": str(gsm or "").strip(),
"ani": str(input_state.get("ani", "") or "").strip(),
"uraCallId": str(input_state.get("ura_call_id", "") or "").strip(),
"agentId": "contas",
"channelId": str(
input_state.get("channel_id")
or input_state.get("channelId")
or "URA"
).strip()
or "URA",
}
def _emit_ic(
ic_code: str,
input_state: dict[str, Any],
*,
extra_metadata: dict[str, Any] | None = None,
) -> None:
"""Emite um IC via agent_framework_bridge com metadados padrao."""
try:
metadata: dict[str, Any] = {
"sessionId": str(get_session_id() or ""),
"tag": ic_code,
"eventDate": int(datetime.now(timezone.utc).timestamp() * 1000),
"uraCallId": str(input_state.get("ura_call_id", "") or "").strip(),
"gsm": str(input_state.get("msisdn", "") or "").strip(),
"agentId": "contas",
"channelId": str(
input_state.get("channel_id")
or input_state.get("channelId")
or "URA"
).strip()
or "URA",
}
ani = str(input_state.get("ani", "") or "").strip()
if ani:
metadata["ani"] = ani
message_id = str(
input_state.get("message_id") or input_state.get("messageId") or ""
).strip()
if message_id:
metadata["messageId"] = message_id
if isinstance(extra_metadata, dict):
metadata.update(extra_metadata)
agent_framework_bridge.event(ic_code, metadata=metadata)
except Exception:
logger.debug("_emit_ic: falha ao emitir %s", ic_code, exc_info=True)
def _idempotency_get(key: str) -> dict[str, Any] | None:
if not key:
return None
now = time.monotonic()
cached = _IDEMPOTENCY_CACHE.get(key)
if cached is None:
return None
created_at, value = cached
if now - created_at > _IDEMPOTENCY_TTL_SECONDS:
_IDEMPOTENCY_CACHE.pop(key, None)
return None
return value
def _idempotency_set(key: str, value: dict[str, Any]) -> None:
if not key:
return
_IDEMPOTENCY_CACHE[key] = (time.monotonic(), value)
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _to_epoch_millis(value: Any) -> int:
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return int(value.timestamp() * 1000)
raw = str(value or "").strip()
if not raw:
return int(datetime.now(timezone.utc).timestamp() * 1000)
if re.fullmatch(r"\d+(?:\.\d+)?", raw):
number = float(raw)
if number > 100_000_000_000:
return int(number)
return int(number * 1000)
iso_raw = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
try:
parsed = datetime.fromisoformat(iso_raw)
except ValueError:
return int(datetime.now(timezone.utc).timestamp() * 1000)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return int(parsed.timestamp() * 1000)
def _to_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
text = str(value or "").strip().lower()
return text in {"1", "true", "t", "sim", "yes", "y"}
def _to_dict(data: Any) -> Any:
if isinstance(data, BaseModel):
return data.model_dump(by_alias=True)
if is_dataclass(data):
return _to_dict(asdict(data))
if isinstance(data, tuple):
return [_to_dict(item) for item in data]
if isinstance(data, list):
return [_to_dict(item) for item in data]
if isinstance(data, dict):
return {key: _to_dict(value) for key, value in data.items()}
return data
def _result_failed_or_missing_data(result: Any, *, state: dict[str, Any]) -> bool:
if not getattr(result, "success", False):
return True
if getattr(result, "data", None) is not None:
return False
input_state = state.get("input", {}) if isinstance(state, dict) else {}
if not isinstance(input_state, dict):
input_state = {}
emit_api_content_mismatch(
values={
"msisdn": str(input_state.get("msisdn", "") or "").strip(),
"invoice_id": str(
input_state.get("invoice_id")
or input_state.get("current_invoice_number")
or ""
).strip(),
"channel_id": str(input_state.get("channel_id", "") or "").strip(),
"ura_call_id": str(input_state.get("ura_call_id", "") or "").strip(),
},
reason="workflow_action_command_success_without_data",
command=str(getattr(result, "metadata", {}).get("command", "") or ""),
api_response_payload=getattr(result, "metadata", {}),
)
return True
def _runtime_llm_callbacks(runtime: WorkflowRuntimeContext) -> list[Any] | None:
callbacks = list(runtime.llm_callbacks)
return callbacks or None
def _runtime_llm_metadata(runtime: WorkflowRuntimeContext) -> dict[str, Any]:
return dict(runtime.llm_metadata)
def _service_from_dict(service: dict[str, Any] | None) -> ServiceInfo | None:
if service is None:
return None
return ServiceInfo(
msisdn=str(service.get("msisdn", "")),
app_id=str(service.get("app_id", "")),
csp_id=str(service.get("csp_id", "")),
ippid=str(service.get("ippid", "")),
service_name=str(service.get("service_name", "")),
status=str(service.get("status", "")),
extra=dict(service.get("extra", {})),
)
def _extract_amount(extra: dict[str, Any] | None) -> str:
if not isinstance(extra, dict):
return ""
details = extra.get("details", {})
if not isinstance(details, dict):
details = {}
return str(
extra.get("valor")
or extra.get("price")
or details.get("valor")
or details.get("price")
or ""
)
def _extract_boleto_code(extra: dict[str, Any] | None) -> str:
if not isinstance(extra, dict):
return ""
return str(
extra.get("codigo_boleto")
or extra.get("boleto_code")
or extra.get("linha_digitavel")
or ""
).strip()
def _find_matching_service(
requested_name: str,
active_services: dict[str, dict[str, Any]],
) -> dict[str, Any] | None:
lowered_name = requested_name.strip().lower()
exact = active_services.get(lowered_name)
if exact:
return exact
normalized_requested = _normalize_service_name(lowered_name)
for key, value in active_services.items():
if _normalize_service_name(key) == normalized_requested:
return value
return next(
(
value
for key, value in active_services.items()
if (
lowered_name in key
or key in lowered_name
or normalized_requested in _normalize_service_name(key)
or _normalize_service_name(key) in normalized_requested
)
),
None,
)
def _normalize_service_name(value: str) -> str:
text = ud.normalize("NFKD", str(value or ""))
text = "".join(ch for ch in text if not ud.combining(ch))
text = re.sub(r"[^a-zA-Z0-9]+", " ", text).strip().lower()
return re.sub(r"\s+", " ", text)
def _resolve_service_csp_id(
matched_service: dict[str, Any],
*,
fallback_csp_id: str,
) -> str:
if not isinstance(matched_service, dict):
return fallback_csp_id
context = matched_service.get("service_context")
if isinstance(context, dict):
csp_id = str(
context.get("csp_id")
or context.get("cspId")
or (
context.get("csp", {}).get("id")
if isinstance(context.get("csp"), dict)
else ""
)
or ""
).strip()
if csp_id:
return csp_id
return fallback_csp_id
def _normalize_amount(value: Any) -> str:
text = str(value or "").strip()
if not text:
return ""
text = (
text.replace("R$", "")
.replace("$", "")
.replace("\u00a0", "")
.strip()
)
text = text.replace("", "-").replace("", "-")
has_neg = text.startswith("-")
if has_neg:
text = text[1:]
else:
text = text.lstrip("+")
text = re.sub(r"\s+", "", text)
text = re.sub(r"[^0-9.,-]", "", text)
if not text:
return ""
has_any = ("." in text) or ("," in text)
if not has_any:
return f"{'-' if has_neg else ''}{text}"
if "." in text and "," in text:
last_dot = text.rfind(".")
last_comma = text.rfind(",")
if last_comma > last_dot:
int_part = text[:last_comma].replace(".", "")
frac_part = text[last_comma + 1 :]
else:
int_part = text[:last_dot].replace(",", "")
frac_part = text[last_dot + 1 :]
normalized = f"{'-' if has_neg else ''}{int_part}.{frac_part}"
return normalized
sep = "." if "." in text else ","
parts = text.split(sep)
if len(parts) > 2:
frac = parts[-1]
if len(frac) <= 2:
normalized = f"{'-' if has_neg else ''}{''.join(parts[:-1])}.{frac}"
else:
normalized = f"{'-' if has_neg else ''}{''.join(parts)}"
return normalized
int_part, frac_part = parts[0], parts[1]
if len(frac_part) <= 2:
normalized = (
f"{'-' if has_neg else ''}{int_part.replace(',', '').replace('.', '')}."
f"{frac_part}"
)
else:
normalized = f"{'-' if has_neg else ''}{int_part}{frac_part}"
return normalized
def _parse_amount(value: Any) -> Decimal | None:
normalized = _normalize_amount(value)
if not normalized:
return None
try:
return Decimal(normalized)
except (InvalidOperation, ValueError):
return None
def _format_amount(value: Decimal | None) -> str:
if value is None:
return ""
return f"{value:.2f}".replace(".", ",")
def _build_sr_notes(
*,
sms_enviado: bool,
codigo_boleto: str,
data_credito_proxima_fatura: str,
) -> str:
if sms_enviado:
return (
f"SMS com código do boleto enviado. Código: {codigo_boleto}."
if codigo_boleto
else "SMS com código do boleto enviado."
)
if data_credito_proxima_fatura:
return (
"Crédito do valor do serviço cancelado registrado "
f"na próxima fatura com vencimento em {data_credito_proxima_fatura}."
)
return "Crédito do valor do serviço cancelado registrado na próxima fatura."
def _format_list_pt_br(values: list[str]) -> str:
cleaned = [str(item).strip() for item in values if str(item).strip()]
if not cleaned:
return ""
if len(cleaned) == 1:
return cleaned[0]
if len(cleaned) == 2:
return f"{cleaned[0]} e {cleaned[1]}"
return f"{', '.join(cleaned[:-1])} e {cleaned[-1]}"
def _final_msisdn(msisdn: str) -> str:
digits = re.sub(r"\D", "", msisdn)
if len(digits) >= 4:
return digits[-4:]
return msisdn[-4:] if len(msisdn) >= 4 else msisdn
def _first_text_from_params_or_state(
state: dict[str, Any],
params: dict[str, Any],
*keys: str,
) -> str:
input_state = state.get("input", {}) if isinstance(state, dict) else {}
sources: list[dict[str, Any]] = []
if isinstance(params, dict):
sources.append(params)
if isinstance(input_state, dict):
sources.append(input_state)
for source in sources:
for key in keys:
value = source.get(key)
if value is None:
continue
text = str(value).strip()
if text:
return text
return ""
def _first_value_from_params_or_state(
state: dict[str, Any],
params: dict[str, Any],
*keys: str,
) -> Any:
input_state = state.get("input", {}) if isinstance(state, dict) else {}
sources: list[dict[str, Any]] = []
if isinstance(params, dict):
sources.append(params)
if isinstance(input_state, dict):
sources.append(input_state)
for source in sources:
for key in keys:
if key not in source:
continue
value = source.get(key)
if value is None:
continue
if isinstance(value, str):
text = value.strip()
if text:
return text
continue
return value
return None
def _contains_any_keyword(value: str, keywords: tuple[str, ...]) -> bool:
normalized = str(value or "").strip().lower()
if not normalized:
return False
return any(keyword in normalized for keyword in keywords)
def _normalize_number_text(value: Any, *, default: str = "0") -> str:
text = str(value).strip()
if not text:
return default
cleaned = text.replace("R$", "").replace(" ", "")
if "," in cleaned:
cleaned = cleaned.replace(".", "").replace(",", ".")
try:
normalized = format(Decimal(cleaned), "f")
except (InvalidOperation, ValueError):
return default
if "." in normalized:
normalized = normalized.rstrip("0").rstrip(".")
return normalized or default
def _normalize_bool(value: Any, *, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
text = str(value).strip().lower()
if not text:
return default
if text in {"true", "1", "sim", "yes", "y"}:
return True
if text in {"false", "0", "nao", "não", "no", "n"}:
return False
return default
def _map_customer_status_code(value: Any) -> str:
text = str(value).strip().lower()
if not text:
return ""
if text in {"0", "1", "2", "3"}:
return text
if "inativ" in text:
return "0"
if "ativ" in text:
return "1"
if "suspens" in text:
return "2"
if "bloque" in text:
return "3"
return ""
def _text_from_keys(source: Any, keys: tuple[str, ...]) -> str:
if not isinstance(source, dict):
return ""
return " ".join(str(source.get(key) or "") for key in keys).strip()
__all__ = [
'_emit_vaa',
'_build_ic_context',
'_emit_ic',
'_idempotency_get',
'_idempotency_set',
'_utc_now_iso',
'_to_epoch_millis',
'_to_bool',
'_to_dict',
'_result_failed_or_missing_data',
'_runtime_llm_callbacks',
'_runtime_llm_metadata',
'_service_from_dict',
'_extract_amount',
'_extract_boleto_code',
'_find_matching_service',
'_normalize_service_name',
'_resolve_service_csp_id',
'_parse_amount',
'_format_amount',
'_build_sr_notes',
'_format_list_pt_br',
'_final_msisdn',
'_first_text_from_params_or_state',
'_first_value_from_params_or_state',
'_contains_any_keyword',
'_normalize_number_text',
'_normalize_bool',
'_map_customer_status_code',
'_text_from_keys',
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
import importlib
import pkgutil
from threading import Lock
_loaded_packages: set[str] = set()
_lock = Lock()
def _load_actions(package_name: str) -> None:
package = importlib.import_module(package_name)
package_path = getattr(package, "__path__", None)
if package_path is None:
return
for module in pkgutil.walk_packages(package_path, package_name + "."):
importlib.import_module(module.name)
def ensure_actions_loaded(
package_name: str = "agente_contas_tim.workflows.actions",
) -> None:
with _lock:
if package_name in _loaded_packages:
return
_load_actions(package_name)
_loaded_packages.add(package_name)

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import Any
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError
class CancelarVasAvulsoItem(BaseModel):
"""Item de entrada do action `cancelamento_vas_avulso_batch`."""
model_config = ConfigDict(
str_strip_whitespace=True,
populate_by_name=True,
)
msisdn: str = Field(
...,
min_length=1,
description=(
"Numero completo da linha do cliente que possui o "
"servico VAS avulso a cancelar."
),
validation_alias=AliasChoices("msisdn", "Msisdn", "MSISDN"),
)
service: str = Field(
...,
min_length=1,
description=(
"Nome do servico VAS avulso a cancelar. "
"Ex: 'TIM Fashion Mensal', 'Galinha Pintadinha'."
"O nome deve ser fiel ao JSON da fatura SEM regra de vocalização ou modificação"
"'Aluguel de filmes 1', 'Aluguel de Filme 3'"
),
validation_alias=AliasChoices("service", "servico", "name"),
)
value: float = Field(
...,
description= "Valor do serviço"
)
def parse_cancel_vas_items(raw: Any) -> list[CancelarVasAvulsoItem]:
"""Valida cada item via Pydantic, descartando entradas invalidas."""
if not isinstance(raw, list):
return []
parsed: list[CancelarVasAvulsoItem] = []
for item in raw:
try:
parsed.append(CancelarVasAvulsoItem.model_validate(item))
except ValidationError:
continue
return parsed

View File

@@ -0,0 +1,548 @@
from __future__ import annotations
import logging
from datetime import (
datetime,
timezone,
)
from typing import Any
from agente_contas_tim.integrations import agent_framework_bridge
from agente_contas_tim.constants.ic_tags_enum import (
CVNTag,
MPITag,
VEBTag,
)
from agente_contas_tim.integrations.agent_event_metadata import build_billing_id_payload
from agente_contas_tim.integrations.rct_policy import RCTOperation, rct_tags_for_attempt
from agente_contas_tim.observability import get_session_id
from agente_contas_tim.workflows.actions.registry import (
WorkflowRuntimeContext,
workflow_action,
)
from agente_contas_tim.workflows.runtime_types import ActionResult
from agente_contas_tim.workflows.actions.common.helpers import (
_emit_ic,
_first_text_from_params_or_state,
_result_failed_or_missing_data,
_runtime_llm_callbacks,
_runtime_llm_metadata,
_to_dict,
)
from agente_contas_tim.workflows.actions.invoice_explanation.helpers import (
_extract_invoice_explanation_text,
_extract_rag_context,
_normalizar_invoice_explanation_mensagem,
)
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
_INVOICE_EXPLANATION_TRAILER = "Com essa explicação, sanei sua dúvida?"
def _metadata_value(metadata: dict[str, Any], *keys: str) -> Any:
for key in keys:
value = metadata.get(key)
if value is None:
continue
if isinstance(value, str) and not value.strip():
continue
return value
return None
def _int_or_none(value: Any) -> int | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, int):
return value
raw = str(value).strip()
if not raw:
return None
try:
return int(raw)
except ValueError:
return None
def _rct_api_metadata_from_result(
result: Any,
input_state: dict[str, Any],
) -> dict[str, Any]:
result_metadata = getattr(result, "metadata", None)
if not isinstance(result_metadata, dict):
return {}
metadata: dict[str, Any] = {}
api_url = _metadata_value(result_metadata, "api_url", "apiUrl")
if api_url is not None:
metadata["apiUrl"] = str(api_url)
status_code = _int_or_none(
_metadata_value(
result_metadata,
"api_status_code",
"apiStatusCode",
"status_code",
)
)
if status_code is not None:
metadata["apiStatusCode"] = status_code
response_payload = _metadata_value(
result_metadata,
"api_response_payload",
"apiResponsePayload",
"error_body",
)
if response_payload is not None:
metadata["apiResponsePayload"] = str(response_payload)
latency_ms = _int_or_none(
_metadata_value(result_metadata, "latency_ms", "latencyMs")
)
if latency_ms is not None:
metadata["latencyMs"] = max(0, latency_ms)
agent_specific_data = build_billing_id_payload(
{
"invoice_id": str(
input_state.get("invoice_id")
or input_state.get("current_invoice_number")
or ""
).strip()
}
)
if agent_specific_data != "{}":
metadata["agentSpecificData"] = agent_specific_data
return metadata
@workflow_action("invoice_explanation")
def invoice_explanation(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Retorna explicacao fixa da fatura."""
msisdn = _first_text_from_params_or_state(
state,
params,
"msisdn",
"Msisdn",
"MSISDN",
)
if not msisdn:
return ActionResult.fail("msisdn obrigatório para invoice_explanation")
result = runtime.factory.create_invoice_explanation(
msisdn=msisdn,
customer_id=_first_text_from_params_or_state(
state,
params,
"customer_id",
"customerId",
),
current_invoice_number=_first_text_from_params_or_state(
state,
params,
"current_invoice_number",
"currentInvoiceNumber",
"invoice_id",
"invoiceId",
),
past_invoice_number=_first_text_from_params_or_state(
state,
params,
"past_invoice_number",
"pastInvoiceNumber",
),
current_invoice_due_date=_first_text_from_params_or_state(
state,
params,
"current_invoice_due_date",
"currentInvoiceDueDate",
),
past_invoice_due_date=_first_text_from_params_or_state(
state,
params,
"past_invoice_due_date",
"pastInvoiceDueDate",
),
channel=_first_text_from_params_or_state(
state,
params,
"channel",
"Channel",
),
).execute()
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha ao montar explicacao da fatura",
**result.metadata,
)
return ActionResult.ok(_to_dict(result.data), **result.metadata)
@workflow_action("preparar_invoice_explanation")
def preparar_invoice_explanation(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Busca a explicacao bruta da fatura via InvoiceExplanationCommand.
Reaproveita a explicacao gerada na primeira passagem ao re-pausar
(caminho OUTRO), ou a explicacao_base recebida do prefetch/cache,
evitando reexecutar o command. Nao aplica vocalizacao nem
enriquecimento — esses passos ficam em `formatar_invoice_explanation`.
"""
input_state = state.get("input", {}) if isinstance(state, dict) else {}
if not isinstance(input_state, dict):
input_state = {}
tentativa_anterior = int(params.get("tentativa_anterior") or 0)
if tentativa_anterior == 0:
_emit_ic(CVNTag.INICIO_FLUXO, input_state)
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
cached = vars_state.get("preparar", {}) if isinstance(vars_state, dict) else {}
explicacao_base = ""
if isinstance(cached, dict):
previous = cached.get("explicacao_base")
if isinstance(previous, str) and previous.strip():
explicacao_base = previous.strip()
metadata: dict[str, Any] = {}
if not explicacao_base:
explicacao_base = _first_text_from_params_or_state(
state,
params,
"explicacao_base",
"invoice_explanation_base",
"invoiceExplanationBase",
)
if not explicacao_base:
msisdn = _first_text_from_params_or_state(
state,
params,
"msisdn",
"Msisdn",
"MSISDN",
)
if not msisdn:
# Pre-validacao falhou antes de chamar o servico → loop de retry
_emit_ic(CVNTag.PRE_VALIDACAO_FAIL, input_state)
return ActionResult.ok(
{
"success": False,
"service_failed": False,
"tentativa": tentativa_anterior + 1,
}
)
# Pre-validacao ok → intencao validada antes de chamar o servico
_emit_ic(CVNTag.PRE_VALIDACAO_OK, input_state)
result = runtime.factory.create_invoice_explanation(
msisdn=msisdn,
customer_id=_first_text_from_params_or_state(
state,
params,
"customer_id",
"customerId",
),
current_invoice_number=_first_text_from_params_or_state(
state,
params,
"current_invoice_number",
"currentInvoiceNumber",
"invoice_id",
"invoiceId",
),
past_invoice_number=_first_text_from_params_or_state(
state,
params,
"past_invoice_number",
"pastInvoiceNumber",
),
current_invoice_due_date=_first_text_from_params_or_state(
state,
params,
"current_invoice_due_date",
"currentInvoiceDueDate",
),
past_invoice_due_date=_first_text_from_params_or_state(
state,
params,
"past_invoice_due_date",
"pastInvoiceDueDate",
),
channel=_first_text_from_params_or_state(
state,
params,
"channel",
"Channel",
),
).execute()
if _result_failed_or_missing_data(result, state=state):
# Falha no servico → vai direto pro FIM, sem retry
_emit_ic(CVNTag.SERVICO_FAIL, input_state)
rct_metadata = _rct_api_metadata_from_result(result, input_state)
for tag in rct_tags_for_attempt(
RCTOperation.BASE_CONHECIMENTO,
tentativa_anterior + 1,
success=False,
):
_emit_ic(tag, input_state, extra_metadata=rct_metadata)
return ActionResult.ok(
{
"success": False,
"service_failed": True,
}
)
explicacao_base = _extract_invoice_explanation_text(_to_dict(result.data))
if not explicacao_base:
_emit_ic(CVNTag.SERVICO_FAIL, input_state)
rct_metadata = _rct_api_metadata_from_result(result, input_state)
for tag in rct_tags_for_attempt(
RCTOperation.BASE_CONHECIMENTO,
tentativa_anterior + 1,
success=False,
):
_emit_ic(tag, input_state, extra_metadata=rct_metadata)
return ActionResult.ok(
{
"success": False,
"service_failed": True,
}
)
_emit_ic(CVNTag.SERVICO_OK, input_state)
for tag in rct_tags_for_attempt(
RCTOperation.BASE_CONHECIMENTO,
tentativa_anterior + 1,
success=True,
):
_emit_ic(tag, input_state)
metadata = dict(result.metadata)
else:
# Cache hit — intencao ja validada sem nova chamada ao servico
_emit_ic(CVNTag.PRE_VALIDACAO_OK, input_state)
_emit_ic(CVNTag.SERVICO_OK, input_state)
for tag in rct_tags_for_attempt(
RCTOperation.BASE_CONHECIMENTO,
tentativa_anterior + 1,
success=True,
):
_emit_ic(tag, input_state)
return ActionResult.ok(
{
"success": True,
"explicacao_base": explicacao_base,
"tentativa": 0,
},
**metadata,
)
@workflow_action("checar_tentativa_cvn")
def checar_tentativa_cvn(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Verifica contador de tentativas do fluxo CVN e emite CVN.004 ou CVN.005."""
input_state = state.get("input", {}) if isinstance(state, dict) else {}
if not isinstance(input_state, dict):
input_state = {}
tentativa = int(params.get("tentativa") or 1)
if tentativa > 2:
_emit_ic(CVNTag.LIMITE_TENTATIVAS, input_state)
else:
_emit_ic(CVNTag.DENTRO_LIMITE, input_state)
return ActionResult.ok({"tentativa": tentativa})
@workflow_action("formatar_invoice_explanation")
def formatar_invoice_explanation(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Reescreve a explicacao bruta para voz, enriquecendo com juros/multas
a partir do invoice_detail, via capability LLM dedicada.
Recebe `explicacao_base` (obrigatorio) e `invoice_detail` (opcional)
nos params. Quando `trailer_override` for informado, instrui o LLM a
usar essa frase como fechamento (em vez da pergunta padrao). Util
para o caminho de reforco (OUTRO).
Em caso de falha do LLM, faz fallback para concatenacao simples
`explicacao_base + trailer` para nao bloquear o fluxo.
"""
explicacao_base = str(
params.get("explicacao_base", "") or ""
).strip()
if not explicacao_base:
return ActionResult.fail(
"explicacao_base obrigatoria para formatar_invoice_explanation"
)
invoice_detail = str(
params.get("invoice_detail", "") or ""
).strip()
trailer_override = str(
params.get("trailer_override", "") or ""
).strip()
fallback_trailer = (
trailer_override or _INVOICE_EXPLANATION_TRAILER
)
mensagem = ""
if runtime.llm_gateway is not None:
try:
llm_result = runtime.llm_gateway.execute(
capability_id="fluxo_invoice_explanation_reescrita",
variables={
"explicacao_base": explicacao_base,
"invoice_detail": invoice_detail or "(vazio)",
"trailer_override": trailer_override,
},
user_text=explicacao_base,
callbacks=_runtime_llm_callbacks(runtime),
tags=["workflow_action"],
metadata=_runtime_llm_metadata(runtime),
)
mensagem = str(
getattr(llm_result, "content", "") or ""
).strip()
mensagem = _normalizar_invoice_explanation_mensagem(
mensagem,
trailer_override=trailer_override,
)
except Exception:
logger.exception(
"formatar_invoice_explanation: falha ao invocar "
"capability fluxo_invoice_explanation_reescrita"
)
if not mensagem:
mensagem = f"{explicacao_base}\n\n{fallback_trailer}"
return ActionResult.ok(
{
"mensagem": mensagem,
"await_user_input": True,
}
)
@workflow_action("registrar_atendimento_invoice_explanation")
def registrar_atendimento_invoice_explanation(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Stub de registro do atendimento de invoice_explanation.
Implementacao real ainda nao definida; deixa apenas um warning para
indicar que o caminho foi exercitado. A resposta ao cliente fica a
cargo do orchestrator (LLM) no proximo round.
"""
resposta = params.get("resposta_usuario")
ic = str(params.get("ic", "")).strip()
resposta_norm = str(resposta or "").strip().upper()
ic_extra = ""
cvn_ic = ""
if resposta_norm == "SIM":
ic_extra = MPITag.EXPLICACAO_SIM
cvn_ic = CVNTag.CLIENTE_CONCORDOU
elif resposta_norm == "NAO":
ic_extra = MPITag.EXPLICACAO_NAO
cvn_ic = CVNTag.CLIENTE_NAO_CONCORDOU
logger.warning(
"registrar_atendimento_invoice_explanation: registro ainda nao "
"implementado (resposta_usuario=%s)",
resposta,
)
if cvn_ic:
input_state = state.get("input", {}) if isinstance(state, dict) else {}
if not isinstance(input_state, dict):
input_state = {}
_emit_ic(cvn_ic, input_state)
if ic_extra:
try:
input_state = (
state.get("input", {}) if isinstance(state, dict) else {}
)
if not isinstance(input_state, dict):
input_state = {}
rag_info = _extract_rag_context(state)
message_id = _first_text_from_params_or_state(
state,
params,
"message_id",
"messageId",
)
channel_id = str(
input_state.get("channel_id")
or input_state.get("channelId")
or "URA"
).strip() or "URA"
event_date = int(datetime.now(timezone.utc).timestamp() * 1000)
customer_message = str(
input_state.get("customer_message")
or input_state.get("customerMessage")
or resposta
or ""
).strip()
metadata = {
"sessionId": get_session_id(),
"tag": ic_extra,
"eventDate": event_date,
"uraCallId": str(
input_state.get("ura_call_id", "")
).strip(),
"ani": str(input_state.get("ani", "")).strip(),
"gsm": str(input_state.get("msisdn", "")).strip(),
"agentId": "contas",
"channelId": channel_id,
"ragRetrievedDocuments": str(
rag_info.get("ragRetrievedDocuments", "")
),
"ragSelectedDocuments": str(
rag_info.get("ragSelectedDocuments", "")
),
"noMatchRag": bool(rag_info.get("noMatchRag", True)),
"llmResponse": str(input_state.get("llm_response", "") or ""),
"customerMessage": customer_message,
}
if message_id:
metadata["messageId"] = message_id
agent_framework_bridge.event(ic_extra, metadata=metadata)
if ic == VEBTag.EXPLICACAO_ACEITA:
veb_metadata = dict(metadata)
veb_metadata["tag"] = ic
agent_framework_bridge.event(ic, metadata=veb_metadata)
except Exception:
logger.debug(
"registrar_atendimento_invoice_explanation: falha ao emitir %s",
ic_extra,
exc_info=True,
)
payload: dict[str, Any] = {}
if ic:
payload["ic"] = ic
return ActionResult.ok(payload)
__all__ = [
'invoice_explanation',
'preparar_invoice_explanation',
'checar_tentativa_cvn',
'formatar_invoice_explanation',
'registrar_atendimento_invoice_explanation',
]

View File

@@ -0,0 +1,125 @@
from __future__ import annotations
import re
from typing import Any
_INVOICE_EXPLANATION_TRAILER = "Com essa explicação, sanei sua dúvida?"
_INVOICE_EXPLANATION_TRAILER_PATTERN = re.compile(
r"com essa explica[cç][aã]o,?\s+sanei sua d[uú]vida\??$",
re.IGNORECASE,
)
_INVOICE_EXPLANATION_PADRAO_SENTINEL = re.compile(
r"\s*\(use o padr[aã]o\)\s*$",
re.IGNORECASE,
)
def _normalizar_mensagem_voz(
mensagem: str,
*,
trailer_override: str,
trailer_default: str = "",
trailer_pattern: re.Pattern[str] | None = None,
) -> str:
"""Pos-processa a mensagem reescrita pelo LLM.
Remove o sentinel "(use o padrao)" caso o modelo o tenha emitido e,
quando `trailer_override` estiver vazio, garante o `trailer_default`
ao final (a menos que o texto ja termine com `trailer_pattern`).
"""
if not mensagem:
return mensagem
if trailer_override:
return mensagem
mensagem = _INVOICE_EXPLANATION_PADRAO_SENTINEL.sub("", mensagem).strip()
if not trailer_default:
return mensagem
if trailer_pattern is not None and trailer_pattern.search(mensagem):
return mensagem
return f"{mensagem} {trailer_default}".strip()
def _normalizar_invoice_explanation_mensagem(
mensagem: str,
*,
trailer_override: str,
) -> str:
return _normalizar_mensagem_voz(
mensagem,
trailer_override=trailer_override,
trailer_default=_INVOICE_EXPLANATION_TRAILER,
trailer_pattern=_INVOICE_EXPLANATION_TRAILER_PATTERN,
)
def _extract_rag_context(state: dict[str, Any]) -> dict[str, Any]:
"""Extrai campos de RAG do estado para inclusão em eventos MPI."""
vars_state = state.get("vars", {})
if not isinstance(vars_state, dict):
return {}
# Procura por qualquer nó que tenha retornado dados de RAG.
# Prioriza os nós conhecidos por realizar busca RAG.
for node_id in ("buscar_informacao", "buscar_informacao_rag", "preparar"):
node_result = vars_state.get(node_id)
if not isinstance(node_result, dict):
continue
# Se o nó já tem os campos formatados, usa eles
if "ragRetrievedDocuments" in node_result:
return {
"ragRetrievedDocuments": node_result.get("ragRetrievedDocuments", ""),
"ragSelectedDocuments": node_result.get("ragSelectedDocuments", ""),
"noMatchRag": node_result.get("noMatchRag", True),
}
# Caso contrário, tenta construir a partir da lista 'documents'
documents = node_result.get("documents")
if isinstance(documents, (list, tuple)):
retrieved_titles = []
selected_titles = []
for doc in documents:
if not isinstance(doc, dict):
continue
t = (
doc.get("title_proc")
or doc.get("title")
or doc.get("chunk_texto")
or ""
)
if t:
title = str(t)
retrieved_titles.append(title)
# Threshold de 0.6 para considerar como selecionado (distância menor é melhor)
distance = float(doc.get("distance", 1.0))
if distance <= 0.6:
selected_titles.append(title)
return {
"ragRetrievedDocuments": "|".join(retrieved_titles),
"ragSelectedDocuments": "|".join(selected_titles),
"noMatchRag": len(documents) == 0,
}
return {}
def _extract_invoice_explanation_text(data: Any) -> str:
if isinstance(data, dict):
for key in ("mensagem", "message", "explicacao", "texto"):
value = data.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
if isinstance(data, str) and data.strip():
return data.strip()
return ""
__all__ = [
'_normalizar_mensagem_voz',
'_normalizar_invoice_explanation_mensagem',
'_extract_rag_context',
'_extract_invoice_explanation_text',
]

View File

@@ -0,0 +1,726 @@
from __future__ import annotations
import logging
import re
from decimal import Decimal
from typing import Any
from agente_contas_tim.protocol_triplets import resolve_protocol_triplet
from agente_contas_tim.text_utils import vocalize_digits
from agente_contas_tim.workflows.actions.registry import (
WorkflowRuntimeContext,
workflow_action,
)
from agente_contas_tim.workflows.runtime_types import ActionResult
from agente_contas_tim.workflows.actions.common.helpers import (
_final_msisdn,
_first_text_from_params_or_state,
_first_value_from_params_or_state,
_format_amount,
_normalize_number_text,
_result_failed_or_missing_data,
_runtime_llm_callbacks,
_runtime_llm_metadata,
_to_dict,
_utc_now_iso,
)
from agente_contas_tim.workflows.actions.invoice_explanation.helpers import _normalizar_mensagem_voz
from agente_contas_tim.workflows.actions.pro_rata.helpers import (
_build_pro_rata_ajuste_recusado_msg,
_build_pro_rata_contestacao_tool_payload,
_build_pro_rata_contestation_items,
_build_pro_rata_controle_msg,
_build_pro_rata_human_validation_text,
_build_pro_rata_oferta_ajuste_msg,
_build_pro_rata_sem_controle_msg,
_decimal_from_any,
_extract_pro_rata_contestation_context,
_fetch_invoice_payload_pro_rata,
_money,
_resolve_plano_controle,
_resolve_pro_rata_liquid_value,
_resolve_pro_rata_period_days,
)
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
@workflow_action("formatar_pro_rata")
def formatar_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Reescreve a mensagem crua do pro-rata aplicando vocalizacao para TTS.
Recebe `mensagem_base` (obrigatorio) e, opcionalmente, `trailer_override`
para forcar uma frase final (ex.: caminho de reperguntar). Em caso de
falha do LLM, faz fallback para a mensagem crua para nao bloquear o
fluxo.
"""
mensagem_base = str(params.get("mensagem_base", "") or "").strip()
if not mensagem_base:
return ActionResult.fail(
"mensagem_base obrigatoria para formatar_pro_rata"
)
trailer_override = str(params.get("trailer_override", "") or "").strip()
mensagem = ""
if runtime.llm_gateway is not None:
try:
llm_result = runtime.llm_gateway.execute(
capability_id="fluxo_pro_rata_reescrita",
variables={
"mensagem_base": mensagem_base,
"trailer_override": trailer_override,
},
user_text=mensagem_base,
callbacks=_runtime_llm_callbacks(runtime),
tags=["workflow_action"],
metadata=_runtime_llm_metadata(runtime),
)
mensagem = str(getattr(llm_result, "content", "") or "").strip()
mensagem = _normalizar_mensagem_voz(
mensagem,
trailer_override=trailer_override,
)
except Exception:
logger.exception(
"formatar_pro_rata: falha ao invocar capability "
"fluxo_pro_rata_reescrita"
)
if not mensagem:
mensagem = (
f"{mensagem_base} {trailer_override}".strip()
if trailer_override
else mensagem_base
)
return ActionResult.ok({"mensagem": mensagem})
@workflow_action("preparar_pro_rata")
def preparar_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Monta mensagem inicial do pro-rata e decide se pausa por Plano Controle."""
raw_planos = params.get("planos")
planos = [p for p in (raw_planos or []) if isinstance(p, dict)]
has_controle = bool(params.get("has_plano_controle", False))
if len(planos) != 2 or not all(
str(p.get("desc", "")).strip() for p in planos
):
return ActionResult.fail(
"pro_rata exige exatamente dois planos com 'desc' preenchido."
)
if not has_controle:
return ActionResult.ok(
{
"await_user_input": False,
"mensagem": _build_pro_rata_sem_controle_msg(planos),
"mensagem_pos_aceite": "",
"mensagem_reperguntar_esclarecimento": "",
"mensagem_oferta_ajuste": "",
"mensagem_reperguntar_ajuste": "",
"mensagem_ajuste_recusado": "",
"has_plano_controle": False,
}
)
mensagem = _build_pro_rata_controle_msg(planos)
mensagem_oferta_ajuste = _build_pro_rata_oferta_ajuste_msg()
return ActionResult.ok(
{
"await_user_input": True,
"mensagem": mensagem,
"mensagem_pos_aceite": "",
"mensagem_reperguntar_esclarecimento": (
"Para seguirmos, preciso de uma confirmacao. " + mensagem
),
"mensagem_oferta_ajuste": mensagem_oferta_ajuste,
"mensagem_reperguntar_ajuste": (
"Para seguirmos, preciso de uma confirmacao. "
+ mensagem_oferta_ajuste
),
"mensagem_ajuste_recusado": _build_pro_rata_ajuste_recusado_msg(),
"has_plano_controle": True,
}
)
@workflow_action("calcular_itens_contestacao_pro_rata")
def calcular_itens_contestacao_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Calcula itens/valores a contestar no fluxo de pro rata."""
raw_planos = _first_value_from_params_or_state(state, params, "planos")
planos = [p for p in (raw_planos or []) if isinstance(p, dict)]
if not planos:
return ActionResult.fail(
"planos obrigatorio para calcular itens de contestacao do pro_rata"
)
items: list[dict[str, Any]] = []
total = Decimal("0")
for plano in planos:
item_name = str(
plano.get("desc")
or plano.get("name")
or plano.get("item_name")
or ""
).strip()
if not item_name:
continue
valor = _normalize_number_text(
plano.get("value")
or plano.get("valor")
or plano.get("claimed_amount")
or "0"
)
valor_decimal = Decimal(valor)
total += valor_decimal
items.append(
{
"item_name": item_name,
"item_type": "PRO_RATA",
"claimed_amount": valor,
"validated_amount": valor,
}
)
if not items:
return ActionResult.fail(
"Nao foi possivel calcular itens de contestacao a partir dos planos."
)
total_txt = _normalize_number_text(format(total, "f"))
return ActionResult.ok(
{
"items": items,
"invoice_amount_open": total_txt,
"invoice_amount": total_txt,
}
)
@workflow_action("executar_contestacao_plano_controle")
def executar_contestacao_plano_controle(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Delega para o workflow/tool contestacao_tool apos calcular os itens."""
context, context_error = _extract_pro_rata_contestation_context(state, params)
if context is None:
return ActionResult.fail(
context_error or "Dados invalidos para contestacao de pro_rata"
)
if runtime.workflow_runner is None:
return ActionResult.fail(
"workflow_runner nao configurado para delegar contestacao_tool"
)
contestacao_payload = _build_pro_rata_contestacao_tool_payload(
state,
params,
context=context,
)
contestacao_result = runtime.workflow_runner(
"contestacao_tool",
contestacao_payload,
None,
None,
)
if contestacao_result.status != "COMPLETED" or not isinstance(
contestacao_result.data, dict
):
return ActionResult.fail(
str(contestacao_result.error or "Falha ao abrir contestacao de pro_rata"),
**(
contestacao_result.metadata
if isinstance(contestacao_result.metadata, dict)
else {}
),
)
contest_payload = contestacao_result.data
contestacao_lines = (
contest_payload.get("linhas", [])
if isinstance(contest_payload.get("linhas"), list)
else []
)
protocolo_id = ""
if contestacao_lines and isinstance(contestacao_lines[0], dict):
protocolo_id = str(contestacao_lines[0].get("protocolo_id", "")).strip()
if not protocolo_id:
protocolo_id = str(
contest_payload.get("protocol_number")
or contest_payload.get("protocolo_id")
or ""
).strip()
mensagem = str(contest_payload.get("mensagem", "")).strip()
if not mensagem:
mensagem = (
"Contestacao de pro rata registrada com sucesso. "
f"Protocolo: {protocolo_id or 'n/a'}."
)
plano_names = [
str(item.get("item_name", "")).strip()
for item in context["items"]
if isinstance(item, dict) and str(item.get("item_name", "")).strip()
]
transaction_id = str(
contest_payload.get("contestation_id")
or contest_payload.get("sr")
or protocolo_id
or ""
).strip()
audit = {
"plano_cliente": ", ".join(plano_names),
"valor_calculado_credito": str(context["invoice_amount"]),
"data_hora_contestacao": _utc_now_iso(),
"transaction_id_ajuste": transaction_id,
"mensagem_confirmacao": mensagem,
}
raw_devolucao = params.get("devolucao")
devolucao = dict(raw_devolucao) if isinstance(raw_devolucao, dict) else {}
devolucao.setdefault("items", context["items"])
devolucao["invoice_amount_open"] = context["invoice_amount_open"]
devolucao["invoice_amount"] = context["invoice_amount"]
format_text = str(
contest_payload.get("format_text")
or (
"sms"
if bool(
contest_payload.get("sms_enviado")
or contest_payload.get("sms_sent")
)
else "conta_futura"
)
).strip()
devolucao["format_text"] = format_text
devolucao["resolution_type"] = str(
contest_payload.get("resolution_type")
or ("new_boleto" if format_text == "sms" else "credit_bill")
).strip()
devolucao["sms_enviado"] = bool(
contest_payload.get("sms_enviado")
or contest_payload.get("sms_sent")
or format_text == "sms"
)
devolucao["sms_sent"] = devolucao["sms_enviado"]
for key in (
"decision_reason",
"data_credito_proxima_fatura",
"barcode",
"items_response",
"sr_conta_certa_id",
):
if key in contest_payload:
devolucao[key] = contest_payload[key]
if protocolo_id:
devolucao["protocolo_id"] = protocolo_id
if transaction_id:
devolucao["transaction_id_ajuste"] = transaction_id
logger.info(
"pro_rata.contestacao.audit msisdn_final=%s plano_cliente=%s valor_credito=%s transaction_id=%s data_hora=%s mensagem_confirmacao=%s",
_final_msisdn(str(context["msisdn"])),
audit["plano_cliente"],
audit["valor_calculado_credito"],
audit["transaction_id_ajuste"],
audit["data_hora_contestacao"],
audit["mensagem_confirmacao"],
)
return ActionResult.ok(
{
"success": True,
"mensagem": mensagem,
"protocolo_id": protocolo_id,
"contestation_id": str(
contest_payload.get("contestation_id")
or ""
).strip(),
"sr": str(
contest_payload.get("sr")
or protocolo_id
).strip(),
"items": context["items"],
"invoice_amount_open": context["invoice_amount_open"],
"invoice_amount": context["invoice_amount"],
"devolucao": devolucao,
"audit": audit,
"response": contest_payload,
},
**(
contestacao_result.metadata
if isinstance(contestacao_result.metadata, dict)
else {}
),
)
@workflow_action("definir_devolucao_ajuste_pro_rata")
def definir_devolucao_ajuste_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Calcula devolucao do ajuste pro-rata para Plano Controle.
A base do pro-rata e o valor liquido do Plano Controle. A devolucao e
alocada nos itens do DANFE vinculados ao mesmo plano.
"""
raw_planos = _first_value_from_params_or_state(state, params, "planos")
planos = [p for p in (raw_planos or []) if isinstance(p, dict)]
if len(planos) != 2:
return ActionResult.fail(
"pro_rata exige exatamente dois planos para calcular devolucao."
)
resolved = _resolve_plano_controle(planos)
if resolved is None:
return ActionResult.fail(
"Nao foi possivel identificar exatamente um Plano Controle."
)
plano_controle, outro_plano = resolved
valor_liquido = _resolve_pro_rata_liquid_value(plano_controle)
if valor_liquido is None or valor_liquido <= 0:
return ActionResult.fail(
"Valor liquido do Plano Controle ausente ou invalido."
)
invoice_payload, error = _fetch_invoice_payload_pro_rata(state, params, runtime)
if error:
return ActionResult.fail(error)
period_days = _resolve_pro_rata_period_days(
outro_plano=outro_plano,
invoice_payload=invoice_payload,
invoice_period=_first_text_from_params_or_state(
state,
params,
"invoice_period",
"invoicePeriod",
"periodo_fatura",
"periodoFatura",
),
invoice_emissao=_first_text_from_params_or_state(
state,
params,
"invoice_emissao",
"invoiceEmissao",
"emissao_fatura",
"emissaoFatura",
),
)
if period_days is None:
return ActionResult.fail(
"Periodo da fatura ou dos planos ausente ou invalido para calcular pro-rata."
)
dias_ciclo, dias_controle = period_days
valor_liquido = _money(valor_liquido)
valor_usado = (valor_liquido / Decimal(dias_ciclo)) * Decimal(dias_controle)
valor_devolver = _money(valor_liquido - valor_usado)
danfe = invoice_payload.get("DANFE-COM")
if not isinstance(danfe, dict) or not danfe:
return ActionResult.fail("DANFE-COM nao encontrado na fatura recuperada.")
items, restante = _build_pro_rata_contestation_items(
danfe=danfe,
plano_controle=plano_controle,
valor_devolver=valor_devolver,
)
if restante > 0:
return ActionResult.fail(
"Itens do DANFE insuficientes para cobrir devolucao de "
f"R$ {_format_amount(restante)}."
)
total_validado = sum(
(
_decimal_from_any(item.get("validatedAmount")) or Decimal("0")
)
for item in items
if isinstance(item, dict)
)
logger.info(
"definir_devolucao_ajuste_pro_rata: items=%s total_validado=%s",
items,
_format_amount(_money(total_validado)),
)
total_validado_txt = _normalize_number_text(format(_money(total_validado), "f"))
texto_validacao_humana = _build_pro_rata_human_validation_text(
plano_controle=plano_controle,
valor_liquido=valor_liquido,
dias_ciclo=dias_ciclo,
dias_controle=dias_controle,
valor_usado=valor_usado,
valor_devolver=valor_devolver,
items=items,
)
return ActionResult.ok(
{
"items": items,
"invoice_amount_open": total_validado_txt,
"invoice_amount": total_validado_txt,
"texto_validacao_humana": texto_validacao_humana,
}
)
@workflow_action("executar_devolucao_pro_rata")
def executar_devolucao_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""A implementar: dispara devolucao do ajuste pro-rata.
Por enquanto repassa o payload de definir_devolucao para o proximo no.
"""
devolucao = params.get("devolucao")
if not isinstance(devolucao, dict):
devolucao = {}
items = devolucao.get("items")
if not isinstance(items, list):
items = []
logger.warning(
"executar_devolucao_pro_rata: a implementar (mock ativo)"
)
output = dict(devolucao)
output["items"] = items
output["mocked"] = True
return ActionResult.ok(output)
@workflow_action("orientar_pagamento_pro_rata")
def orientar_pagamento_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Orienta o cliente sobre pagamento apos definir o credito."""
devolucao = params.get("devolucao")
if not isinstance(devolucao, dict):
devolucao = {}
raw_items = devolucao.get("items")
items = raw_items if isinstance(raw_items, list) else []
valor = Decimal("0")
plano_nome = ""
for item in items:
if not isinstance(item, dict):
continue
if not plano_nome:
plano_nome = str(
item.get("itemName")
or item.get("item_name")
or ""
).strip()
item_value = _decimal_from_any(
item.get("validatedAmount")
if item.get("validatedAmount") is not None
else item.get("validated_amount")
)
if item_value is not None:
valor += item_value
valor_txt = _format_amount(_money(valor))
plano_txt = f" {plano_nome}" if plano_nome else ""
protocolo_id = str(devolucao.get("protocolo_id", "")).strip()
protocolo_txt = (
f" Seu numero de protocolo e {vocalize_digits(protocolo_id)}."
if protocolo_id
else ""
)
data_credito = str(
devolucao.get("data_credito_proxima_fatura", "")
).strip()
data_credito_txt = (
f" na fatura com vencimento em {data_credito}, considerando o seu ciclo de faturamento"
if data_credito
else " em uma proxima fatura"
)
sms_codigo_txt = (
"com o codigo de barras atualizado"
if str(devolucao.get("barcode", "")).strip()
else "com as orientacoes para pagamento"
)
mensagem = (
(
"Realizei a contestacao da fatura considerando o valor "
f"proporcional do Plano Controle{plano_txt}. O valor "
f"contestado, de R$ {valor_txt}, foi retirado da sua fatura. "
f"Enviamos uma mensagem {sms_codigo_txt}, com prazo de 10 dias "
f"para pagamento.{protocolo_txt}"
)
if str(devolucao.get("format_text", "")).strip() == "sms"
else (
"Realizei a contestacao considerando o valor proporcional do "
f"Plano Controle{plano_txt}. O valor contestado, de "
f"R$ {valor_txt}, ficou registrado como credito{data_credito_txt}."
f"{protocolo_txt}"
)
).strip()
return ActionResult.ok({"mensagem": mensagem, "devolucao": devolucao})
@workflow_action("registrar_atendimento_pro_rata")
def registrar_atendimento_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Registra protocolo de fechamento do atendimento pro rata."""
mensagem_base = str(params.get("mensagem_base", "")).strip()
caminho = str(params.get("caminho", "")).strip()
devolucao = params.get("devolucao")
if not isinstance(devolucao, dict):
devolucao = {}
msisdn = _first_text_from_params_or_state(
state,
params,
"msisdn",
"Msisdn",
"MSISDN",
)
if not msisdn:
return ActionResult.fail("msisdn obrigatorio para registrar_atendimento_pro_rata")
social_sec_no = re.sub(
r"\D",
"",
_first_text_from_params_or_state(
state,
params,
"social_sec_no",
"socialSecNo",
"cpf",
"customer_document",
"customerDocument",
"document",
),
)
caminho_norm = caminho.strip().lower()
scenario = (
"pro_rata_reclamacao"
if caminho_norm == "nao_aceitou"
else "pro_rata_mensalidade"
)
close_triplet = resolve_protocol_triplet(scenario, stage="close")
reason1 = close_triplet.reason1 or "Informação"
reason2 = close_triplet.reason2 or "Conta"
reason3 = close_triplet.reason3 or "Mensalidade"
request_status = str(params.get("request_status", "Fechado")).strip() or "Fechado"
status = str(params.get("status", "CLOSED")).strip() or "CLOSED"
ic = str(params.get("ic", "")).strip()
message_id = _first_text_from_params_or_state(
state,
params,
"message_id",
"messageId",
)
register_result = runtime.factory.create_protocol_v2(
msisdn=msisdn,
interaction_protocol="",
flag_sms=True,
social_sec_no=social_sec_no,
source="CHAT",
reason1=reason1,
reason2=reason2,
reason3=reason3,
direction_contact="FROM-CLIENT",
type="CLIENTE",
request_flag=True,
request_status=request_status,
status=status,
service_request_notes="Solicitação via chat",
client_id="AIAGENTCR",
message_id=message_id,
).execute()
if _result_failed_or_missing_data(register_result, state=state):
return ActionResult.fail(
register_result.error or "Falha ao registrar protocolo do pro_rata",
**register_result.metadata,
)
payload = _to_dict(register_result.data)
response = payload.get("response", {}) if isinstance(payload, dict) else {}
protocolo_id = ""
if isinstance(response, dict):
protocolo_id = str(
response.get("interactionProtocol")
or response.get("protocolId")
or response.get("protocolo_id")
or ""
).strip()
if not protocolo_id:
return ActionResult.fail("Protocolo nao retornado para pro_rata")
if not mensagem_base and caminho_norm == "aceitou":
mensagem_base = (
f"Sua solicitação foi registrada. "
f"Seu número de protocolo é {vocalize_digits(protocolo_id)}."
)
logger.info(
"registrar_atendimento_pro_rata caminho=%s msisdn=%s protocolo=%s",
caminho_norm or "n/a",
_final_msisdn(msisdn),
protocolo_id,
)
result: dict[str, Any] = {
"success": True,
"mensagem": mensagem_base,
"protocolo": protocolo_id,
"protocolo_id": protocolo_id,
"pro_rata_protocol": protocolo_id,
"protocolos_por_linha": [
{
"msisdn": msisdn,
"nome": "pro_rata",
"protocolo_id": protocolo_id,
}
],
"tripleta": {
"reason1": reason1,
"reason2": reason2,
"reason3": reason3,
},
"protocol_closed": True,
"devolucao": devolucao,
"requires_protocol_in_response": True,
"protocols_for_response": [protocolo_id],
}
if ic:
result["ic"] = ic
return ActionResult.ok(result)
__all__ = [
'formatar_pro_rata',
'preparar_pro_rata',
'calcular_itens_contestacao_pro_rata',
'executar_contestacao_plano_controle',
'definir_devolucao_ajuste_pro_rata',
'executar_devolucao_pro_rata',
'orientar_pagamento_pro_rata',
'registrar_atendimento_pro_rata',
]

View File

@@ -0,0 +1,793 @@
from __future__ import annotations
import json
import re
import unicodedata as ud
from datetime import date
from decimal import (
Decimal,
InvalidOperation,
ROUND_HALF_UP,
)
from typing import Any
from agente_contas_tim.workflows.actions.common.helpers import (
_final_msisdn,
_first_text_from_params_or_state,
_first_value_from_params_or_state,
_format_amount,
_format_list_pt_br,
_normalize_bool,
_normalize_number_text,
_parse_amount,
_result_failed_or_missing_data,
)
from agente_contas_tim.workflows.actions.contestacao.helpers import _build_contestation_items
_CENT = Decimal("0.01")
def _format_plano_pro_rata(plano: dict[str, Any]) -> str:
desc = str(plano.get("desc", "")).strip()
valor_txt = _format_plano_pro_rata_value(plano)
final = _final_msisdn(str(plano.get("msisdn", "")))
return f"{desc} (valor R$ {valor_txt}, linha final {final})"
def _format_plano_pro_rata_value(plano: dict[str, Any]) -> str:
raw_value = (
plano.get("valor_final")
or plano.get("valor_liquido")
or plano.get("value")
or plano.get("valor_bruto")
)
if isinstance(raw_value, bool):
valor_parsed = None
elif isinstance(raw_value, (int, float, Decimal)):
valor_parsed = Decimal(str(raw_value))
else:
valor_parsed = _parse_amount(str(raw_value or ""))
return _format_amount(valor_parsed) or "0,00"
def _format_plano_pro_rata_display_name(
plano: dict[str, Any],
*,
fallback: str,
) -> str:
desc = str(plano.get("desc", "")).strip()
clean = re.sub(r"\s*\([^)]*\)\s*$", "", desc).strip()
return clean or fallback
def _format_pro_rata_period_reference(plano: dict[str, Any]) -> str:
days = _resolve_days_number(
plano.get("days") if plano.get("days") is not None else plano.get("dias")
)
if days is None:
return "referente ao período de uso na fatura"
unidade = "dia" if days == 1 else "dias"
return f"referente a {days} {unidade} de uso no período"
def _format_planos_pro_rata(planos: list[dict[str, Any]]) -> str:
formatted = [
_format_plano_pro_rata(p) for p in planos if isinstance(p, dict)
]
return _format_list_pt_br(formatted)
def _build_pro_rata_controle_msg(planos: list[dict[str, Any]]) -> str:
resolved = _resolve_plano_controle(planos)
if resolved is None:
return (
"Houve uma troca de plano e, por isso, na sua fatura "
"apareceram duas cobranças.\n\n"
"Uma é cobrança proporcional de outro plano, referente ao "
"período de uso na fatura.\n\n"
"A outra é do plano Controle.\n\n"
"No plano Controle funciona assim: sempre que a franquia é "
"renovada, o valor do plano é cobrado por completo.\n\n"
"Isso acontece porque, a partir da renovação, você já passa a "
"ter acesso a todos os benefícios do plano imediatamente, como "
"internet, ligações e outras vantagens.\n\n"
"Consegui esclarecer sua dúvida?"
)
plano_controle, outro_plano = resolved
controle_nome = _format_plano_pro_rata_display_name(
plano_controle,
fallback="Controle",
)
outro_nome = _format_plano_pro_rata_display_name(
outro_plano,
fallback="identificado",
)
controle_valor = _format_plano_pro_rata_value(plano_controle)
outro_valor = _format_plano_pro_rata_value(outro_plano)
outro_periodo = _format_pro_rata_period_reference(outro_plano)
return (
"Houve uma troca de plano e, por isso, na sua fatura apareceram "
"duas cobranças.\n\n"
f"Uma é cobrança proporcional do plano {outro_nome}, no valor de "
f"R$ {outro_valor}, {outro_periodo}.\n\n"
f"A outra é do plano {controle_nome}, no valor de R$ "
f"{controle_valor}.\n\n"
"No plano Controle funciona assim: sempre que a franquia é "
"renovada, o valor do plano é cobrado por completo.\n\n"
"Isso acontece porque, a partir da renovação, você já passa a ter "
"acesso a todos os benefícios do plano imediatamente, como "
"internet, ligações e outras vantagens.\n\n"
"Consegui esclarecer sua dúvida?"
)
def _build_pro_rata_sem_controle_msg(planos: list[dict[str, Any]]) -> str:
return (
"Identifiquei a cobranca proporcional na sua fatura "
f"envolvendo os planos {_format_planos_pro_rata(planos)}. "
"Como não envolveu troca para o Plano Controle, segue como "
"cobranca proporcional padrao dos dias usados em cada plano."
)
def _build_pro_rata_oferta_ajuste_msg() -> str:
return (
"Para buscarmos a "
"melhor solução, posso solicitar o "
"ajuste proporcional do plano Controle?"
)
def _build_pro_rata_ajuste_recusado_msg() -> str:
return (
"Entendi. Sem a sua confirmação, a solicitação de ajuste não "
"será aberta neste momento."
)
def _money(value: Decimal) -> Decimal:
return value.quantize(_CENT, rounding=ROUND_HALF_UP)
def _decimal_from_any(value: Any) -> Decimal | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, Decimal):
return value
if isinstance(value, (int, float)):
return Decimal(str(value))
parsed = _parse_amount(str(value or ""))
return parsed
def _first_decimal_from_mapping(
data: dict[str, Any],
*keys: str,
) -> Decimal | None:
for key in keys:
if key not in data:
continue
value = _decimal_from_any(data.get(key))
if value is not None:
return value
return None
def _normalize_match_text(value: Any) -> str:
text = re.sub(r"\s*\([^)]*\)", "", str(value or "")).strip()
text = ud.normalize("NFKD", text)
text = "".join(ch for ch in text if not ud.combining(ch))
text = text.casefold()
text = re.sub(r"[^a-z0-9]+", " ", text)
return re.sub(r"\s+", " ", text).strip()
def _is_same_plan_name(left: Any, right: Any) -> bool:
left_key = _normalize_match_text(left)
right_key = _normalize_match_text(right)
if not left_key or not right_key:
return False
return left_key == right_key or left_key in right_key or right_key in left_key
def _resolve_plano_controle(
planos: list[dict[str, Any]],
) -> tuple[dict[str, Any], dict[str, Any]] | None:
controles = [
plano
for plano in planos
if isinstance(plano, dict) and bool(plano.get("is_controle"))
]
if len(controles) != 1:
return None
controle = controles[0]
outro = next((plano for plano in planos if plano is not controle), None)
if not isinstance(outro, dict):
return None
return controle, outro
def _resolve_pro_rata_liquid_value(
plano_controle: dict[str, Any],
) -> Decimal | None:
valor_liquido = _first_decimal_from_mapping(
plano_controle,
"valor_final",
"valorFinal",
"valor_liquido",
"valorLiquido",
"net_value",
"netValue",
"subtotal",
"value_final",
)
if valor_liquido is None:
valor_bruto = _first_decimal_from_mapping(
plano_controle,
"valor_bruto",
"valorBruto",
"gross_value",
"grossValue",
"valor_bruto_plano",
"valorBrutoPlano",
"preco_unit",
)
total_descontos = _first_decimal_from_mapping(
plano_controle,
"total_descontos",
"totalDescontos",
"discount_total",
)
if valor_bruto is not None and total_descontos is not None:
valor_liquido = valor_bruto + total_descontos
return valor_liquido
def _parse_emissao_year(
invoice_payload: dict[str, Any],
*,
invoice_emissao: str = "",
) -> int | None:
match = re.search(r"\b\d{2}/\d{2}/(?P<year>\d{4})\b", invoice_emissao)
if match:
return int(match.group("year"))
for item in invoice_payload.get("Fatura Resumo", []) or []:
if not isinstance(item, dict):
continue
emissao = str(item.get("emissao", "") or "").strip()
match = re.search(r"\b\d{2}/\d{2}/(?P<year>\d{4})\b", emissao)
if match:
return int(match.group("year"))
return None
def _extract_resumo_period(
invoice_payload: dict[str, Any],
*,
invoice_period: str = "",
) -> str:
if invoice_period:
return invoice_period
for item in invoice_payload.get("Fatura Resumo", []) or []:
if not isinstance(item, dict):
continue
desc = _normalize_match_text(item.get("desc", ""))
if desc == "periodo":
return str(item.get("period", "") or "").strip()
return ""
def _parse_period_range(value: Any, *, emission_year: int) -> tuple[date, date] | None:
text = str(value or "").strip()
match = re.search(
r"(?P<start_day>\d{2})/(?P<start_month>\d{2})\s+a\s+"
r"(?P<end_day>\d{2})/(?P<end_month>\d{2})",
text,
)
if not match:
return None
start_day = int(match.group("start_day"))
start_month = int(match.group("start_month"))
end_day = int(match.group("end_day"))
end_month = int(match.group("end_month"))
start_year = emission_year
end_year = emission_year
if start_month > end_month:
start_year -= 1
try:
start = date(start_year, start_month, start_day)
end = date(end_year, end_month, end_day)
except ValueError:
return None
if start > end:
return None
return start, end
def _inclusive_days(period: tuple[date, date]) -> int:
return (period[1] - period[0]).days + 1
def _resolve_days_number(value: Any) -> int | None:
try:
days = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return None
if days <= 0:
return None
return int(days.to_integral_value(rounding=ROUND_HALF_UP))
def _resolve_pro_rata_period_days(
*,
outro_plano: dict[str, Any],
invoice_payload: dict[str, Any],
invoice_period: str = "",
invoice_emissao: str = "",
) -> tuple[int, int] | None:
emission_year = _parse_emissao_year(
invoice_payload,
invoice_emissao=invoice_emissao,
)
resumo_period_text = _extract_resumo_period(
invoice_payload,
invoice_period=invoice_period,
)
if emission_year is None or not resumo_period_text:
return None
ciclo_period = _parse_period_range(
resumo_period_text,
emission_year=emission_year,
)
if ciclo_period is None:
return None
dias_ciclo = _inclusive_days(ciclo_period)
if dias_ciclo <= 0:
return None
dias_outro = _resolve_days_number(
outro_plano.get("days")
if outro_plano.get("days") is not None
else outro_plano.get("dias")
)
if dias_outro is None:
return None
if dias_outro >= dias_ciclo:
return dias_ciclo, 1
dias_controle = dias_ciclo - dias_outro
if dias_controle <= 0:
dias_controle = 1
return dias_ciclo, dias_controle
def _parse_invoice_detail_payload(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, list):
return {"_items": value}
if not isinstance(value, str):
return {}
text = value.strip()
if not text:
return {}
try:
parsed = json.loads(text)
except json.JSONDecodeError:
return {}
if isinstance(parsed, dict):
return parsed
if isinstance(parsed, list):
return {"_items": parsed}
return {}
def _extract_danfe_from_state_or_params(
state: dict[str, Any],
params: dict[str, Any],
) -> dict[str, Any]:
payload = _extract_invoice_payload_from_state_or_params(state, params)
danfe = payload.get("DANFE-COM") if isinstance(payload, dict) else None
return danfe if isinstance(danfe, dict) else {}
def _extract_invoice_payload_from_state_or_params(
state: dict[str, Any],
params: dict[str, Any],
) -> dict[str, Any]:
detail = _first_value_from_params_or_state(
state,
params,
"invoice_detail",
"invoiceDetail",
"danfe_detail",
"danfeDetail",
)
return _parse_invoice_detail_payload(detail)
def _fetch_invoice_payload_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> tuple[dict[str, Any], str | None]:
payload = _extract_invoice_payload_from_state_or_params(state, params)
if payload.get("DANFE-COM"):
return payload, None
invoice_id = _first_text_from_params_or_state(
state,
params,
"invoice_id",
"invoiceId",
"current_invoice_number",
"currentInvoiceNumber",
)
customer_id = _first_text_from_params_or_state(
state,
params,
"customer_id",
"customerId",
)
msisdn = _first_text_from_params_or_state(state, params, "msisdn")
if not invoice_id or not customer_id or not msisdn:
return payload, (
"Pro-rata exige invoice_id, customer_id e msisdn para buscar "
"fatura com DANFE."
)
result = runtime.factory.create_bill_pdf(
invoice_id=invoice_id,
msisdn=msisdn,
customer_id=customer_id,
include_danfe=True,
).execute()
if _result_failed_or_missing_data(result, state=state):
return payload, result.error or "Falha ao buscar fatura com DANFE."
parsed = result.data.parsed_content or {}
if not isinstance(parsed, dict):
return payload, "Fatura com DANFE retornou conteudo invalido."
return parsed, None
def _fetch_danfe_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> tuple[dict[str, Any], str | None]:
payload, error = _fetch_invoice_payload_pro_rata(state, params, runtime)
if error:
return {}, error
danfe = payload.get("DANFE-COM")
if not isinstance(danfe, dict) or not danfe:
return {}, "DANFE-COM nao encontrado na fatura recuperada."
return danfe, None
def _find_danfe_plan_items(
danfe: dict[str, Any],
plano_controle: dict[str, Any],
) -> list[dict[str, Any]]:
planos = danfe.get("Planos")
if not isinstance(planos, dict):
return []
controle_desc = str(plano_controle.get("desc", "")).strip()
for plan_name, raw_items in planos.items():
if not _is_same_plan_name(plan_name, controle_desc):
continue
if isinstance(raw_items, list):
return [item for item in raw_items if isinstance(item, dict)]
return []
def _build_pro_rata_contestation_items(
*,
danfe: dict[str, Any],
plano_controle: dict[str, Any],
valor_devolver: Decimal,
) -> tuple[list[dict[str, float | str]], Decimal]:
items = _find_danfe_plan_items(danfe, plano_controle)
restante = _money(valor_devolver)
contestation_items: list[dict[str, float | str]] = []
for item in items:
desc = str(item.get("desc", "")).strip()
if not desc:
continue
claimed_amount = _first_decimal_from_mapping(
item,
"valor_final",
"valorFinal",
)
if claimed_amount is None or claimed_amount <= 0:
continue
claimed_amount = _money(claimed_amount)
validated_amount = min(restante, claimed_amount)
if validated_amount <= 0:
continue
contestation_items.append(
{
"itemName": desc,
"itemType": "PRO_RATA",
"claimedAmount": float(claimed_amount),
"validatedAmount": float(validated_amount),
}
)
restante = _money(restante - validated_amount)
if restante <= 0:
break
return contestation_items, restante
def _build_pro_rata_human_validation_text(
*,
plano_controle: dict[str, Any],
valor_liquido: Decimal,
dias_ciclo: int,
dias_controle: int,
valor_usado: Decimal,
valor_devolver: Decimal,
items: list[dict[str, float | str]],
) -> str:
plano_nome = re.sub(
r"\s*\([^)]*\)",
"",
str(plano_controle.get("desc") or "").strip(),
).strip()
if not plano_nome:
plano_nome = "Plano Controle"
item_lines = []
for index, item in enumerate(items, start=1):
item_name = str(item.get("itemName") or "").strip()
claimed = _decimal_from_any(item.get("claimedAmount")) or Decimal("0")
validated = _decimal_from_any(item.get("validatedAmount")) or Decimal("0")
item_lines.append(
f"{index}. {item_name}: valor DANFE R$ {_format_amount(_money(claimed))}; "
f"valor a abater R$ {_format_amount(_money(validated))}"
)
itens_txt = "; ".join(item_lines) if item_lines else "nenhum item gerado"
return (
"Validacao humana pro-rata:"
f"Plano Controle identificado: {plano_nome}. "
f"Base liquida do plano: R$ {_format_amount(valor_liquido)}. "
f"Calculo: ciclo de {dias_ciclo} dias, uso considerado de "
f"{dias_controle} dias; valor usado R$ {_format_amount(_money(valor_usado))}; "
f"valor a devolver R$ {_format_amount(valor_devolver)}. "
f"Itens selecionados no DANFE, na ordem de abatimento: {itens_txt}. "
)
def _extract_pro_rata_contestation_context(
state: dict[str, Any], params: dict[str, Any]
) -> tuple[dict[str, Any] | None, str | None]:
devolucao = params.get("devolucao")
contestation_params = params
if isinstance(devolucao, dict):
contestation_params = {**devolucao, **params}
if "items" not in params:
contestation_params["items"] = devolucao.get("items")
msisdn = _first_text_from_params_or_state(
state,
params,
"msisdn",
"Msisdn",
"MSISDN",
)
if not msisdn:
return None, "msisdn obrigatorio para contestacao de pro_rata"
social_sec_no = re.sub(
r"\D",
"",
_first_text_from_params_or_state(
state,
params,
"social_sec_no",
"socialSecNo",
"cpf",
"customer_document",
"customerDocument",
"document",
),
)
customer_id = _first_text_from_params_or_state(
state,
params,
"customer_id",
"customerId",
)
if not customer_id:
return None, "customer_id obrigatorio para contestacao de pro_rata"
invoice_number = _first_text_from_params_or_state(
state,
params,
"current_invoice_number",
"currentInvoiceNumber",
"invoice_id",
"invoiceId",
"invoice_number",
"invoiceNumber",
)
if not invoice_number:
return None, "invoice_number obrigatorio para contestacao de pro_rata"
items = _build_contestation_items(state, contestation_params)
if not items:
return None, "items obrigatorio para contestacao de pro_rata"
fallback_amount = _normalize_number_text(
format(
sum(
(
_decimal_from_any(item.get("validated_amount"))
or Decimal("0")
)
for item in items
if isinstance(item, dict)
),
"f",
),
default="0",
)
invoice_amount_open = _normalize_number_text(
_first_text_from_params_or_state(
state,
contestation_params,
"invoice_amount_open",
"invoiceAmountOpen",
),
default=fallback_amount,
)
invoice_amount = _normalize_number_text(
_first_text_from_params_or_state(
state,
contestation_params,
"invoice_amount",
"invoiceAmount",
),
default=invoice_amount_open,
)
context = {
"msisdn": msisdn,
"social_sec_no": social_sec_no,
"customer_id": customer_id,
"invoice_number": invoice_number,
"items": items,
"invoice_amount_open": invoice_amount_open,
"invoice_amount": invoice_amount,
}
return context, None
def _build_pro_rata_contestacao_tool_payload(
state: dict[str, Any],
params: dict[str, Any],
*,
context: dict[str, Any],
) -> dict[str, Any]:
return {
"msisdn": context["msisdn"],
"social_sec_no": context["social_sec_no"],
"customer_id": context["customer_id"],
"current_invoice_number": context["invoice_number"],
"invoice_id": context["invoice_number"],
"customer_type": (
_first_text_from_params_or_state(
state, params, "customer_type", "customerType"
)
or "2"
),
"customer_status": (
_first_text_from_params_or_state(
state, params, "customer_status", "customerStatus"
)
or "1"
),
"invoice_status": (
_first_text_from_params_or_state(
state, params, "invoice_status", "invoiceStatus"
)
or "1"
),
"invoice_amount_open": context["invoice_amount_open"],
"invoice_amount": context["invoice_amount"],
"current_invoice_due_date": _first_text_from_params_or_state(
state,
params,
"current_invoice_due_date",
"currentInvoiceDueDate",
),
"contestation_type": (
_first_text_from_params_or_state(
state, params, "contestation_type", "contestationType"
)
or "0"
),
"adjust_reason": (
_first_text_from_params_or_state(
state, params, "adjust_reason", "adjustReason"
)
or "SERVICO_NAO_SOLICITADO"
),
"observation": _first_text_from_params_or_state(
state, params, "observation", "descricao"
),
"refund_option": (
_first_text_from_params_or_state(
state, params, "refund_option", "refundOption"
)
or ""
),
"manual_conta_certa_indicator": _first_value_from_params_or_state(
state,
params,
"manual_conta_certa_indicator",
"manualContaCertaIndicator",
),
"double_refund": _normalize_bool(
_first_value_from_params_or_state(
state,
params,
"double_refund",
"doubleRefund",
),
default=False,
),
"user_id": _first_text_from_params_or_state(
state, params, "user_id", "userId"
),
"message_id": _first_text_from_params_or_state(
state, params, "message_id", "messageId"
),
"client_id": _first_text_from_params_or_state(
state, params, "client_id", "clientId"
),
"tipo_atendimento": "pro_rata",
"skip_invoice_item_validation": True,
"items": context["items"],
}
__all__ = [
'_format_plano_pro_rata',
'_format_plano_pro_rata_value',
'_format_plano_pro_rata_display_name',
'_format_pro_rata_period_reference',
'_format_planos_pro_rata',
'_build_pro_rata_controle_msg',
'_build_pro_rata_sem_controle_msg',
'_build_pro_rata_oferta_ajuste_msg',
'_build_pro_rata_ajuste_recusado_msg',
'_money',
'_decimal_from_any',
'_first_decimal_from_mapping',
'_normalize_match_text',
'_is_same_plan_name',
'_resolve_plano_controle',
'_resolve_pro_rata_liquid_value',
'_parse_emissao_year',
'_extract_resumo_period',
'_parse_period_range',
'_inclusive_days',
'_resolve_days_number',
'_resolve_pro_rata_period_days',
'_parse_invoice_detail_payload',
'_extract_danfe_from_state_or_params',
'_extract_invoice_payload_from_state_or_params',
'_fetch_invoice_payload_pro_rata',
'_fetch_danfe_pro_rata',
'_find_danfe_plan_items',
'_build_pro_rata_contestation_items',
'_build_pro_rata_human_validation_text',
'_extract_pro_rata_contestation_context',
'_build_pro_rata_contestacao_tool_payload',
]

View File

@@ -0,0 +1,218 @@
from __future__ import annotations
import logging
from concurrent.futures import ThreadPoolExecutor
from contextvars import copy_context
from typing import Any
from agente_contas_tim.workflows.actions.registry import (
WorkflowRuntimeContext,
workflow_action,
)
from agente_contas_tim.workflows.runtime_types import ActionResult
from agente_contas_tim.workflows.actions.common.helpers import (
_result_failed_or_missing_data,
_runtime_llm_callbacks,
_runtime_llm_metadata,
_to_dict,
)
from agente_contas_tim.workflows.actions.rag.helpers import (
_build_rag_answer_item,
_extract_rag_documents,
_is_selected_rag_document,
_normalize_rag_queries,
_rag_document_text,
_rag_document_title,
)
_RAG_FALLBACK_MESSAGE = (
"Não foi possível buscar essa informação no momento. "
"Por favor, aguarde na linha para que possamos te ajudar de outra forma."
)
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
@workflow_action("buscar_informacao_rag")
def buscar_informacao_rag(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
queries = _normalize_rag_queries(params)
if not queries:
return ActionResult.fail("Informe uma pergunta para buscar na base.")
raw_top_k = params.get("top_k")
resolved_top_k: int | None = None
if raw_top_k is not None and str(raw_top_k).strip():
try:
resolved_top_k = int(raw_top_k)
except (TypeError, ValueError):
return ActionResult.fail("top_k invalido: informe um numero inteiro")
segment = str(params.get("segment", "")).strip()
results: list[dict[str, Any]] = []
documents: list[dict[str, Any]] = []
answer_parts: list[str] = []
retrieved_titles: list[str] = []
selected_titles: list[str] = []
metadata: dict[str, Any] = {}
def _execute(query: str) -> Any:
return runtime.factory.create_rag_search(
query=query,
top_k=resolved_top_k,
segment=segment,
).execute()
if len(queries) == 1:
raw_results = [_execute(queries[0])]
else:
with ThreadPoolExecutor(
max_workers=min(len(queries), 6),
thread_name_prefix="rag-search",
) as executor:
futures = [
executor.submit(copy_context().run, _execute, query)
for query in queries
]
raw_results = [future.result() for future in futures]
for query, result in zip(queries, raw_results):
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha na busca RAG",
**result.metadata,
)
metadata.update(result.metadata)
payload = _to_dict(result.data)
query_documents = _extract_rag_documents(payload)
query_total = len(query_documents)
query_message = (
f"Encontrei {query_total} trecho(s) relevante(s) na base."
if query_total
else "Nao encontrei trechos relevantes para essa busca."
)
query_answer = _build_rag_answer_item(query, query_documents)
results.append(
{
"query": query,
"total": query_total,
"documents": query_documents,
"message": query_message,
"answer": query_answer,
}
)
documents.extend(query_documents)
answer_parts.append(query_answer)
for doc in query_documents:
title = _rag_document_title(doc)
if not title:
continue
retrieved_titles.append(title)
if _is_selected_rag_document(doc):
selected_titles.append(title)
total = len(documents)
message = (
f"Encontrei {total} trecho(s) relevante(s) na base."
if total
else "Nao encontrei trechos relevantes para essa busca."
)
return ActionResult.ok(
{
"success": True,
"query": queries[0],
"queries": queries,
"total": total,
"documents": documents,
"results": results,
"answer": "\n\n".join(answer_parts),
"message": message,
"ragRetrievedDocuments": "|".join(retrieved_titles),
"ragSelectedDocuments": "|".join(selected_titles),
"noMatchRag": total == 0,
},
**metadata,
)
@workflow_action("reescrever_resposta_buscar_informacao")
def reescrever_resposta_buscar_informacao(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
raw_queries = params.get("queries")
queries: list[str] = []
if isinstance(raw_queries, (list, tuple)):
for item in raw_queries:
text = str(item or "").strip()
if text:
queries.append(text)
elif isinstance(raw_queries, str) and raw_queries.strip():
queries.append(raw_queries.strip())
raw_documents = params.get("documents")
documents: list[dict[str, Any]] = []
if isinstance(raw_documents, (list, tuple)):
for item in raw_documents:
if isinstance(item, dict):
documents.append(item)
rag_context_parts: list[str] = []
for doc in documents:
text = _rag_document_text(doc)
if text:
rag_context_parts.append(text)
rag_context = "\n\n".join(rag_context_parts).strip()
if not rag_context:
rag_context = str(params.get("answer", "") or "").strip()
no_match = bool(params.get("noMatchRag", False))
def _payload(answer: str, *, success: bool = True) -> dict[str, Any]:
return {
"success": success,
"answer": answer,
"noMatchRag": no_match,
}
if runtime.llm_gateway is None:
return ActionResult.ok(_payload(_RAG_FALLBACK_MESSAGE))
queries_text = "; ".join(queries) if queries else ""
try:
llm_result = runtime.llm_gateway.execute(
capability_id="fluxo_buscar_informacao_reescrita",
variables={
"queries": queries_text,
"rag_context": rag_context,
},
user_text=queries_text,
callbacks=_runtime_llm_callbacks(runtime),
tags=["workflow_action"],
metadata=_runtime_llm_metadata(runtime),
)
answer = str(getattr(llm_result, "content", "") or "").strip()
except Exception:
logger.exception(
"reescrever_resposta_buscar_informacao: falha ao invocar "
"capability fluxo_buscar_informacao_reescrita"
)
return ActionResult.ok(_payload(_RAG_FALLBACK_MESSAGE))
if not answer:
return ActionResult.ok(_payload(_RAG_FALLBACK_MESSAGE))
return ActionResult.ok(_payload(answer))
__all__ = [
'buscar_informacao_rag',
'reescrever_resposta_buscar_informacao',
]

View File

@@ -0,0 +1,81 @@
from __future__ import annotations
from typing import Any
def _normalize_rag_queries(params: dict[str, Any]) -> list[str]:
raw_queries = params.get("queries")
queries: list[str] = []
if isinstance(raw_queries, (list, tuple)):
for item in raw_queries:
text = str(item or "").strip()
if text:
queries.append(text)
elif isinstance(raw_queries, str) and raw_queries.strip():
queries.append(raw_queries.strip())
if not queries:
query = str(params.get("query", "")).strip()
if query:
queries.append(query)
return queries
def _extract_rag_documents(payload: Any) -> list[dict[str, Any]]:
documents: list[dict[str, Any]] = []
if not isinstance(payload, dict):
return documents
raw_documents = payload.get("documents")
if isinstance(raw_documents, (list, tuple)):
for item in raw_documents:
if isinstance(item, dict):
documents.append(dict(item))
return documents
def _rag_document_title(doc: dict[str, Any]) -> str:
return str(
doc.get("title_proc")
or doc.get("title")
or doc.get("chunk_texto")
or ""
).strip()
def _rag_document_text(doc: dict[str, Any]) -> str:
return str(
doc.get("chunk_texto")
or doc.get("text")
or doc.get("title_proc")
or doc.get("title")
or ""
).strip()
def _is_selected_rag_document(doc: dict[str, Any]) -> bool:
try:
return float(doc.get("distance", 1.0)) <= 0.6
except (TypeError, ValueError):
return False
def _build_rag_answer_item(query: str, documents: list[dict[str, Any]]) -> str:
if not documents:
return f"{query}: Nao encontrei trechos relevantes para essa busca."
snippets = []
for doc in documents:
text = _rag_document_text(doc)
if text:
snippets.append(text)
if not snippets:
return f"{query}: Encontrei trecho(s), mas sem texto disponivel."
return f"{query}: {' '.join(snippets)}"
__all__ = [
'_normalize_rag_queries',
'_extract_rag_documents',
'_rag_document_title',
'_rag_document_text',
'_is_selected_rag_document',
'_build_rag_answer_item',
]

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
from dataclasses import dataclass, field
from threading import Lock
from typing import TYPE_CHECKING, Any, Callable
from agente_contas_tim.factory import CommandFactory
from agente_contas_tim.workflows.runtime_types import ActionResult
if TYPE_CHECKING:
from agente_contas_tim.agent.llm_gateway import LLMCapabilityGateway
from agente_contas_tim.workflows.runtime_types import WorkflowRunResponse
RuntimeState = dict[str, Any]
@dataclass(frozen=True, slots=True)
class WorkflowRuntimeContext:
factory: CommandFactory
llm_gateway: "LLMCapabilityGateway | None" = None
workflow_runner: (
Callable[[str, dict[str, Any], str | None, int | None], "WorkflowRunResponse"]
| None
) = None
llm_callbacks: tuple[Any, ...] = ()
llm_metadata: dict[str, Any] = field(default_factory=dict)
ActionHandler = Callable[
[RuntimeState, dict[str, Any], WorkflowRuntimeContext],
ActionResult,
]
@dataclass(frozen=True, slots=True)
class ActionSpec:
name: str
handler: ActionHandler
source: str
class ActionRegistry:
def __init__(self) -> None:
self._actions: dict[str, ActionSpec] = {}
self._lock = Lock()
def register(self, name: str, handler: ActionHandler, *, source: str) -> None:
with self._lock:
existing = self._actions.get(name)
if existing is not None:
if existing.handler is handler:
return
raise ValueError(
f"Action {name!r} já registrada por {existing.source}; "
f"tentativa atual: {source}"
)
self._actions[name] = ActionSpec(
name=name,
handler=handler,
source=source,
)
def get(self, name: str) -> ActionHandler:
spec = self._actions.get(name)
if spec is None:
raise ValueError(f"Action não registrada: {name}")
return spec.handler
def list_names(self) -> list[str]:
return sorted(self._actions.keys())
DEFAULT_ACTION_REGISTRY = ActionRegistry()
def workflow_action(name: str):
def decorator(fn: ActionHandler) -> ActionHandler:
source = f"{fn.__module__}.{fn.__name__}"
DEFAULT_ACTION_REGISTRY.register(name, fn, source=source)
return fn
return decorator

View File

@@ -0,0 +1,72 @@
"""Compatibility facade for legacy workflow action imports.
The action implementations live in workflow-specific packages under
``agente_contas_tim.workflows.actions``. This module keeps the previous
``tim_actions`` import path stable for tests and any external callers.
"""
from __future__ import annotations
from types import ModuleType
from agente_contas_tim.integrations import agent_framework_bridge
from agente_contas_tim.workflows.actions.common import (
actions as _common_actions,
helpers as _common_helpers,
)
from agente_contas_tim.workflows.actions.contestacao import (
actions as _contestacao_actions,
helpers as _contestacao_helpers,
)
from agente_contas_tim.workflows.actions.invoice_explanation import (
actions as _invoice_explanation_actions,
helpers as _invoice_explanation_helpers,
)
from agente_contas_tim.workflows.actions.pro_rata import (
actions as _pro_rata_actions,
helpers as _pro_rata_helpers,
)
from agente_contas_tim.workflows.actions.rag import (
actions as _rag_actions,
helpers as _rag_helpers,
)
from agente_contas_tim.workflows.actions.vas_avulso import (
actions as _vas_avulso_actions,
helpers as _vas_avulso_helpers,
)
from agente_contas_tim.workflows.actions.vas_estrategico import (
actions as _vas_estrategico_actions,
helpers as _vas_estrategico_helpers,
)
_EXPORT_MODULES: tuple[ModuleType, ...] = (
_common_helpers,
_contestacao_helpers,
_invoice_explanation_helpers,
_pro_rata_helpers,
_rag_helpers,
_vas_avulso_helpers,
_vas_estrategico_helpers,
_common_actions,
_contestacao_actions,
_invoice_explanation_actions,
_pro_rata_actions,
_rag_actions,
_vas_avulso_actions,
_vas_estrategico_actions,
)
__all__ = ["agent_framework_bridge"]
def _export_from(module: ModuleType) -> None:
for name in getattr(module, "__all__", ()):
globals()[name] = getattr(module, name)
__all__.append(name)
for _module in _EXPORT_MODULES:
_export_from(_module)
del ModuleType, _EXPORT_MODULES, _export_from, _module

View File

@@ -0,0 +1,851 @@
from __future__ import annotations
import logging
import re
from decimal import Decimal
from typing import Any
from agente_contas_tim.constants.ic_tags_enum import VAATag
from agente_contas_tim.integrations.rct_policy import RCTOperation
from agente_contas_tim.observability import get_session_id
from agente_contas_tim.protocol_triplets import resolve_protocol_triplet
from agente_contas_tim.workflows.actions.inputs import parse_cancel_vas_items
from agente_contas_tim.workflows.actions.registry import (
WorkflowRuntimeContext,
workflow_action,
)
from agente_contas_tim.workflows.runtime_types import ActionResult
from agente_contas_tim.workflows.actions.common.helpers import (
_build_ic_context,
_emit_vaa,
_find_matching_service,
_first_text_from_params_or_state,
_format_amount,
_idempotency_get,
_idempotency_set,
_parse_amount,
_resolve_service_csp_id,
_result_failed_or_missing_data,
_service_from_dict,
_to_dict,
)
from agente_contas_tim.workflows.actions.vas_avulso.helpers import (
_build_active_services,
_empty_cancel_result,
_service_can_be_canceled,
)
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
@workflow_action("consulta_vas")
def query_vas(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
input_state = state.get("input", {}) if isinstance(state, dict) else {}
app_id = str(input_state.get("app_id", ""))
service_context = input_state.get("service_context")
if isinstance(service_context, dict):
context_app_id = str(service_context.get("app_id", "")).strip()
if not app_id or context_app_id == app_id:
return ActionResult.ok(
{
"services": [service_context],
"service_found": True,
"service_status": str(service_context.get("status", "")),
"service": service_context,
"consulta_metadata": {"source": "service_context"},
}
)
result = runtime.factory.create_query_vas(
msisdn=str(params["msisdn"])
).execute()
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha na consulta",
**result.metadata,
)
payload = _to_dict(result.data)
services = payload.get("services", [])
target = next(
(service for service in services if str(service.get("app_id", "")) == app_id),
None,
)
return ActionResult.ok(
{
"services": services,
"service_found": target is not None,
"service_status": str((target or {}).get("status", "")),
"service": target,
"consulta_metadata": dict(result.metadata),
},
**result.metadata,
)
@workflow_action("cancelamento_vas_avulso_batch")
def cancelamento_vas_avulso_batch(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
csp_id = str(params.get("csp_id", "740")).strip() or "740"
channel = "AIAGENTCR"
request_status = str(params.get("request_status", "Fechado")).strip() or "Fechado"
status = str(params.get("status", "CLOSED")).strip() or "CLOSED"
message_id = _first_text_from_params_or_state(
state,
params,
"message_id",
"messageId",
)
idempotency_key = str(params.get("idempotency_key", "")).strip()
social_sec_no = re.sub(
r"\D",
"",
_first_text_from_params_or_state(
state,
params,
"social_sec_no",
"socialSecNo",
"customer_document",
"customerDocument",
),
)
input_state = state.get("input", {}) if isinstance(state, dict) else {}
_ic_base: dict[str, Any] = {
"sessionId": str(get_session_id() or ""),
"gsm": "",
"ani": str((input_state.get("ani") if isinstance(input_state, dict) else "") or "").strip(),
"uraCallId": str((input_state.get("ura_call_id") if isinstance(input_state, dict) else "") or "").strip(),
"agentId": "contas",
"channelId": str((input_state.get("channel_id") if isinstance(input_state, dict) else "URA") or "URA").strip(),
}
parsed_items = parse_cancel_vas_items(params.get("items"))
if not parsed_items:
return ActionResult.ok(
_empty_cancel_result(
"", mensagem="Nenhum serviço informado para contestação."
)
)
normalized_items: list[tuple[str, str, float]] = [
(item.msisdn, item.service, item.value) for item in parsed_items
]
_emit_vaa(VAATag.INICIO_FLUXO, _ic_base, gsm=normalized_items[0][0])
msisdns_in_order: list[str] = []
seen_msisdns: set[str] = set()
for item_msisdn, _, _ in normalized_items:
if item_msisdn not in seen_msisdns:
seen_msisdns.add(item_msisdn)
msisdns_in_order.append(item_msisdn)
protocol_entries: list[dict[str, str]] = []
active_by_msisdn: dict[str, dict[str, dict[str, Any]]] = {}
query_failures: set[str] = set()
for line_msisdn in msisdns_in_order:
query_result = runtime.factory.create_query_vas(
msisdn=line_msisdn
).execute()
if _result_failed_or_missing_data(query_result, state=state):
query_failures.add(line_msisdn)
active_by_msisdn[line_msisdn] = {}
continue
active_by_msisdn[line_msisdn] = _build_active_services(
getattr(query_result.data, "services", [])
)
canceled_items: list[dict[str, Any]] = []
errors: list[dict[str, str]] = []
not_found_services: list[dict[str, str]] = []
contestation_candidates: list[dict[str, Any]] = []
technical_error_in_iu = False
def _to_display_amount(value: Any) -> str:
candidates: list[Any] = [value]
def _collect_from_any(raw: Any) -> list[Any]:
collected: list[Any] = []
if raw is None:
return collected
if isinstance(raw, (str, int, float, Decimal)):
collected.append(raw)
return collected
if isinstance(raw, dict):
for key in (
"valor",
"price",
"amount",
"cost",
"value",
"valor_numeric",
"valor_float",
"price_numeric",
"amount_float",
):
if key in raw:
candidate = raw.get(key)
if candidate not in (None, ""):
collected.append(candidate)
details = raw.get("details")
if isinstance(details, dict):
collected.extend(_collect_from_any(details))
extra = raw.get("extra")
if isinstance(extra, dict):
collected.extend(_collect_from_any(extra))
if isinstance(raw, (list, tuple, set)):
for item in raw:
if item not in (None, ""):
collected.extend(_collect_from_any(item))
return collected
for candidate in _collect_from_any(value):
parsed = _parse_amount(candidate)
if parsed is None:
continue
try:
return _format_amount(parsed)
except Exception:
continue
return ""
def _resolve_item_amount(
requested: Any,
matched_service: dict[str, Any],
) -> str:
service_context = matched_service.get("service_context")
context = service_context if isinstance(service_context, dict) else {}
return (
_to_display_amount(requested)
or _to_display_amount(
{
"valor": matched_service.get("valor"),
"price": matched_service.get("price"),
"amount": matched_service.get("amount"),
"cost": matched_service.get("cost"),
"value": matched_service.get("value"),
"amount_numeric": matched_service.get("amount_numeric"),
"valor_numeric": matched_service.get("valor_numeric"),
"valor_float": matched_service.get("valor_float"),
"price_numeric": matched_service.get("price_numeric"),
"service_context": context,
}
)
)
def _parse_amount_from_item(item: dict[str, Any]) -> Decimal | None:
for key in ("valor", "value", "amount", "price", "servico_valor"):
raw_value = item.get(key)
if raw_value is None:
continue
parsed = _parse_amount(raw_value)
if parsed is not None:
return parsed
return None
def _build_contestation_candidate(
*,
msisdn: str,
service_name: str,
amount: Any,
app_id: str = "",
codigo_boleto: str = "",
iu_reason: str = "",
) -> dict[str, Any]:
resolved_amount = _to_display_amount(amount) or "0"
return {
"msisdn": msisdn,
"servico": str(service_name or "").strip(),
"service": str(service_name or "").strip(),
"app_id": str(app_id or "").strip(),
"valor": resolved_amount,
"value": resolved_amount,
"amount": resolved_amount,
"price": resolved_amount,
"codigo_boleto": str(codigo_boleto or "").strip(),
"iu_reason": str(iu_reason or "").strip(),
}
for item_msisdn, requested_service, requested_value in normalized_items:
if item_msisdn in query_failures:
technical_error_in_iu = True
errors.append(
{
"msisdn": item_msisdn,
"servico": requested_service,
"erro": (
"Falha ao consultar a linha de final "
f"{item_msisdn[-2:]}."
),
}
)
continue
active_services = active_by_msisdn.get(item_msisdn, {})
matched_service = _find_matching_service(
requested_service, active_services,
)
if not matched_service:
logger.info(
"cancelamento_vas_avulso_batch.skip reason=service_not_found msisdn=%s requested_service=%s available_services=%s",
item_msisdn,
requested_service,
list(active_services.keys()),
)
not_found_services.append(
{"msisdn": item_msisdn, "servico": requested_service}
)
contestation_candidates.append(
_build_contestation_candidate(
msisdn=item_msisdn,
service_name=requested_service,
amount=requested_value,
iu_reason="service_not_found",
)
)
continue
if not _service_can_be_canceled(matched_service):
logger.info(
"cancelamento_vas_avulso_batch.skip reason=can_cancel_false msisdn=%s requested_service=%s matched_service=%s",
item_msisdn,
requested_service,
str(matched_service.get("service_name", "")).strip(),
)
errors.append(
{
"msisdn": item_msisdn,
"servico": requested_service,
"erro": (
"Serviço já se encontra cancelado, não sendo possível "
"realizar recancelamento."
),
}
)
contestation_candidates.append(
_build_contestation_candidate(
msisdn=item_msisdn,
service_name=str(
matched_service.get("service_name", "")
).strip()
or requested_service,
amount=requested_value,
app_id=str(matched_service.get("app_id", "")).strip(),
codigo_boleto=str(
matched_service.get("codigo_boleto", "")
).strip(),
iu_reason="already_inactive_or_blocked",
)
)
continue
service_name = str(matched_service.get("service_name", ""))
app_id = str(matched_service.get("app_id", ""))
valor = _resolve_item_amount(requested_value, matched_service)
codigo_boleto = str(matched_service.get("codigo_boleto", "")).strip()
cache_key = ""
if idempotency_key and service_name:
cache_key = (
f"{idempotency_key}:{item_msisdn}:"
f"{service_name.lower()}:{app_id}"
)
cached_item = _idempotency_get(cache_key)
if cached_item is not None:
merged_item = dict(cached_item)
if not str(
merged_item.get("valor", "")
or merged_item.get("value", "")
or merged_item.get("amount", "")
).strip():
merged_item.update(
{
"valor": valor,
"value": valor,
"amount": valor,
"price": valor,
}
)
if cache_key:
_idempotency_set(cache_key, merged_item)
canceled_items.append(merged_item)
continue
resolved_csp_id = _resolve_service_csp_id(
matched_service,
fallback_csp_id=csp_id,
)
_emit_vaa(VAATag.BLOQUEIO_INICIO, _ic_base, gsm=item_msisdn)
block_result = runtime.factory.create_block_vas(
msisdn=item_msisdn,
app_id=app_id,
csp_id=resolved_csp_id,
service=_service_from_dict(
matched_service.get("service_context")
),
ic_context={**_ic_base, "gsm": item_msisdn},
).execute()
if not block_result.success:
technical_error_in_iu = True
_emit_vaa(VAATag.ITEM_CANCELADO_FAIL, _ic_base, gsm=item_msisdn)
errors.append(
{
"msisdn": item_msisdn,
"servico": service_name,
"erro": block_result.error or "Falha no bloqueio",
}
)
continue
cancel_result = runtime.factory.create_cancellation_vas(
msisdn=item_msisdn,
app_id=app_id,
csp_id=resolved_csp_id,
channel=channel,
interaction_protocol="",
service=_service_from_dict(
matched_service.get("service_context")
),
).execute()
if not cancel_result.success:
technical_error_in_iu = True
_emit_vaa(VAATag.ITEM_CANCELADO_FAIL, _ic_base, gsm=item_msisdn)
errors.append(
{
"msisdn": item_msisdn,
"servico": service_name,
"erro": cancel_result.error or "Falha no cancelamento",
}
)
continue
_emit_vaa(VAATag.ITEM_CANCELADO_OK, _ic_base, gsm=item_msisdn)
cancel_payload = _to_dict(cancel_result.data)
cancelamento = (
cancel_payload.get("cancelamento", {})
if isinstance(cancel_payload, dict)
else {}
)
cancellation_protocol = ""
if isinstance(cancelamento, dict):
cancellation_protocol = str(
cancelamento.get("interactionProtocol")
or cancelamento.get("protocolId")
or cancelamento.get("protocolo_id")
or ""
).strip()
if not cancellation_protocol:
body_payload = cancelamento.get("body", {})
if isinstance(body_payload, dict):
cancellation_protocol = str(
body_payload.get("interactionProtocol")
or body_payload.get("protocolId")
or body_payload.get("protocolo_id")
or ""
).strip()
canceled_item: dict[str, Any] = {
"msisdn": item_msisdn,
"servico": service_name,
"app_id": app_id,
"valor": valor,
"value": valor,
"amount": valor,
"price": valor,
"codigo_boleto": codigo_boleto,
}
canceled_items.append(canceled_item)
contestation_candidates.append(dict(canceled_item))
if cache_key:
_idempotency_set(cache_key, canceled_item)
_emit_vaa(VAATag.PROTOCOLO_INICIO, _ic_base, gsm=item_msisdn)
close_triplet = resolve_protocol_triplet(
"cancelamento_vas_avulso",
stage="close",
context={"servico": service_name},
)
protocol_result = runtime.factory.create_protocol_v2(
msisdn=item_msisdn,
interaction_protocol=cancellation_protocol,
flag_sms=True,
social_sec_no=social_sec_no,
source="CHAT",
reason1=close_triplet.reason1 or "Informação",
reason2=close_triplet.reason2 or "Conta",
reason3=close_triplet.reason3 or "Valor",
direction_contact="FROM-CLIENT",
type="CLIENTE",
request_flag=True,
request_status=request_status,
status=status,
service_request_notes=f"Serviço cancelado: {service_name}",
client_id="AIAGENTCR",
ic_context={**_ic_base, "gsm": str(item_msisdn)},
rct_operation=RCTOperation.REG_ATEND_VAS_AVULSO,
message_id=message_id,
).execute()
if _result_failed_or_missing_data(protocol_result, state=state):
errors.append(
{
"msisdn": item_msisdn,
"servico": service_name,
"erro": (
protocol_result.error
or "Falha ao registrar protocolo após cancelamento"
),
}
)
continue
_emit_vaa(VAATag.PROTOCOLO_OK, _ic_base, gsm=item_msisdn)
protocol_payload = _to_dict(protocol_result.data)
protocol_response = (
protocol_payload.get("response", {})
if isinstance(protocol_payload, dict)
else {}
)
if not isinstance(protocol_response, dict):
protocol_response = {}
protocolo_id = str(
cancellation_protocol
or protocol_response.get("interactionProtocol")
or protocol_response.get("protocolId")
or protocol_response.get("protocolo_id")
or ""
).strip()
if not protocolo_id:
errors.append(
{
"msisdn": item_msisdn,
"servico": service_name,
"erro": "Protocolo não retornado no registro de atendimento",
}
)
continue
protocol_entries.append(
{"msisdn": item_msisdn, "protocolo_id": protocolo_id}
)
summary_parts: list[str] = []
if canceled_items:
by_line: dict[str, list[str]] = {}
for item in canceled_items:
by_line.setdefault(
str(item.get("msisdn", "")), []
).append(str(item.get("servico", "")))
for line, names in by_line.items():
summary_parts.append(
"Cancelados com sucesso na linha de final "
f"{line[-2:]}: {', '.join(names)}."
)
for error in errors:
summary_parts.append(
f"Erro ao cancelar {error['servico']} "
f"(linha {error['msisdn'][-2:]}): {error['erro']}."
)
if not_found_services:
joined = ", ".join(
f"{nf['servico']} (linha {nf['msisdn'][-2:]})"
for nf in not_found_services
)
summary_parts.append(f"Não encontrados: {joined}.")
total_valor = Decimal("0")
total_tem_valor = False
cancelados_descritivo: list[str] = []
for item in canceled_items:
servico = str(item.get("servico", "")).strip()
valor = str(item.get("valor", "")).strip()
parsed = _parse_amount_from_item(item)
if parsed is not None:
total_valor += parsed
total_tem_valor = True
if servico:
valor_txt = f" (valor: R$ {valor})" if valor else ""
cancelados_descritivo.append(f"{servico}{valor_txt}")
total_valor_txt = (
f"R$ {_format_amount(total_valor)}"
if total_tem_valor
else "R$ 0,00"
)
if technical_error_in_iu:
contestation_candidates = []
primary_msisdn = msisdns_in_order[0] if msisdns_in_order else ""
resumo_agente = (
"Cancelamento VAS avulso concluido. "
f"Telefone: {primary_msisdn}. "
"Servicos cancelados: "
f"{', '.join(cancelados_descritivo) if cancelados_descritivo else 'nenhum'}. "
f"Total dos servicos cancelados: {total_valor_txt}."
)
return ActionResult.ok(
{
"success": len(canceled_items) > 0,
"mensagem": " ".join(summary_parts),
"resumo_agente": resumo_agente,
"msisdn": primary_msisdn,
"cancelados": canceled_items,
"erros": errors,
"nao_encontrados": not_found_services,
"itens_para_contestacao": contestation_candidates,
"protocolos_por_linha": protocol_entries,
"protocol_closed": bool(protocol_entries),
"prompt_signals": {
"servicos_cancelados": [
item.get("servico") for item in canceled_items
],
},
}
)
@workflow_action("avaliar_proxima_acao")
def evaluate_next_action(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Action exemplo: decide se deve aguardar confirmação do usuário."""
services = []
previous_query = state.get("vars", {}).get("node1")
if isinstance(previous_query, dict):
previous_services = previous_query.get("services", [])
if isinstance(previous_services, list):
services = previous_services
metadata: dict[str, Any] = {}
if not services:
result = runtime.factory.create_query_vas(
msisdn=str(params["msisdn"])
).execute()
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha na consulta para avaliação",
**result.metadata,
)
services = _to_dict(result.data).get("services", [])
metadata = dict(result.metadata)
tim_music = next(
(
s
for s in services
if str(s.get("service_name", "")).strip().lower() == "tim music"
and str(s.get("status", "")).upper() == "ACTIVE"
),
None,
)
if tim_music:
client_payload = {
"type": "question",
"text": (
"O motivo do aumento da sua fatura é o serviço TIM Music, que está ativo em sua linha. "
"Deseja cancelar o TIM Music? Se sim, responda SIM para que eu possa prosseguir com o bloqueio e cancelamento."
),
"options": ["SIM", "NAO"],
"service": {
"app_id": tim_music.get("app_id", ""),
"service_name": tim_music.get("service_name", ""),
"status": tim_music.get("status", ""),
},
}
return ActionResult.ok(
{
"await_user_input": True,
"client_payload": client_payload,
"service_context": tim_music,
},
**metadata,
)
# Caso não haja TIM Music ativo, segue fluxo normal
app_id = str(params.get("app_id", state.get("input", {}).get("app_id", "")))
target = next(
(service for service in services if str(service.get("app_id", "")) == app_id),
None,
)
if target is None:
return ActionResult.ok(
{
"await_user_input": False,
"client_payload": {
"type": "info",
"text": f"Serviço appId={app_id} não localizado para confirmação.",
},
}
)
status = str(target.get("status", "")).upper()
await_user_input = status == "ACTIVE"
if await_user_input:
client_payload = {
"type": "question",
"text": "Deseja continuar com a operação deste serviço?",
"options": ["SIM", "NAO"],
"service": {
"app_id": target.get("app_id", ""),
"service_name": target.get("service_name", ""),
"status": target.get("status", ""),
},
}
else:
client_payload = {
"type": "info",
"text": "Serviço não requer confirmação adicional.",
"service": {
"app_id": target.get("app_id", ""),
"service_name": target.get("service_name", ""),
"status": target.get("status", ""),
},
}
return ActionResult.ok(
{
"await_user_input": await_user_input,
"client_payload": client_payload,
"service_context": target,
},
**metadata,
)
@workflow_action("bloquear_vas")
def bloquear_vas(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
service_context = state.get("vars", {}).get("node2", {}).get("service_context")
msisdn = str(params["msisdn"])
input_state = state.get("input", {}) if isinstance(state, dict) else {}
result = runtime.factory.create_block_vas(
msisdn=msisdn,
app_id=str(params["app_id"]),
csp_id=str(params.get("csp_id", "740")),
service=_service_from_dict(service_context),
ic_context=_build_ic_context(input_state, gsm=msisdn),
query_metadata=state.get("vars", {})
.get("node1", {})
.get("consulta_metadata", {}),
).execute()
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha no bloqueio",
**result.metadata,
)
return ActionResult.ok(_to_dict(result.data), **result.metadata)
@workflow_action("cancelar_vas")
def cancelar_vas(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
service_context = state.get("vars", {}).get("node2", {}).get("service_context")
result = runtime.factory.create_cancellation_vas(
msisdn=str(params["msisdn"]),
app_id=str(params["app_id"]),
csp_id=str(params.get("csp_id", "740")),
interaction_protocol=str(params.get("interaction_protocol", "")),
channel="AIAGENTCR",
service=_service_from_dict(service_context),
query_metadata=state.get("vars", {})
.get("node1", {})
.get("consulta_metadata", {}),
).execute()
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha no cancelamento",
**result.metadata,
)
return ActionResult.ok(_to_dict(result.data), **result.metadata)
@workflow_action("bloquear_vas_single")
def bloquear_vas_single(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Bloqueia um serviço VAS usando msisdn + app_id dos params."""
service_context = (
state.get("vars", {})
.get("consulta_vas", {})
.get("service")
)
msisdn = str(params["msisdn"])
input_state = state.get("input", {}) if isinstance(state, dict) else {}
result = runtime.factory.create_block_vas(
msisdn=msisdn,
app_id=str(params["app_id"]),
csp_id=str(params.get("csp_id", "740")),
service=_service_from_dict(service_context),
ic_context=_build_ic_context(input_state, gsm=msisdn),
).execute()
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha no bloqueio",
**result.metadata,
)
return ActionResult.ok(_to_dict(result.data), **result.metadata)
@workflow_action("cancelar_vas_single")
def cancelar_vas_single(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Cancela um serviço VAS usando msisdn + app_id dos params."""
service_context = (
state.get("vars", {})
.get("consulta_vas", {})
.get("service")
)
result = runtime.factory.create_cancellation_vas(
msisdn=str(params["msisdn"]),
app_id=str(params["app_id"]),
csp_id=str(params.get("csp_id", "740")),
channel="AIAGENTCR",
interaction_protocol=str(params.get("interaction_protocol", "")),
service=_service_from_dict(service_context),
).execute()
if _result_failed_or_missing_data(result, state=state):
return ActionResult.fail(
result.error or "Falha no cancelamento",
**result.metadata,
)
return ActionResult.ok(_to_dict(result.data), **result.metadata)
__all__ = [
'query_vas',
'cancelamento_vas_avulso_batch',
'evaluate_next_action',
'bloquear_vas',
'cancelar_vas',
'bloquear_vas_single',
'cancelar_vas_single',
]

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
from typing import Any
from agente_contas_tim.workflows.actions.common.helpers import (
_extract_amount,
_extract_boleto_code,
_to_dict,
)
def _empty_cancel_result(
msisdn: str,
*,
mensagem: str,
nao_encontrados: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
return {
"success": False,
"mensagem": mensagem,
"resumo_agente": (
"Contestacao VAS avulso concluida. "
f"Telefone: {msisdn}. "
"SMS enviado: nao. "
"Servicos cancelados: nenhum. "
"Total dos servicos cancelados: R$ 0,00."
),
"msisdn": msisdn,
"cancelados": [],
"erros": [],
"nao_encontrados": nao_encontrados or [],
"prompt_signals": {
"sms_enviado": False,
"servicos_com_sms": [],
"servicos_com_credito_fatura": [],
"tipos_fatura_por_servico": {},
"boletos_por_servico": {},
"data_credito_por_servico": {},
},
}
def _build_active_services(raw_services: Any) -> dict[str, dict[str, Any]]:
active: dict[str, dict[str, Any]] = {}
if not isinstance(raw_services, (list, tuple)):
return active
for raw_service in raw_services:
service_dict = _to_dict(raw_service)
service_name = str(service_dict.get("service_name", "") or "")
app_id = str(service_dict.get("app_id", "") or "")
extra = service_dict.get("extra", {}) or {}
if service_name and app_id:
active[service_name.lower()] = {
"service_name": service_name,
"app_id": app_id,
"valor": _extract_amount(extra),
"codigo_boleto": _extract_boleto_code(extra),
"service_context": service_dict,
}
return active
def _service_can_be_canceled(matched_service: dict[str, Any]) -> bool:
context = matched_service.get("service_context", {})
if not isinstance(context, dict):
return False
extra = context.get("extra", {})
if not isinstance(extra, dict):
return False
can = extra.get("can", {})
if not isinstance(can, dict):
return False
return can.get("cancel") is True
__all__ = [
'_empty_cancel_result',
'_build_active_services',
'_service_can_be_canceled',
]

View File

@@ -0,0 +1,408 @@
from __future__ import annotations
import logging
import re
from typing import Any
from agente_contas_tim.integrations.rct_policy import RCTOperation
from agente_contas_tim.observability import get_session_id
from agente_contas_tim.protocol_triplets import resolve_protocol_triplet
from agente_contas_tim.workflows.actions.registry import (
WorkflowRuntimeContext,
workflow_action,
)
from agente_contas_tim.workflows.runtime_types import ActionResult
from agente_contas_tim.workflows.actions.common.helpers import (
_final_msisdn,
_first_text_from_params_or_state,
_result_failed_or_missing_data,
_runtime_llm_callbacks,
_runtime_llm_metadata,
_to_dict,
)
from agente_contas_tim.workflows.actions.vas_estrategico.helpers import (
_build_vas_accept_message,
_build_vas_bundle_close_message,
_build_vas_initial_message,
_build_vas_lines_from_items,
_emit_veb_005_cancelamento_info,
_format_protocolo_suffix_vas,
_is_vas_cancelamento_info_path,
)
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
@workflow_action("montar_resposta_texto")
def montar_resposta_text(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Monta resposta de texto para o cliente (sem SMS)."""
dados = params.get("dados", {})
texto = str(dados.get("texto_usuario", ""))
return ActionResult.ok({
"success": True,
"mensagem": texto,
})
@workflow_action("preparar_vas_estrategico")
def preparar_vas_estrategico(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
lines = params.get("linhas", [])
if not isinstance(lines, list) or not lines:
items = params.get("items", [])
if not isinstance(items, list):
items = []
lines = _build_vas_lines_from_items(
[item for item in items if isinstance(item, dict)]
)
normalized_lines = []
for line in lines:
if not isinstance(line, dict):
continue
msisdn = str(line.get("msisdn", "")).strip()
if not msisdn:
continue
raw_items = line.get("items", [])
line_items = (
[item for item in raw_items if isinstance(item, dict)]
if isinstance(raw_items, list)
else []
)
if not line_items:
line_items = []
for bundle_name in line.get("bundle_names", []):
name = str(bundle_name).strip()
if name:
line_items.append(
{"type": "bundle", "msisdn": msisdn, "name": name}
)
for estrategico_name in line.get("estrategico_names", []):
name = str(estrategico_name).strip()
if name:
line_items.append(
{"type": "estrategico", "msisdn": msisdn, "name": name}
)
rebuilt = _build_vas_lines_from_items(line_items)
if rebuilt:
normalized_lines.extend(rebuilt)
if not normalized_lines:
return ActionResult.fail(
"Nenhum item valido para o fluxo de VAS estrategico."
)
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
source_node = str(params.get("source_node", "resolve")).strip() or "resolve"
payload = vars_state.get(source_node, {})
if not isinstance(payload, dict):
payload = {}
instrucoes = str(payload.get("content", "")).strip()
has_estrategico_items = any(
bool(
isinstance(line, dict)
and isinstance(line.get("estrategico_names", []), list)
and any(str(name).strip() for name in line.get("estrategico_names", []))
)
for line in normalized_lines
)
has_bundle_items = any(
bool(
isinstance(line, dict)
and isinstance(line.get("bundle_names", []), list)
and any(str(name).strip() for name in line.get("bundle_names", []))
)
for line in normalized_lines
)
return ActionResult.ok(
{
"linhas": normalized_lines,
"instrucoes": instrucoes,
"mensagem": _build_vas_initial_message(normalized_lines),
"mensagem_pos_aceite": _build_vas_accept_message(normalized_lines),
"mensagem_bundle_fechamento": _build_vas_bundle_close_message(
normalized_lines
),
# Bundle também pausa em "sanei sua dúvida?": o agente não cancela,
# só explica que é incluso. A finalização ocorre DEPOIS da resposta
# do cliente, não no turno da explicação. (incidente CY0010)
"await_user_input": has_estrategico_items or has_bundle_items,
"has_estrategico_items": has_estrategico_items,
"has_bundle_items": has_bundle_items,
}
)
@workflow_action("montar_explicacao_cancelamento_vas_estrategico")
def montar_explicacao_cancelamento_vas_estrategico(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
lines = params.get("linhas", [])
orientacoes = params.get("orientacoes_cancelamento", [])
orientacoes_map: dict[tuple[str, str], str] = {}
if isinstance(orientacoes, list):
for item in orientacoes:
if not isinstance(item, dict):
continue
msisdn = str(item.get("msisdn", "")).strip()
name = str(item.get("name", "")).strip().lower()
orientacao = str(item.get("orientacao", "")).strip()
if msisdn and name and orientacao:
orientacoes_map[(msisdn, name)] = orientacao
context_lines: list[str] = []
fallback_sections: list[str] = []
if isinstance(lines, list):
for line in lines:
if not isinstance(line, dict):
continue
msisdn = str(line.get("msisdn", "")).strip()
if not msisdn:
continue
final_line = _final_msisdn(msisdn)
estrategico_names = [
str(name).strip()
for name in line.get("estrategico_names", [])
if str(name).strip()
]
for service_name in estrategico_names:
orientacao = orientacoes_map.get(
(msisdn, service_name.lower()),
(
"O cancelamento deve ser feito no app ou site oficial "
"do parceiro que fornece o serviço."
),
)
raw = str(orientacao).replace("\\n", "\n")
parts = [
re.sub(r"\s+", " ", p).strip()
for p in raw.split("\n---\n")
]
cleaned_orientation = " || ".join(p for p in parts if p)
context_lines.append(
f"Serviço: {service_name} | Linha final: {final_line} | "
f"Orientação bruta: {cleaned_orientation}"
)
fallback_sections.append(
f"Para cancelar {service_name} na linha final {final_line}: "
"acesse o app ou site oficial do parceiro e solicite "
"o cancelamento da assinatura."
)
if not context_lines:
fallback_sections.append(
"Não há serviços estratégicos com procedimento adicional de "
"cancelamento para este atendimento."
)
input_state = state.get("input", {}) if isinstance(state, dict) else {}
next_subject = str(
(input_state.get("next_subject") if isinstance(input_state, dict) else "")
or params.get("next_subject", "")
or ""
).strip()
if runtime.llm_gateway is not None and context_lines:
try:
cancelamento_context = "\n".join(context_lines)
llm_result = runtime.llm_gateway.execute(
capability_id="fluxo_vas_estrategico_cancelamento_resumo",
variables={
"cancelamento_context": cancelamento_context,
"next_subject": next_subject,
},
user_text=cancelamento_context,
callbacks=_runtime_llm_callbacks(runtime),
tags=["workflow_action"],
metadata=_runtime_llm_metadata(runtime),
)
llm_message = str(llm_result.content or "").strip()
if llm_message:
return ActionResult.ok({"mensagem": llm_message})
except Exception:
pass
fallback_msg = "\n".join(fallback_sections[:4])
if next_subject:
transition = f" Podemos seguir agora com o tratamento de {next_subject}?"
if transition.strip().lower() not in fallback_msg.lower():
fallback_msg = f"{fallback_msg.rstrip()}{transition}"
return ActionResult.ok({"mensagem": fallback_msg})
@workflow_action("registrar_atendimento_vas_estrategico")
def registrar_atendimento_vas_estrategico(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> ActionResult:
"""Registra protocolo por linha para atendimento de VAS estratégico."""
lines = params.get("linhas", [])
if not isinstance(lines, list):
lines = []
mensagem_base = str(params.get("mensagem_base", "")).strip()
input_state = state.get("input", {}) if isinstance(state, dict) else {}
social_sec_no = re.sub(
r"\D",
"",
_first_text_from_params_or_state(
state,
params,
"social_sec_no",
"socialSecNo",
"cpf",
"customer_document",
"customerDocument",
"document",
),
)
open_triplet = resolve_protocol_triplet("vas_estrategico", stage="open")
request_status = str(params.get("request_status", "Fechado")).strip() or "Fechado"
status = str(params.get("status", "CLOSED")).strip() or "CLOSED"
message_id = _first_text_from_params_or_state(
state,
params,
"message_id",
"messageId",
)
should_emit_veb_005 = _is_vas_cancelamento_info_path(
state,
mensagem_base=mensagem_base,
)
protocolos_por_linha: list[dict[str, Any]] = []
erros: list[dict[str, Any]] = []
_ic_emitted = False
_veb_005_emitted = False
for line in lines:
if not isinstance(line, dict):
continue
msisdn = str(line.get("msisdn", "")).strip()
if not msisdn:
continue
bundle_names = line.get("bundle_names", [])
estrategico_names = line.get("estrategico_names", [])
if not isinstance(bundle_names, list):
bundle_names = []
if not isinstance(estrategico_names, list):
estrategico_names = []
reason3 = open_triplet.reason3 or "Valor"
_ic_base: dict[str, Any] = {
"sessionId": str(get_session_id() or ""),
"gsm": str(msisdn),
"ani": str((input_state.get("ani") if isinstance(input_state, dict) else "") or "").strip(),
"uraCallId": str((input_state.get("ura_call_id") if isinstance(input_state, dict) else "") or "").strip(),
"agentId": "contas",
"channelId": str((input_state.get("channel_id") if isinstance(input_state, dict) else "URA") or "URA").strip(),
}
if should_emit_veb_005 and not _veb_005_emitted:
_emit_veb_005_cancelamento_info(
input_state=input_state if isinstance(input_state, dict) else {},
msisdn=msisdn,
mensagem_base=mensagem_base,
)
_veb_005_emitted = True
register_result = runtime.factory.create_protocol_v2(
msisdn=msisdn,
interaction_protocol="",
social_sec_no=social_sec_no,
source="CHAT",
reason1=open_triplet.reason1 or "Informação",
reason2=open_triplet.reason2 or "Conta",
reason3=reason3,
direction_contact="FROM-CLIENT",
type="CLIENTE",
request_flag=True,
request_status=request_status,
status=status,
service_request_notes="Solicitação via chat",
client_id="AIAGENTCR",
ic_context=_ic_base,
rct_operation=RCTOperation.REG_ATEND_VAS_ESTRAT,
message_id=message_id,
).execute()
if register_result.success:
_ic_emitted = True
if _result_failed_or_missing_data(register_result, state=state):
erros.append(
{
"msisdn": msisdn,
"stage": "register_protocol",
"erro": register_result.error or "Falha ao registrar protocolo",
}
)
continue
payload = _to_dict(register_result.data)
response = payload.get("response", {}) if isinstance(payload, dict) else {}
protocolo_id = ""
if isinstance(response, dict):
protocolo_id = str(
response.get("interactionProtocol")
or response.get("protocolId")
or response.get("protocolo_id")
or ""
).strip()
if not protocolo_id:
erros.append(
{
"msisdn": msisdn,
"stage": "register_protocol",
"erro": "Protocolo não retornado pelo serviço",
}
)
continue
protocolos_por_linha.append(
{
"msisdn": msisdn,
"protocolo_id": protocolo_id,
}
)
suffix = _format_protocolo_suffix_vas(protocolos_por_linha)
if not mensagem_base:
mensagem_final = "Manteremos o serviço."
if suffix:
mensagem_final = f"{mensagem_final} {suffix}"
else:
mensagem_final = (
f"{mensagem_base} {suffix}".strip() if suffix else mensagem_base
)
protocolos_raw = [
str(p.get("protocolo_id", "")).strip()
for p in protocolos_por_linha
if isinstance(p, dict) and str(p.get("protocolo_id", "")).strip()
]
output: dict[str, Any] = {
"success": len(protocolos_por_linha) > 0,
"mensagem": mensagem_final,
"protocolos_por_linha": protocolos_por_linha,
"erros": erros,
"protocol_closed": len(protocolos_por_linha) > 0,
"mocked": False,
}
if protocolos_raw:
output["requires_protocol_in_response"] = True
output["protocols_for_response"] = protocolos_raw
return ActionResult.ok(output)
__all__ = [
'montar_resposta_text',
'preparar_vas_estrategico',
'montar_explicacao_cancelamento_vas_estrategico',
'registrar_atendimento_vas_estrategico',
]

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