commit 60c2f03eb331080d04fc5b01d046e5fe05f8089d Author: hoshikawa2 Date: Tue Jun 16 20:54:49 2026 -0300 first commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..430eedf Binary files /dev/null and b/.DS_Store differ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..74e2f2a --- /dev/null +++ b/.env.example @@ -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= diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..18d7b81 --- /dev/null +++ b/.idea/.gitignore @@ -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 diff --git a/.idea/first_contas.iml b/.idea/first_contas.iml new file mode 100644 index 0000000..18ec59d --- /dev/null +++ b/.idea/first_contas.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..9d74af9 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..3f75568 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..188eaef --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.oca/custom_code_review_guidelines.txt b/.oca/custom_code_review_guidelines.txt new file mode 100644 index 0000000..a0a3b63 --- /dev/null +++ b/.oca/custom_code_review_guidelines.txt @@ -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. +# +# *Comment: +# Category: Minor +# Issue: Use streams instead of a loop for better readability. +# Code Block: +# +# ```java +# // Calculate squares of numbers +# List squares = new ArrayList<>(); +# for (int number : numbers) { +# squares.add(number * number); +# } +# ``` +# Recommendation: +# +# ```java +# // Calculate squares of numbers +# List squares = Arrays.stream(numbers) +# .map(n -> n * n) // Map each number to its square +# .toList(); +# ``` +# diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..273fe01 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..51e3baa --- /dev/null +++ b/README.md @@ -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`. diff --git a/README_MIGRACAO_CONTAS_FIRST.md b/README_MIGRACAO_CONTAS_FIRST.md new file mode 100644 index 0000000..df0f220 --- /dev/null +++ b/README_MIGRACAO_CONTAS_FIRST.md @@ -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`. diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 0000000..ec9246e Binary files /dev/null and b/app/.DS_Store differ diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/__pycache__/__init__.cpython-313.pyc b/app/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..c897304 Binary files /dev/null and b/app/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/__pycache__/main.cpython-313.pyc b/app/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..4499a9e Binary files /dev/null and b/app/__pycache__/main.cpython-313.pyc differ diff --git a/app/__pycache__/state.cpython-313.pyc b/app/__pycache__/state.cpython-313.pyc new file mode 100644 index 0000000..6255e07 Binary files /dev/null and b/app/__pycache__/state.cpython-313.pyc differ diff --git a/app/agents/__init__.py b/app/agents/__init__.py new file mode 100644 index 0000000..17c551c --- /dev/null +++ b/app/agents/__init__.py @@ -0,0 +1,3 @@ +from .contas_agent import ContasAgent + +__all__ = ["ContasAgent"] diff --git a/app/agents/__pycache__/__init__.cpython-313.pyc b/app/agents/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..7eee036 Binary files /dev/null and b/app/agents/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/agents/__pycache__/contas_agent.cpython-313.pyc b/app/agents/__pycache__/contas_agent.cpython-313.pyc new file mode 100644 index 0000000..a2b0f0a Binary files /dev/null and b/app/agents/__pycache__/contas_agent.cpython-313.pyc differ diff --git a/app/agents/__pycache__/runtime.cpython-313.pyc b/app/agents/__pycache__/runtime.cpython-313.pyc new file mode 100644 index 0000000..a4083ef Binary files /dev/null and b/app/agents/__pycache__/runtime.cpython-313.pyc differ diff --git a/app/agents/contas_agent.py b/app/agents/contas_agent.py new file mode 100644 index 0000000..d7d5c08 --- /dev/null +++ b/app/agents/contas_agent.py @@ -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" diff --git a/app/agents/prompting.py b/app/agents/prompting.py new file mode 100644 index 0000000..255422b --- /dev/null +++ b/app/agents/prompting.py @@ -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}" diff --git a/app/agents/runtime.py b/app/agents/runtime.py new file mode 100644 index 0000000..e6429c4 --- /dev/null +++ b/app/agents/runtime.py @@ -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"] diff --git a/app/examples/__init__.py b/app/examples/__init__.py new file mode 100644 index 0000000..3f95e96 --- /dev/null +++ b/app/examples/__init__.py @@ -0,0 +1 @@ +"""Exemplos de uso do template backend enterprise.""" diff --git a/app/examples/grl_examples.py b/app/examples/grl_examples.py new file mode 100644 index 0000000..8dadac8 --- /dev/null +++ b/app/examples/grl_examples.py @@ -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", + ) diff --git a/app/examples/ic_examples.py b/app/examples/ic_examples.py new file mode 100644 index 0000000..f6daa57 --- /dev/null +++ b/app/examples/ic_examples.py @@ -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", + ) diff --git a/app/examples/mcp_examples.py b/app/examples/mcp_examples.py new file mode 100644 index 0000000..613f10c --- /dev/null +++ b/app/examples/mcp_examples.py @@ -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 diff --git a/app/examples/noc_examples.py b/app/examples/noc_examples.py new file mode 100644 index 0000000..2b38a15 --- /dev/null +++ b/app/examples/noc_examples.py @@ -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", + ) diff --git a/app/examples/observer_examples.py b/app/examples/observer_examples.py new file mode 100644 index 0000000..926b553 --- /dev/null +++ b/app/examples/observer_examples.py @@ -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", + ) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..386cd89 --- /dev/null +++ b/app/main.py @@ -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() diff --git a/app/observability/__init__.py b/app/observability/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/observability/__pycache__/__init__.cpython-313.pyc b/app/observability/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..99a48eb Binary files /dev/null and b/app/observability/__pycache__/__init__.cpython-313.pyc differ diff --git a/app/observability/__pycache__/telemetry_observer.cpython-313.pyc b/app/observability/__pycache__/telemetry_observer.cpython-313.pyc new file mode 100644 index 0000000..9cfd6e5 Binary files /dev/null and b/app/observability/__pycache__/telemetry_observer.cpython-313.pyc differ diff --git a/app/observability/telemetry_observer.py b/app/observability/telemetry_observer.py new file mode 100644 index 0000000..92f07a1 --- /dev/null +++ b/app/observability/telemetry_observer.py @@ -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}) diff --git a/app/state.py b/app/state.py new file mode 100644 index 0000000..3620e4e --- /dev/null +++ b/app/state.py @@ -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 diff --git a/app/workflows/.DS_Store b/app/workflows/.DS_Store new file mode 100644 index 0000000..444b40f Binary files /dev/null and b/app/workflows/.DS_Store differ diff --git a/app/workflows/__pycache__/agent_graph.cpython-313.pyc b/app/workflows/__pycache__/agent_graph.cpython-313.pyc new file mode 100644 index 0000000..b8eb02d Binary files /dev/null and b/app/workflows/__pycache__/agent_graph.cpython-313.pyc differ diff --git a/app/workflows/agent_graph.py b/app/workflows/agent_graph.py new file mode 100644 index 0000000..90a4f64 --- /dev/null +++ b/app/workflows/agent_graph.py @@ -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")}}) diff --git a/backend_mock/README.md b/backend_mock/README.md new file mode 100644 index 0000000..8ba6ba9 --- /dev/null +++ b/backend_mock/README.md @@ -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 \ No newline at end of file diff --git a/backend_mock/__pycache__/app.cpython-313.pyc b/backend_mock/__pycache__/app.cpython-313.pyc new file mode 100644 index 0000000..cc158d1 Binary files /dev/null and b/backend_mock/__pycache__/app.cpython-313.pyc differ diff --git a/backend_mock/app.py b/backend_mock/app.py new file mode 100644 index 0000000..f847ac0 --- /dev/null +++ b/backend_mock/app.py @@ -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"} \ No newline at end of file diff --git a/config/agents.yaml b/config/agents.yaml new file mode 100644 index 0000000..f19362b --- /dev/null +++ b/config/agents.yaml @@ -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. diff --git a/config/agents/contas_first/guardrails.yaml b/config/agents/contas_first/guardrails.yaml new file mode 100644 index 0000000..ef39081 --- /dev/null +++ b/config/agents/contas_first/guardrails.yaml @@ -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 diff --git a/config/agents/contas_first/judges.yaml b/config/agents/contas_first/judges.yaml new file mode 100644 index 0000000..8206da3 --- /dev/null +++ b/config/agents/contas_first/judges.yaml @@ -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 diff --git a/config/agents/contas_first/prompt_policy.yaml b/config/agents/contas_first/prompt_policy.yaml new file mode 100644 index 0000000..0945d21 --- /dev/null +++ b/config/agents/contas_first/prompt_policy.yaml @@ -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. diff --git a/config/guardrails.yaml b/config/guardrails.yaml new file mode 100644 index 0000000..44887eb --- /dev/null +++ b/config/guardrails.yaml @@ -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 \ No newline at end of file diff --git a/config/identity.yaml b/config/identity.yaml new file mode 100644 index 0000000..d1df6e2 --- /dev/null +++ b/config/identity.yaml @@ -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 diff --git a/config/judges.yaml b/config/judges.yaml new file mode 100644 index 0000000..d488063 --- /dev/null +++ b/config/judges.yaml @@ -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 \ No newline at end of file diff --git a/config/mcp_parameter_mapping.yaml b/config/mcp_parameter_mapping.yaml new file mode 100644 index 0000000..31abc87 --- /dev/null +++ b/config/mcp_parameter_mapping.yaml @@ -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 diff --git a/config/mcp_servers.docker.yaml b/config/mcp_servers.docker.yaml new file mode 100644 index 0000000..79a66b0 --- /dev/null +++ b/config/mcp_servers.docker.yaml @@ -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. diff --git a/config/mcp_servers.yaml b/config/mcp_servers.yaml new file mode 100644 index 0000000..fe6cb87 --- /dev/null +++ b/config/mcp_servers.yaml @@ -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. diff --git a/config/prompt_policy.yaml b/config/prompt_policy.yaml new file mode 100644 index 0000000..32924f3 --- /dev/null +++ b/config/prompt_policy.yaml @@ -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 diff --git a/config/routing.yaml b/config/routing.yaml new file mode 100644 index 0000000..3216986 --- /dev/null +++ b/config/routing.yaml @@ -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. diff --git a/config/tools.yaml b/config/tools.yaml new file mode 100644 index 0000000..84ff156 --- /dev/null +++ b/config/tools.yaml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4aec748 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/ATUALIZACAO_TEMPLATE_ANALYTICS_OUTPUT_SUPERVISOR.md b/docs/ATUALIZACAO_TEMPLATE_ANALYTICS_OUTPUT_SUPERVISOR.md new file mode 100644 index 0000000..d81efdf --- /dev/null +++ b/docs/ATUALIZACAO_TEMPLATE_ANALYTICS_OUTPUT_SUPERVISOR.md @@ -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//topics/ +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= +OCI_STREAM_OCID= +GCP_PUBSUB_TOPIC_PATH=projects//topics/ +``` + +## 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. diff --git a/docs/COMO_USAR_IC_NOC_GRL_NO_TEMPLATE.md b/docs/COMO_USAR_IC_NOC_GRL_NO_TEMPLATE.md new file mode 100644 index 0000000..83975af --- /dev/null +++ b/docs/COMO_USAR_IC_NOC_GRL_NO_TEMPLATE.md @@ -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. diff --git a/docs/CONVERSATION_SUMMARY_MEMORY_BACKEND.md b/docs/CONVERSATION_SUMMARY_MEMORY_BACKEND.md new file mode 100644 index 0000000..3f981ac --- /dev/null +++ b/docs/CONVERSATION_SUMMARY_MEMORY_BACKEND.md @@ -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`. diff --git a/docs/ENV_REAL_INTEGRATION_STATUS.md b/docs/ENV_REAL_INTEGRATION_STATUS.md new file mode 100644 index 0000000..a75af4f --- /dev/null +++ b/docs/ENV_REAL_INTEGRATION_STATUS.md @@ -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. diff --git a/docs/FRAMEWORK_CHANNEL_INPUT_MODE.md b/docs/FRAMEWORK_CHANNEL_INPUT_MODE.md new file mode 100644 index 0000000..c7bd3b2 --- /dev/null +++ b/docs/FRAMEWORK_CHANNEL_INPUT_MODE.md @@ -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 +``` diff --git a/docs/GUARDRAILS_PARALLELOS_OBSERVER_IC.md b/docs/GUARDRAILS_PARALLELOS_OBSERVER_IC.md new file mode 100644 index 0000000..849fda1 --- /dev/null +++ b/docs/GUARDRAILS_PARALLELOS_OBSERVER_IC.md @@ -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`. diff --git a/docs/IMPLEMENTACAO_IC_NOC_GRL_SEM_REMOVER_LOGICA.md b/docs/IMPLEMENTACAO_IC_NOC_GRL_SEM_REMOVER_LOGICA.md new file mode 100644 index 0000000..edcd2c7 --- /dev/null +++ b/docs/IMPLEMENTACAO_IC_NOC_GRL_SEM_REMOVER_LOGICA.md @@ -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._MCP_CONTEXT_COLLECTED` quando houver dados MCP +- `IC._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. diff --git a/docs/LANGFUSE_SINGLE_TRACE_OBSERVER_FIX.md b/docs/LANGFUSE_SINGLE_TRACE_OBSERVER_FIX.md new file mode 100644 index 0000000..bc2638b --- /dev/null +++ b/docs/LANGFUSE_SINGLE_TRACE_OBSERVER_FIX.md @@ -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. diff --git a/docs/MCP_DIAGNOSTIC_FIX_V6.md b/docs/MCP_DIAGNOSTIC_FIX_V6.md new file mode 100644 index 0000000..1cd2ccf --- /dev/null +++ b/docs/MCP_DIAGNOSTIC_FIX_V6.md @@ -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 +``` diff --git a/docs/MCP_ENV_LOADING_FIX_V8.md b/docs/MCP_ENV_LOADING_FIX_V8.md new file mode 100644 index 0000000..8b2b9ff --- /dev/null +++ b/docs/MCP_ENV_LOADING_FIX_V8.md @@ -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. diff --git a/docs/MCP_EXTERNAL_SERVICES_IMPLEMENTATION.md b/docs/MCP_EXTERNAL_SERVICES_IMPLEMENTATION.md new file mode 100644 index 0000000..147dbd3 --- /dev/null +++ b/docs/MCP_EXTERNAL_SERVICES_IMPLEMENTATION.md @@ -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`. diff --git a/docs/MCP_LEGACY_COMMAND_MIGRATION_V7.md b/docs/MCP_LEGACY_COMMAND_MIGRATION_V7.md new file mode 100644 index 0000000..ce6101f --- /dev/null +++ b/docs/MCP_LEGACY_COMMAND_MIGRATION_V7.md @@ -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. diff --git a/docs/MCP_REAL_INTEGRATION_NOTES.md b/docs/MCP_REAL_INTEGRATION_NOTES.md new file mode 100644 index 0000000..086cda0 --- /dev/null +++ b/docs/MCP_REAL_INTEGRATION_NOTES.md @@ -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 +``` diff --git a/docs/TIM_LEGACY_ENV_MAPPING.md b/docs/TIM_LEGACY_ENV_MAPPING.md new file mode 100644 index 0000000..094f884 --- /dev/null +++ b/docs/TIM_LEGACY_ENV_MAPPING.md @@ -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. diff --git a/docs/VALIDACAO_BACKEND_IC_NOC_GRL.md b/docs/VALIDACAO_BACKEND_IC_NOC_GRL.md new file mode 100644 index 0000000..a9e4458 --- /dev/null +++ b/docs/VALIDACAO_BACKEND_IC_NOC_GRL.md @@ -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 + diff --git a/docs/VALIDACAO_TEMPLATE_ENTERPRISE.txt b/docs/VALIDACAO_TEMPLATE_ENTERPRISE.txt new file mode 100644 index 0000000..fac4bf4 --- /dev/null +++ b/docs/VALIDACAO_TEMPLATE_ENTERPRISE.txt @@ -0,0 +1,3 @@ +compileall app: OK +Arquivos de exemplos IC/NOC/GRL adicionados. +Agentes preservam implementação original comentada. diff --git a/legacy_reference/.DS_Store b/legacy_reference/.DS_Store new file mode 100644 index 0000000..2f6cecc Binary files /dev/null and b/legacy_reference/.DS_Store differ diff --git a/legacy_reference/docs/conversation_spec.md b/legacy_reference/docs/conversation_spec.md new file mode 100644 index 0000000..f2c51cb --- /dev/null +++ b/legacy_reference/docs/conversation_spec.md @@ -0,0 +1,1581 @@ +# ConversationRuntime: especificação técnica de implementação + +Especificação técnica do pacote `agent/conversation/`. Documento autocontido, escrito para guiar a implementação. Cada seção detalha um arquivo do pacote com sua API pública, tipos esperados, contratos e critérios de aceite. Quem escreve código aqui consulta este arquivo como referência viva. + +## Status de implementação + +> Este spec é o **norte** (visão completa). `state.py`, `intent_extractor.py`, `mutations.py` e `serialization.py` estão implementados; a cadeia de ação da Fase 2 (`invoice_resolver` → `action_queue` → `confirmation_manager` → `action_executor` → `finalization_machine`) está ligada no `runtime.py`, e o `response_composer` cobre `confirmation()` (templates dinâmicos) e `item_not_found()`. `script_catalog` + `scripts/*.yaml` seguem como roadmap. +> +> **Completo:** +> - `intent_extractor.py`: `IntentKind` (taxonomia de 10), `ExtractedIntent`, `IntentClassifierContext`, `IntentClassifierLLM` (Protocol), `INTENT_CLASSIFICATION_SCHEMA`, `IntentExtractor` (fast-path AFFIRMATIONS/NEGATIONS/END_PHRASES) e `CapabilityIntentClassifier` (capability `conversation_intent_classifier` + fallback inline). +> - `state.py`: enums (`ConversationStatus`/`Channel`/`ToolKind`/`ToolOutcome`), value objects (`ResolvedInvoiceItem`/`ActionStep`/`PendingConfirmation`/`ResolvedAction`), `ActionQueue` e `ConversationState` (campos do spec) + `to_prompt_context()`. +> - `mutations.py`: os 15 hooks. `serialization.py`: (de)serialização completa (enums/value objects/fila), com reset em drift de versão. +> +> **Cobertura atual da Policy/runtime:** a `PolicyEngine` trata deterministicamente: `confirmation_response` + pending → `execute_action_queue`; `action_request` com menção ambígua (2+ matches distintos) → `fallback_to_orchestrator` com `reason="ambiguous_item_match"` (runtime anexa uma diretiva curta à fala do cliente via `RuntimeResult.orchestrator_directive`, não ao system prompt); `action_request` com items → `ask_confirmation`, sem items → `item_not_found`; `conceptual_question` → RAG inline (consulta = `rag_query` refinada, senão fala literal; query vazia → `rag_no_query`); `human_or_end` → finalização. Off-context é contado no estado e roteia para o orquestrador (regra terminal de off-context segue roadmap). Demais intenções → `fallback_to_orchestrator` (com `conversation_state_summary` injetado). +> +> **Divergências duráveis** (decididas): +> - `conceptual_question` → **RAG inline** no runtime (decisão de produto), em vez de `call_informative_tool` delegando ao orquestrador. As consultas do RAG são as **queries refinadas pelo classificador** (`ExtractedIntent.rag_query`, uma por conceito, ex.: `["tamboro o que é", "tim fashion o que é"]`), buscadas em paralelo pelo backend; na ausência delas, cai na fala literal, e se nada sobra o runtime devolve `rag_no_query`. **(Revoga a decisão anterior de usar sempre a fala crua do cliente, que recuperava mal na base vetorial — ex.: "um tal de tamboro" não casava documento algum.)** +> - `IntentClassifierLLM.classify` recebe `callbacks` opcional (observabilidade do span do classificador). +> - `turn_index` é incrementado pela própria camada em `on_turn_started` (ela é dona do contador), não espelhado do `_turn_index` do core. +> - `serialization` reseta em mismatch de versão (sessões SSE curtas); migração em cadeia fica para quando o estado evoluir. +> +> **Roadmap:** Fase 2 (`invoice_resolver`/`action_queue`/`confirmation_manager`/`action_executor` + regras `ask_confirmation`/`execute_action_queue`/`call_informative_tool`/`redirect_off_context` da Policy) → `finalization_machine` + `response_composer` → `script_catalog` + `scripts/*.yaml`. + +## Sumário + +- [Visão geral](#visão-geral) +- [Princípios fundamentais](#princípios-fundamentais) +- [Governança anti-inflação da camada](#governança-anti-inflação-da-camada) +- [Estrutura de arquivos](#estrutura-de-arquivos) +- [Convenções de código](#convenções-de-código) +- [state.py](#statepy) +- [mutations.py](#mutationspy) +- [intent_extractor.py](#intent_extractorpy) +- [invoice_resolver.py](#invoice_resolverpy) +- [action_queue.py](#action_queuepy) +- [confirmation_manager.py](#confirmation_managerpy) +- [policy_engine.py](#policy_enginepy) +- [action_executor.py](#action_executorpy) +- [finalization_machine.py](#finalization_machinepy) +- [script_catalog.py](#script_catalogpy) +- [response_composer.py](#response_composerpy) +- [serialization.py](#serializationpy) +- [runtime.py](#runtimepy) +- [Testes](#testes) +- [Integração com o agente existente](#integração-com-o-agente-existente) + +## Visão geral + +O pacote `agent/conversation/` implementa a camada de domínio do diálogo. Recebe a mensagem do cliente, mantém estado canônico do turno, decide deterministicamente o próximo passo, executa ações via backends existentes e gera fala canônica quando aplicável. Convive com o `RunLoopExecutor` atual via mecanismo de fallback por intenção. + +**Ponto de entrada único.** `ConversationRuntime.try_handle(...)` em `runtime.py`. + +**Resultado esperado.** Um `RuntimeResult` que indica se o turno foi resolvido pelo runtime (`handled=True` com payload de resposta) ou se cai para o orquestrador atual (`handled=False` com `fallback_reason`). + +**Modelo de coexistência.** Dois mecanismos garantem reversibilidade. + +- `fallback_to_orchestrator` por intenção: a `PolicyEngine` cobre um conjunto fechado de intenções. Intenções não cobertas devolvem `fallback_to_orchestrator` e o `RunLoopExecutor` atual assume o turno com acesso ao estado serializado via `to_prompt_context()`. +- Variável de ambiente `CONVERSATION_RUNTIME_ENABLED` como kill switch global. Quando desligada, todo turno vai direto para o `RunLoopExecutor`, ignorando o runtime. Padrão desligada em produção até aprovação do checkpoint de pré-produção. + +## Princípios fundamentais + +Internalize antes de codar qualquer arquivo da camada. + +1. **Estado canônico como memória estruturada.** O `ConversationState` é consumível por múltiplos atores: `PolicyEngine` (para decisão determinística), `IntentExtractor` (para montar contexto de classificação), LLM via prompt (no caminho de fallback e como contexto auxiliar), telemetria, retry de reasoning leak e o próprio runtime para roteamento interno. Um campo pertence ao estado se tem pelo menos um consumidor estruturado entre esses seis. Consumidores são read-only; mutação só por hook. +2. **Função pura de decisão.** A `PolicyEngine` é função pura sobre o estado, sem efeitos colaterais, sem chamadas externas, sem logs internos. Devolve uma decisão estruturada. +3. **Tipos fechados em todos os contratos.** Enums e literais ao invés de strings mágicas e dicts soltos. Validação de runtime barata e testes triviais. +4. **Mutação só por hook nomeado.** Não há atribuição direta a campos do estado fora de `mutations.py`. Cada mutação é evento de domínio com nome estável, automaticamente observável. +5. **Compatibilidade com tools e backends atuais.** O executor delega para `backend.cancelar_vas_single`, `backend.vas_strategic` e `backend.pro_rata` via adapter. Nada do que funciona hoje precisa ser reescrito. +6. **Migração progressiva por cobertura de intenção.** Intenções cobertas pelo runtime usam o caminho novo; intenções não cobertas caem no `RunLoopExecutor` atual via `fallback_to_orchestrator`, recebendo o estado serializado como contexto. +7. **Versionamento de estado desde o dia um.** Qualquer mudança breaking no schema do estado incrementa versão e dispara migração no carregamento da sessão. +8. **Exposição textual do estado para o LLM.** O `ConversationState` expõe `to_prompt_context()` como ponto único de tradução do estado em texto natural injetável no prompt. Filtra campos de telemetria e formata o que importa para decisão do LLM. + +## Governança anti-inflação da camada + +Para impedir que a Policy e o state repliquem o problema do prompt do orquestrador (acumulação de regras e flags por incidente), aplicamos seis políticas. Estas políticas valem desde o primeiro PR. + +### 1. Toda regra cita o incidente que cobre + +Cada `if` na `PolicyEngine.decide()` deve ter comentário com identificador do incidente ou caso de uso. Regras sem motivação concreta não são mergeadas. + +### 2. Regras devem ser disjuntas + +Se duas regras casam ao mesmo tempo no mesmo estado, é sinal de problema. Teste obrigatório: para cada par de regras, escrever caso que prove que não há estado em que ambas casariam com decisões diferentes. + +### 3. Regra com mais de 3 condições é sinal de erro + +Se você se vê escrevendo `if intent.kind == "X" and state.Y and not state.Z and state.W < 3`, pare. Pelo menos uma condição deveria ter sido decidida em outro componente antes de chegar na Policy. A Policy compõe sinais, não classifica. + +### 4. Em dúvida, fallback + +A Policy não cobre tudo. Casos ambíguos, raros, ou que exigem compreensão de linguagem natural devolvem `fallback_to_orchestrator`. O LLM continua disponível com o state como contexto auxiliar. + +### 5. State é dado de domínio, não acumulador de flags + +Cada novo campo no `ConversationState` exige justificativa estrutural: quem produz, quem consome, qual conceito de domínio representa, por que não cabe em estrutura existente. Se serve apenas a uma regra da Policy, provavelmente é flag e está no lugar errado. + +### 6. Métricas de saúde da camada + +| Métrica | Como medir | Alvo em estado estacionário | +|---|---|---| +| Regras na `PolicyEngine.decide()` | grep `return ConversationDecision` | menor que 15 | +| Linhas do `policy_engine.py` | wc -l | menor que 300 | +| Campos do `ConversationState` | contar campos da dataclass | menor que 20 | +| Valores de `IntentKind` | len do Literal | menor que 15 | +| Regras adicionadas no último mês | git log | menor que 3 | +| Taxa de `fallback_to_orchestrator` | telemetria | não zero (zero indica overfitting) | + +Métricas crescendo sem justificativa estrutural levantam discussão em sync semanal. + +### 7. ADR para regras após a 10ª + +A Policy nasce com 7 regras. Crescer até 10 é evolução natural. A partir da 11ª regra, cada nova exige documento de uma página justificando incidente que motivou, por que não cabe em outro componente, qual classe inteira de casos cobre e como será removida se ficar obsoleta. + +## Estrutura de arquivos + +``` +agent/conversation/ +├── __init__.py +├── spec.md # este documento +├── state.py # ConversationState, enums, value objects +├── mutations.py # 15 hooks nomeados +├── intent_extractor.py # IntentExtractor, ExtractedIntent, IntentKind +├── invoice_resolver.py # InvoiceResolver +├── action_queue.py # ActionQueueBuilder, ACTION_ORDER +├── confirmation_manager.py # ConfirmationManager com 3 guards +├── policy_engine.py # PolicyEngine, ConversationDecision +├── action_executor.py # ActionQueueExecutor, ToolAdapter +├── finalization_machine.py # FinalizationMachine +├── script_catalog.py # ScriptCatalog +├── response_composer.py # ResponseComposer +├── serialization.py # serialize/deserialize/migrate +├── runtime.py # ConversationRuntime (fachada) +├── scripts/ # falas canônicas em YAML +│ ├── juros_e_multas.yaml +│ ├── encerramento_resolvido.yaml +│ ├── encerramento_nao_resolvido.yaml +│ ├── devolucao_antes_cancelamento.yaml +│ ├── recusa_ressarcimento_dobro.yaml +│ ├── item_not_found.yaml +│ └── README.md +└── tests/ + ├── __init__.py + ├── conftest.py + ├── test_state.py + ├── test_mutations.py + ├── test_intent_extractor.py + ├── test_invoice_resolver.py + ├── test_action_queue.py + ├── test_confirmation_manager.py + ├── test_policy_engine.py + ├── test_action_executor.py + ├── test_finalization_machine.py + ├── test_script_catalog.py + ├── test_response_composer.py + ├── test_serialization.py + └── test_runtime_end_to_end.py +``` + +## Convenções de código + +1. **Python 3.13.** Sintaxe moderna (union types com `|`, `Self`, `Literal`). +2. **Sem dependência circular.** `state.py` é base. Outros módulos importam de `state.py`. `mutations.py` importa `intent_extractor` apenas via `TYPE_CHECKING`. +3. **Tipos fechados.** Enums e `Literal`. Evitar `dict[str, Any]` em contratos públicos. +4. **Frozen quando aplicável.** Value objects (`PendingConfirmation`, `ResolvedInvoiceItem`, `ActionStep`, `ResolvedAction`) são `frozen=True`. Estado mutável apenas em `ConversationState` e `ActionQueue`. +5. **Mutação só por hook.** Componentes que precisam mutar `ConversationState` importam e chamam funções de `mutations.py`. Nunca escrevem direto no campo. Vale para `state.status` também. +6. **Sem dependência de LangChain ou LangGraph.** Permite testes unitários sem mock pesado. +7. **Sem efeito colateral em funções puras quando aplicável.** `PolicyEngine.decide`, `InvoiceResolver.resolve_items`, `ActionQueueBuilder.build` são puras. O `IntentExtractor.extract` é híbrido: caminho rápido é puro, caminho LLM faz IO via Protocol injetado. A interface Protocol garante que testes da camada não dependem de cliente concreto. +8. **Logging via callbacks recebidos por injeção.** Componentes não importam `langfuse` diretamente. +9. **Falhar rápido.** `ValueError` em entradas inválidas. Não engolir exceção silenciosamente. + +## state.py + +Define a estrutura de estado canônico do turno e todos os tipos auxiliares. + +### Constantes + +```python +SCHEMA_VERSION: int = 1 +``` + +### Enums + +```python +class ConversationStatus(str, Enum): + OPEN = "open" + AWAITING_CONFIRMATION = "awaiting_confirmation" + EXECUTING_ACTION = "executing_action" + AWAITING_WORKFLOW_INPUT = "awaiting_workflow_input" + FINALIZING = "finalizing" + CLOSED = "closed" + + +class Channel(str, Enum): + VOICE = "voice" + TEXT = "text" + + +class ToolKind(str, Enum): + INFO = "info" + EXPLANATION = "explanation" + ACTION = "action" + META = "meta" + + +class ToolOutcome(str, Enum): + SUCCESS = "success" + NO_MATCH = "no_match" + FAILURE = "failure" + PARTIAL = "partial" +``` + +### Value objects (frozen) + +```python +@dataclass(frozen=True) +class ResolvedInvoiceItem: + canonical_name: str + tool_category: Literal["cancelar_vas_avulso", "vas_estrategico", "pro_rata"] + item_type: Literal["avulso", "estrategico", "bundle", "plano"] + msisdn: str + value: Decimal | None + section: str + raw_entry: dict[str, Any] + + +@dataclass(frozen=True) +class ActionStep: + tool: Literal["cancelar_vas_avulso", "pro_rata", "vas_estrategico"] + subject: str + items: list[dict[str, Any]] + + +@dataclass(frozen=True) +class PendingConfirmation: + id: str + authorized_queue: "ActionQueue" + asked_turn_index: int + prompt_text: str + + +@dataclass(frozen=True) +class ResolvedAction: + tool: str + subject: str + tool_kind: ToolKind + outcome: ToolOutcome + result_summary: dict[str, Any] +``` + +### ActionQueue (mutável com cursor) + +```python +@dataclass +class ActionQueue: + steps: list[ActionStep] = field(default_factory=list) + cursor: int = 0 + + def current(self) -> ActionStep | None: + if self.cursor >= len(self.steps): + return None + return self.steps[self.cursor] + + def remaining_after_current(self) -> list[ActionStep]: + return self.steps[self.cursor + 1:] + + def advance(self) -> None: + self.cursor += 1 + + def is_empty(self) -> bool: + return len(self.steps) == 0 + + def is_exhausted(self) -> bool: + return self.cursor >= len(self.steps) +``` + +### ConversationState (mutável, via hooks) + +```python +@dataclass +class ConversationState: + schema_version: int = SCHEMA_VERSION + session_id: str = "" + turn_index: int = 0 + channel: Channel = Channel.VOICE + status: ConversationStatus = ConversationStatus.OPEN + pending_confirmation: PendingConfirmation | None = None + action_queue: ActionQueue = field(default_factory=ActionQueue) + last_completed_action: ResolvedAction | None = None + last_invoice_answer: Literal["SIM", "NAO"] | None = None + client_requested_end: bool = False + off_context_count: int = 0 + informative_answer_delivered: bool = False + last_finalization_reason: str | None = None + decision_trace: list[dict[str, Any]] = field(default_factory=list) + + def to_prompt_context(self) -> str: + """Representação textual filtrada do estado para injeção no prompt do LLM. + + Inclui campos que orientam decisão do LLM (status, fila, confirmação, + última ação, resposta de fatura, sinais de turno). Exclui campos + puramente de telemetria (decision_trace, last_finalization_reason). + """ + lines = [ + f"Turno atual: {self.turn_index}", + f"Canal: {self.channel.value}", + f"Status: {self.status.value}", + ] + if self.pending_confirmation is not None: + lines.append( + f"Aguardando confirmação do cliente sobre: " + f"{self.pending_confirmation.prompt_text}" + ) + if not self.action_queue.is_empty(): + current = self.action_queue.current() + remaining = self.action_queue.remaining_after_current() + if current is not None: + lines.append( + f"Próxima ação na fila: {current.tool} para {current.subject}" + ) + if remaining: + pending_subjects = ", ".join(s.subject for s in remaining) + lines.append(f"Pendentes depois: {pending_subjects}") + if self.last_completed_action is not None: + la = self.last_completed_action + lines.append( + f"Última ação executada: {la.tool} para {la.subject}, " + f"resultado: {la.outcome.value}" + ) + if self.last_invoice_answer is not None: + lines.append( + f"Resposta do cliente sobre a fatura: {self.last_invoice_answer}" + ) + if self.client_requested_end: + lines.append("Cliente pediu encerramento.") + if self.off_context_count > 0: + lines.append( + f"Cliente está há {self.off_context_count} turno(s) fora de contexto." + ) + return "\n".join(lines) +``` + +### Snapshot da fatura para anáfora plural (as-built) + +Campos novos em `ConversationState` para suportar referência anafórica plural a itens listados turnos atrás: + +```python +invoice_snapshot: tuple[ResolvedInvoiceItem, ...] = () +invoice_snapshot_source_turn: int = 0 +invoice_snapshot_source_msisdn: str = "" +``` + +**Motivação (incidentes CY0004 runs 211739/211914).** Quando o T2 do agente lista 7 itens via `invoice_explanation` e o cliente faz negação genérica plural no T5 ("mas eu não contratei nenhum deles"), o `IntentClassifierContext.conversation_history` tem janela curta e o classificador perde referência aos 5 avulsos listados em T2 (anáfora curta). O `InvoiceResolver` ignora menções não-extraídas. A `ActionQueue` sai subdimensionada (1 step de 2 itens em vez de 7) e o composer emite confirmação errada. + +**Lifecycle.** O snapshot é populado uma vez no turno em que `invoice_explanation` retorna com sucesso, via hook `on_invoice_snapshot_populated(state, snapshot, msisdn, source_turn)` em `mutations.py`. O conteúdo vem de `InvoiceResolver.build_snapshot(invoice_detail, msisdn)`, que reusa `_iter_candidates` + `_build_item` para classificar todos os itens das 4 seções suportadas (mesma lógica de `resolve`). Invalida via `on_invoice_snapshot_invalidated` quando (a) `on_msisdn_changed` dispara ou (b) `on_action_completed` registra SUCCESS de `cancelar_vas_avulso`. Re-populate é lazy: próxima `invoice_explanation` que rodar repopula. Em conversas multi-cancel longas, a nova chamada repopula naturalmente. + +**Consumo.** O snapshot é exposto ao classificador via `IntentClassifierContext.invoice_snapshot_items` (string renderizada filtrada pelo `state.msisdn` ativo, agrupada por seção, máximo 20 linhas, para cobrir família multi-msisdn). O prompt do classificador tem uma regra curta: quando a fala do cliente contém negação genérica plural (`nenhum deles`, `nada disso`, `todos esses`, `esses todos`, `nenhum desses`) e o snapshot está populado, expandir `mentioned_items` para todos os itens listados. Sem snapshot, o classificador degrada gracefully (recebe string vazia ou `nenhum`). + +**Serialização.** Bumpar `CONVERSATION_STATE_SCHEMA_VERSION` v6→v7 em `serialization.py`. Helpers `_ser_snapshot` e `_deser_snapshot` cuidam de `Decimal → str` e `raw_entry` jsonable. Sessões v6 antigas restauram com `invoice_snapshot = ()` (graceful default). Sessões SSE são curtas (3-5 turnos); deploy em janela de baixo tráfego. + +**Reversibilidade.** Comentar `_populate_invoice_snapshot_if_needed` em uma linha do runtime desliga o FIX 1 inteiro mantendo o resto. Kill-switch `CONVERSATION_RUNTIME_ENABLED=false` reverte o runtime inteiro. + +### Critérios de aceite + +- Todos os enums com valores em snake_case minúsculos. +- Todas as dataclasses frozen têm `__hash__` automático. +- `ActionQueue` tem testes para `current`, `remaining_after_current`, `advance`, `is_empty`, `is_exhausted` cobrindo fila vazia, fila com um item, fila exaurida. +- `ConversationState` tem valores default seguros para criação sem argumentos. +- `to_prompt_context()` tem testes cobrindo estado vazio, com confirmação pendente, com fila e última ação, e exclusão explícita de campos de telemetria. +- `invoice_snapshot` tem testes de default vazio, round-trip de serialização v7, restore de sessão v6 produz snapshot vazio, e invalidação via hooks (mudança de msisdn, SUCCESS de cancelamento). + +## mutations.py + +Catálogo de 15 hooks nomeados. Cada hook recebe `state` e muta in-place. Componentes externos importam e chamam. + +### Hooks de ciclo de turno + +```python +def on_turn_started(state: ConversationState, intent: "ExtractedIntent") -> None: + state.turn_index += 1 + state.client_requested_end = intent.kind == "human_or_end" +``` + +### Hooks de confirmação + +```python +def on_confirmation_requested( + state: ConversationState, + confirmation: PendingConfirmation, +) -> None: + state.pending_confirmation = confirmation + state.status = ConversationStatus.AWAITING_CONFIRMATION + + +def on_confirmation_consumed(state: ConversationState, queue: ActionQueue) -> None: + state.action_queue = queue + state.pending_confirmation = None + state.status = ConversationStatus.EXECUTING_ACTION +``` + +### Hooks de ação + +```python +def on_action_started(state: ConversationState, step: ActionStep) -> None: + # Observabilidade apenas; sem mutação. + pass + + +def on_action_completed(state: ConversationState, action: ResolvedAction) -> None: + state.last_completed_action = action + state.action_queue.advance() +``` + +### Hooks de workflow + +```python +def on_workflow_paused( + state: ConversationState, pending: PendingWorkflowResume +) -> None: + state.status = ConversationStatus.AWAITING_WORKFLOW_INPUT + state.pending_workflow = pending # dados p/ o runtime retomar no próximo turno + + +def on_workflow_resumed(state: ConversationState) -> None: + state.status = ConversationStatus.EXECUTING_ACTION + state.pending_workflow = None # pendência consumida +``` + +**Resume é dono do runtime.** Quando um step pausa (`aguardando_resposta`), o +executor guarda em `state.pending_workflow` (tool, `execution_id`, +`expected_input_key`, `allowed_values`, `question`, `normalize`) e o estado fica +`AWAITING_WORKFLOW_INPUT`. No próximo turno, `try_handle` intercepta ANTES de +classificar: interpreta a resposta (`ValueInterpreter`) e retoma o workflow pelo +MESMO trilho `conversation.action.execute` (`ActionQueueExecutor.resume_current`, +reinvocando o backend com o `execution_id`). O runtime NÃO seta mais +`agent._pending_tool_resume` — esse caminho (langchain `try_resume`) fica +exclusivo das tools pausáveis do orquestrador (ex.: `invoice_explanation`). + +### Hooks de resposta de domínio + +```python +def on_invoice_answer( + state: ConversationState, + answer: Literal["SIM", "NAO"], +) -> None: + state.last_invoice_answer = answer + + +def on_informative_answered(state: ConversationState) -> None: + state.informative_answer_delivered = True +``` + +### Hooks de off-context + +```python +def on_off_context_detected(state: ConversationState) -> None: + state.off_context_count += 1 + + +def on_in_context_message(state: ConversationState) -> None: + state.off_context_count = 0 +``` + +### Hooks de finalização + +```python +def on_finalization_emitted(state: ConversationState, reason: str) -> None: + state.last_finalization_reason = reason + state.status = ConversationStatus.CLOSED +``` + +### Hooks de transição (status puro) + +Estes três hooks existem para que mutações de `state.status` feitas pela `FinalizationMachine` passem pelo padrão de hook, mantendo observabilidade uniforme. + +```python +def on_next_action_requested(state: ConversationState) -> None: + state.status = ConversationStatus.EXECUTING_ACTION + + +def on_finalize_recommended(state: ConversationState) -> None: + state.status = ConversationStatus.FINALIZING + + +def on_turn_continued(state: ConversationState) -> None: + state.status = ConversationStatus.OPEN +``` + +### Contrato + +- Cada hook recebe `state` como primeiro parâmetro e o muta in-place. +- Cada hook emite span filho em Langfuse com nome `conversation.hook.` contendo o diff de campos antes e depois (a emissão é responsabilidade de decorator externo). +- Hooks são chamados de exatamente um lugar no código. Chamada múltipla é violação de contrato. +- Hooks nunca chamam outros hooks. Composição acontece no chamador. +- Mutação de `state.status` acontece exclusivamente via hooks. Nenhum componente fora de `mutations.py` escreve em `state.status` diretamente. + +### Critérios de aceite + +- Cada hook tem teste unitário próprio cobrindo o caso normal e ao menos uma borda. +- Hooks não chamam outros hooks. Composição acontece no chamador. +- Hooks não emitem logs nem disparam eventos diretamente. + +## intent_extractor.py + +Transforma `user_text` em `ExtractedIntent` usando estratégia híbrida: caminho rápido com regras determinísticas para casos triviais (afirmações curtas, negações curtas, frases canônicas de encerramento) e caminho LLM com schema estruturado fechado para o restante. O `IntentKind` é taxonomia fechada e estável; o LLM produz dado tipado dentro dessa taxonomia, nunca decisão final. + +### Por que LLM + +Classificação de intenção em diálogo real envolve nuance que regras determinísticas não capturam bem. Frases como "vou querer entender essa cobrança que apareceu agora" são pedido de explicação que regex de palavras-chave erra. "Quero ver se dá pra tirar esse Netflix" é ação que regex de "cancelar" não pega. O LLM resolve esses casos sem fazer o extractor virar repositório de regras. + +O caminho rápido continua existindo para latência: classificar "sim" ou "tchau" em milissegundos, sem chamada de rede. Mensagens longas ou ambíguas vão para o LLM. + +### Tipo de saída + +```python +IntentKind = Literal[ + "start", + "invoice_overview", + "broad_invoice_complaint", + "conceptual_question", + "value_question", + "action_request", + "confirmation_response", + "human_or_end", + "off_context", + "unknown", +] + + +@dataclass(frozen=True) +class ExtractedIntent: + kind: IntentKind + requested_action: bool = False + action_verbs: list[str] = field(default_factory=list) + mentioned_items: list[str] = field(default_factory=list) + affirmation: bool = False + negation: bool = False + rag_query: list[str] = field(default_factory=list) + raw_text: str = "" + source: Literal["rules", "llm", "fallback"] = "rules" +``` + +O campo `source` registra qual caminho produziu o intent. Usado em telemetria para medir cobertura de cada caminho e detectar regressões. + +O campo `rag_query` carrega as **consultas canônicas do RAG** para `conceptual_question` — uma entrada por serviço/conceito perguntado (os "pontos de dúvida"), no formato `" o que é"`. Fala simples vira lista de um item (`"tem youtube também"` → `["youtube o que é"]`); fala composta vira várias (`"tamboro e tim fashion o que é"` → `["tamboro o que é", "tim fashion o que é"]`). Vazio nas demais intenções. A `PolicyEngine` repassa essa lista como as `queries` da decisão `rag`; o backend (`buscar_informacao_rag`) busca cada uma **em paralelo**. Na ausência de `rag_query`, cai na fala literal (`user_text`). Isso **revoga** a decisão anterior de usar sempre a fala crua, que produzia recuperação ruim na base vetorial (ex.: "um tal de tamboro" não casava documento algum). Ver [policy_engine.py](#policy_enginepy). + +### Contexto de classificação + +A classificação por LLM precisa de contexto mínimo do turno para resolver ambiguidades comuns (ex.: "ok" depois de uma pergunta de confirmação é `confirmation_response`, "tudo certo" depois de explicação de fatura também é). Esse contexto é derivado do `ConversationState` pelo `IntentExtractor` antes de chamar o classificador. + +```python +@dataclass(frozen=True) +class IntentClassifierContext: + """Contexto mínimo passado ao classificador LLM. + + Derivado do ConversationState pelo IntentExtractor. Frozen: o + classificador não pode mutar. Enxuto: apenas sinais que ajudam + classificar a mensagem atual, nunca o estado inteiro. + """ + has_pending_confirmation: bool + pending_confirmation_prompt: str | None + last_tool_name: str | None + last_tool_kind: ToolKind | None + last_invoice_answer: Literal["SIM", "NAO"] | None + turn_index: int +``` + +**Princípios para o contexto não inflar.** + +- Apenas sinais derivados do `ConversationState`. Nada novo entra no contexto sem antes existir no estado canônico. +- Apenas o que o classificador usaria para classificar `IntentKind`. Histórico completo de mensagens, dados de fatura, identificação do cliente: tudo isso fica fora. +- Frozen. O classificador não pode mutar, só ler. + +### Contrato do classificador LLM + +A dependência de LLM é injetada via Protocol, não importa cliente concreto. Isso permite testar a camada sem mock pesado de LangChain. + +```python +from typing import Protocol + + +class IntentClassifierLLM(Protocol): + """Classificador de intenção via LLM com schema fechado. + + Implementações concretas usam langchain-oci, langchain-groq ou outro + cliente que suporte structured output. O Protocol é a única dependência + direta do IntentExtractor. + """ + + def classify( + self, + user_text: str, + *, + context: IntentClassifierContext, + ) -> dict[str, Any]: + """Retorna dict aderente ao INTENT_CLASSIFICATION_SCHEMA. + + Em caso de falha (timeout, parse error, schema inválido), levanta + IntentClassifierError. O IntentExtractor trata como fallback. + + O `context` é frozen. A implementação concreta usa para enriquecer + o prompt do classificador. + """ + ... + + +class IntentClassifierError(Exception): + """Erro do classificador LLM (timeout, parse, schema).""" + pass +``` + +### Schema fechado do output do LLM + +O classificador LLM deve produzir saída aderente a este schema. Implementações concretas usam structured output ou tool calling para garantir aderência. + +```python +INTENT_CLASSIFICATION_SCHEMA = { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "invoice_overview", + "broad_invoice_complaint", + "conceptual_question", + "value_question", + "action_request", + "confirmation_response", + "human_or_end", + "off_context", + "unknown", + ], + }, + "requested_action": {"type": "boolean"}, + "action_verbs": {"type": "array", "items": {"type": "string"}}, + "mentioned_items": {"type": "array", "items": {"type": "string"}}, + "affirmation": {"type": "boolean"}, + "negation": {"type": "boolean"}, + "rag_query": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["kind"], + "additionalProperties": False, +} +``` + +O classificador concreto (`CapabilityIntentClassifier` + prompt `conversation_intent_classifier`, v5) é instruído a preencher `rag_query` somente em `conceptual_question`, com uma query canônica por conceito (split de falas compostas), removendo ruído conversacional ("tem", "um tal de", "também") e resolvendo o serviço/conceito pela fala ou pelo histórico. O schema é validado *post-hoc* (o prompt instrui o JSON; `extract()` mapeia via `_str_or_list(payload.get("rag_query"))`, tolerando lista ou string única), então o campo novo propaga sem mudança de infraestrutura. + +O valor `start` do `IntentKind` é sentinela do sistema (primeiro turno antes de qualquer mensagem do cliente) e não aparece no schema do LLM. Não cabe ao LLM classificar como `start`. + +### API + +```python +class IntentExtractor: + AFFIRMATIONS = {"sim", "isso", "isso mesmo", "pode seguir", "correto", "confirmo"} + NEGATIONS = {"nao", "não", "negativo", "nem", "não quero"} + END_PHRASES = ("so isso", "só isso", "obrigado", "obrigada", "tchau") + + def __init__(self, llm_classifier: IntentClassifierLLM) -> None: ... + + def extract( + self, + user_text: str, + *, + state: ConversationState, + ) -> ExtractedIntent: ... + + def _try_rules(self, normalized: str, user_text: str) -> ExtractedIntent | None: ... + + def _build_classifier_context( + self, + state: ConversationState, + ) -> IntentClassifierContext: ... + + def _classify_with_llm( + self, + user_text: str, + context: IntentClassifierContext, + ) -> ExtractedIntent: ... +``` + +O `IntentExtractor` recebe o `ConversationState` como input de leitura. Não muta. Usa o estado apenas para construir o `IntentClassifierContext` que enriquece o classificador LLM. + +### Algoritmo de extração + +A função `extract` segue três etapas em ordem. + +```python +def extract(self, user_text: str, *, state: ConversationState) -> ExtractedIntent: + normalized = user_text.strip().lower() + + # 1. Caminho rápido: regras determinísticas para casos óbvios. + # Não depende de contexto. Resolve casos canônicos exatos. + fast_path = self._try_rules(normalized, user_text) + if fast_path is not None: + return fast_path + + # 2. Caminho LLM: classificação estruturada com contexto do turno. + context = self._build_classifier_context(state) + try: + return self._classify_with_llm(user_text, context) + except IntentClassifierError: + # 3. Fallback: intent unknown, sinaliza source="fallback" para telemetria. + return ExtractedIntent(kind="unknown", raw_text=user_text, source="fallback") + + +def _build_classifier_context( + self, + state: ConversationState, +) -> IntentClassifierContext: + return IntentClassifierContext( + has_pending_confirmation=state.pending_confirmation is not None, + pending_confirmation_prompt=( + state.pending_confirmation.prompt_text + if state.pending_confirmation is not None + else None + ), + last_tool_name=( + state.last_completed_action.tool + if state.last_completed_action is not None + else None + ), + last_tool_kind=( + state.last_completed_action.tool_kind + if state.last_completed_action is not None + else None + ), + last_invoice_answer=state.last_invoice_answer, + turn_index=state.turn_index, + ) +``` + +### Regras do caminho rápido + +A ordem importa. Encerramento ganha de afirmação e negação. + +1. **Encerramento**: se `normalized` contém alguma `END_PHRASE`, retorna `ExtractedIntent(kind="human_or_end", source="rules")`. +2. **Afirmação canônica**: se `normalized in AFFIRMATIONS` (match exato), retorna `ExtractedIntent(kind="confirmation_response", affirmation=True, source="rules")`. +3. **Negação canônica**: se `normalized in NEGATIONS` (match exato), retorna `ExtractedIntent(kind="confirmation_response", negation=True, source="rules")`. +4. **Qualquer outro caso**: retorna `None`, indicando que o caminho rápido não classificou e o LLM deve ser chamado. + +O caminho rápido **não tenta classificar `action_request`, `invoice_overview` ou `conceptual_question` por palavras-chave**. Esses casos vão direto para o LLM. Isso é mudança em relação ao desenho anterior baseado em palavras-chave. + +### Caminho LLM + +```python +def _classify_with_llm( + self, + user_text: str, + context: IntentClassifierContext, +) -> ExtractedIntent: + result = self._llm_classifier.classify(user_text, context=context) + # result é dict aderente a INTENT_CLASSIFICATION_SCHEMA, garantido pelo + # structured output do cliente concreto. Aqui apenas mapeia para o dataclass. + return ExtractedIntent( + kind=result["kind"], + requested_action=result.get("requested_action", False), + action_verbs=result.get("action_verbs", []), + mentioned_items=result.get("mentioned_items", []), + affirmation=result.get("affirmation", False), + negation=result.get("negation", False), + rag_query=_str_or_list(result.get("rag_query")), # lista; tolera string única + raw_text=user_text, + source="llm", + ) +``` + +A implementação concreta do `IntentClassifierLLM` recebe o `context` e usa para enriquecer o prompt do classificador. Casos típicos: + +- Se `context.has_pending_confirmation` e `user_text` é afirmação não-canônica (ex.: "ok", "tá bom"), classifica como `confirmation_response` com `affirmation=True`. +- Se `context.last_tool_name == "invoice_explanation"` e `user_text` é resposta breve ("certo", "entendi"), classifica como `confirmation_response`. +- Se `context.turn_index == 1` e `user_text` é cumprimento ("oi", "bom dia"), classifica como `start` ou `unknown` conforme prompt. + +### Tratamento de falha + +Quando `_classify_with_llm` levanta `IntentClassifierError`, o `IntentExtractor` devolve `ExtractedIntent(kind="unknown", source="fallback")`. A Policy trata `unknown` como `fallback_to_orchestrator`, levando o turno para o `RunLoopExecutor`. O LLM principal do orquestrador então responde com o `to_prompt_context()` injetado. + +Esse fallback é importante: a falha do classificador não derruba o turno; apenas degrada para o comportamento atual. + +### Prompt e implementação concreta do classificador + +A implementação concreta do `IntentClassifierLLM` vive fora do pacote `agent/conversation/`. Ela escolhe modelo (sugestão: pequeno e rápido como Llama via Groq ou Cohere via OCI), prompt de classificação e configuração de structured output. O pacote `agent/conversation/` apenas consome o Protocol. + +Sugestão de prompt para a implementação concreta (referência, não normativo): + +``` +Você é um classificador de intenção em diálogo de atendimento sobre fatura +de telecom. Receba o contexto do turno e a mensagem do cliente, e classifique +em uma das categorias do schema. Extraia também os itens mencionados (nomes +de serviços ou produtos) quando relevante. + +[CONTEXTO DO TURNO] +Turno: {turn_index} +Há confirmação pendente: {has_pending_confirmation} +Pergunta de confirmação anterior: {pending_confirmation_prompt or "nenhuma"} +Última ação executada: {last_tool_name or "nenhuma"} ({last_tool_kind or "n/a"}) +Última resposta de fatura: {last_invoice_answer or "nenhuma"} + +[MENSAGEM DO CLIENTE] +"{user_text}" + +Categorias: +- invoice_overview: cliente quer entender a fatura em geral +- broad_invoice_complaint: reclamação genérica sobre fatura +- conceptual_question: pergunta sobre conceito ou termo +- value_question: pergunta específica sobre valor +- action_request: pedido de ação (cancelar, contestar, ajustar) +- confirmation_response: resposta a uma pergunta de confirmação +- human_or_end: cliente quer encerrar ou falar com humano +- off_context: mensagem fora do escopo de fatura +- unknown: não foi possível classificar + +Regras de uso do contexto: +- Se há confirmação pendente e a mensagem é breve afirmativa + (ex.: "ok", "tá bom", "pode"), classifique como confirmation_response + com affirmation=true. +- Se a última ação foi invoice_explanation e a mensagem é breve afirmativa + ("certo", "entendi", "tudo claro"), classifique como confirmation_response. +- Se a mensagem é breve negativa em qualquer contexto ("não", "negativo"), + classifique como confirmation_response com negation=true. + +Devolva apenas o JSON aderente ao schema. +``` + +### Observabilidade + +A implementação concreta do classificador é responsável por emitir spans Langfuse com: + +- `intent.classifier.latency_ms` +- `intent.classifier.model` +- `intent.classifier.prompt_tokens` +- `intent.classifier.completion_tokens` +- `intent.classifier.kind` (resultado) +- `intent.classifier.error` (quando aplicável) + +O `IntentExtractor` em si emite apenas: + +- `intent.source` (rules, llm, fallback) +- `intent.kind` (resultado final) + +### Critérios de aceite + +- Testes do caminho rápido cobrem `human_or_end`, `confirmation_response` afirmativa, `confirmation_response` negativa e precedência (`"cancelar, obrigada"` resolve para `human_or_end`). O caminho rápido **não recebe `state`** e produz `source="rules"`. +- Testes do caminho LLM usam fake `IntentClassifierLLM` que retorna dict do schema. Cobrem cada um dos 8 kinds que o LLM pode produzir (excluindo `start`, que é sentinela). +- Teste de fallback: fake do classifier que levanta `IntentClassifierError` produz `ExtractedIntent(kind="unknown", source="fallback")`. +- Teste do campo `source`: regra produz `source="rules"`, LLM produz `source="llm"`, falha produz `source="fallback"`. +- O `_classify_with_llm` é mockável via `IntentClassifierLLM` Protocol. Sem mock de LangChain ou de cliente HTTP. +- Pelo menos um teste valida que mensagem longa que não casa com regras vai para o LLM e não cai em `unknown` no caminho rápido. +- Testes do `_build_classifier_context` cobrem: state vazio (todos os campos opcionais como None), state com `pending_confirmation` preenchido, state com `last_completed_action` preenchido, state com `last_invoice_answer` definido. +- Teste de leitura sem mutação: chamar `extract(text, state=state)` não muta nenhum campo do `state` (comparar `state` antes e depois deve ser igual). +- Teste de uso do contexto pelo classifier: fake `IntentClassifierLLM` registra o `context` recebido e o teste valida que os campos foram corretamente derivados do `state`. + +### Considerações de latência + +O caminho rápido resolve em microssegundos (string operations). O caminho LLM adiciona latência de rede mais inferência. Estimativas dependem do modelo escolhido na implementação concreta, mas o pacote `agent/conversation/` não toma decisão sobre isso. + +Quando a latência do LLM degrada o turno, a alternativa é a implementação concreta caching layer ou modelo menor. Não é responsabilidade do `IntentExtractor` no pacote. + +## invoice_resolver.py + +Cruza `mentioned_items` com `invoice_detail` e devolve itens canônicos. Substitui várias regras do prompt sobre msisdn, classe e seção. + +### Constantes + +```python +STRATEGIC_NAMES = { + "apple music", "deezer", "disney", "hbo", "netflix", + "youtube", "youtube premium", "paramount", "looke", +} +``` + +### API + +```python +class InvoiceResolver: + def resolve_items( + self, + mentioned_items: list[str], + invoice_detail: dict[str, Any], + ) -> list[ResolvedInvoiceItem]: ... + + def _resolve_section( + self, + mentioned_items: list[str], + entries: list[dict[str, Any]], + *, + section: str, + default_tool: str, + default_type: str, + ) -> list[ResolvedInvoiceItem]: ... + + def _classify(self, desc: str, section: str, default_type: str) -> str: ... + + @staticmethod + def _was_mentioned(desc: str, mentioned_items: list[str]) -> bool: ... + + @staticmethod + def _parse_money(value: Any) -> Decimal | None: ... + + @staticmethod + def _dedupe_exact_matches( + items: list[ResolvedInvoiceItem], + ) -> list[ResolvedInvoiceItem]: ... +``` + +### Seções tratadas + +Para cada linha (msisdn) do `invoice_detail`, exceto `Fatura Resumo`: + +| Seção do invoice_detail | default_tool | default_type | +|---|---|---| +| `SVA Detalhe Total` | `cancelar_vas_avulso` | `avulso` | +| `Itens Eventuais` | `cancelar_vas_avulso` | `avulso` | +| `Serviços Contratados de Terceiros` | `vas_estrategico` | `estrategico` | +| `Serviços Bundle Inclusos` | `vas_estrategico` | `bundle` | + +### Regra de `_classify` + +```python +def _classify(self, desc: str, section: str, default_type: str) -> str: + desc_lower = desc.lower() + if section == "Serviços Bundle Inclusos": + return "bundle" + if any(name in desc_lower for name in STRATEGIC_NAMES): + return "estrategico" + return default_type +``` + +### Regra de `tool_category` + +```python +tool_category = "vas_estrategico" if item_type in {"bundle", "estrategico"} else default_tool +``` + +### Regra de `_was_mentioned` + +Match case-insensitive bidirecional: `desc.lower() in mention.lower()` ou `mention.lower() in desc.lower()`. + +### Regra de `msisdn` + +Vem da entrada cujo `desc` bate com o item mencionado, nunca da linha titular por default. + +### Matching híbrido + ambiguidade (as-built) + +Além do `resolve_items` (determinístico, back-compat — devolve **todos** os matches), o resolver expõe `resolve(...) -> ResolutionOutcome`, usado pelo runtime, com consciência de ambiguidade e match tolerante a grafia: + +- **Matcher LLM injetado (espelha `IntentExtractor`).** `InvoiceResolver(*, matcher: ItemMatcherLLM | None = None)`. `ItemMatcherLLM` é Protocol (`match(mention, candidates, *, callbacks=None) -> list[str]`); o concreto `CapabilityItemMatcher` (módulo `item_matcher.py`, capability `conversation_item_matcher`) fica **fora** do resolver para mantê-lo livre de langchain. Sem matcher, o resolver é puro/determinístico (todos os contratos de `resolve_items` inalterados). +- **Acionado só no miss.** Para cada menção: tenta o determinístico (substring); se 0 matches **e** houver matcher, manda os `desc` candidatos ao LLM (grafia: "aia"→"Aya"). Falha do matcher é engolida → menção sem match. Happy-path não chama LLM. +- **Modelo dedicado (20b).** O `core.py` injeta no matcher um LLM fixado na variante OCI **20b** (`ConversationRuntime(..., matcher_llm=_build_item_matcher_llm(config))`) — tarefa simples, não precisa do modelo do orquestrador. Reusa o principal quando não-OCI ou já-20b; falha na construção degrada para o principal. A geração aparece no Langfuse como `conversation_item_matcher` (callbacks repassados via `resolve(..., callbacks=...)`). +- **`ItemResolution(mention, matches)`** com `is_ambiguous` = >1 match distinto por `(canonical_name, msisdn)` (nome diferente **ou** mesma desc em linhas diferentes). **`ResolutionOutcome(resolved, ambiguous, not_found)`**: `resolved` é o flat dedupado das menções não-ambíguas (alimenta o `ActionQueueBuilder`); `ambiguous` vira diretiva ao orquestrador; `not_found` são menções sem match. +- **Roteamento.** Qualquer menção ambígua → Policy regra 3.5 → `fallback_to_orchestrator` com `reason="ambiguous_item_match"`. O runtime devolve a diretiva curta em `RuntimeResult.orchestrator_directive` (via `ResponseComposer.disambiguation_directive`); o `core.py` a **anexa à fala do cliente** (`build_messages(llm_directive=...)`, marcador `###...###`) que o orquestrador vê — **não** ao system prompt e **não** ao histórico salvo (mesmo padrão do `regen_flag`). + +### Critérios de aceite + +- Fixture com `invoice_detail` real cobrindo as quatro seções. +- Teste explícito para item estratégico (ex.: Netflix). +- Teste explícito para item avulso em SVA Detalhe Total. +- Teste explícito para item bundle. +- Teste de dedupe com mesmo item em duas linhas. +- Teste de msisdn correto vindo da entrada exata. +- `resolve()`: menção ambígua (2 Ayas mesmo nome/linhas diferentes; 2 nomes distintos) cai em `ambiguous`; menção sem match em `not_found`; matcher acionado só no miss; matcher devolvendo 2+ → ambíguo; `ItemMatcherError` degrada para sem-match. + +## action_queue.py + +Constrói a `ActionQueue` a partir dos itens resolvidos, com ordem fixa. + +### Constantes + +```python +ACTION_ORDER: dict[str, int] = { + "cancelar_vas_avulso": 0, + "pro_rata": 1, + "vas_estrategico": 2, +} +``` + +### API + +```python +class ActionQueueBuilder: + def build(self, items: list[ResolvedInvoiceItem]) -> ActionQueue: ... + + def _subject_for(self, items: list[ResolvedInvoiceItem]) -> str: ... +``` + +### Algoritmo + +1. Agrupa `items` por `tool_category`. +2. Para cada grupo em ordem de `ACTION_ORDER`, cria um `ActionStep` com `tool=tool_category`, `subject=join(canonical_names)`, `items=[asdict(item) for item in group]`. +3. Devolve `ActionQueue(steps=steps, cursor=0)`. + +### Critérios de aceite + +- Teste do exemplo VOD + HBO Max: queue tem dois steps em ordem `cancelar_vas_avulso, vas_estrategico`. +- Teste de fila com só uma tool: queue tem um step. +- Teste de fila vazia: queue retornada com `is_empty() == True`. +- Teste de múltiplos itens da mesma tool: agrupados em um único step. + +## confirmation_manager.py + +Cria e consome confirmações com três guards. + +### API + +```python +class ConfirmationManager: + AFFIRMATIONS = {"sim", "isso", "isso mesmo", "pode seguir", "correto", "confirmo"} + + def create_confirmation( + self, + state: ConversationState, + action_queue: ActionQueue, + *, + prompt_text: str, + turn_index: int, + ) -> PendingConfirmation: ... + + def consume_if_confirmed( + self, + state: ConversationState, + user_text: str, + *, + current_turn_index: int, + ) -> ActionQueue | None: ... +``` + +### Três guards de `consume_if_confirmed` + +A ordem de checagem importa. Se qualquer guard falha, devolve `None`. + +1. **Guard de status.** `state.status` deve ser `AWAITING_CONFIRMATION`. Caso contrário, há outro evento em andamento. +2. **Guard de turno.** `current_turn_index` deve ser exatamente `state.pending_confirmation.asked_turn_index + 1`. Garante que o "sim" é da resposta direta à pergunta. +3. **Guard de afirmação.** `user_text.strip().lower()` deve estar em `AFFIRMATIONS`. Negações e textos ambíguos não consomem. + +### Mutação via hooks + +`create_confirmation` chama `on_confirmation_requested(state, confirmation)`. + +`consume_if_confirmed`, quando todos os guards passam, chama `on_confirmation_consumed(state, queue)`. + +### Critérios de aceite + +- Teste "sim válido no turno correto" devolve a queue autorizada. +- Teste "sim no turno errado" devolve `None`. +- Teste "sim com status OPEN" devolve `None`. +- Teste "negação no turno correto" devolve `None` e não muta o estado. +- Teste "afirmação parcial não-canônica" (ex.: "claro") devolve `None`. + +## policy_engine.py + +Função pura que decide o próximo evento. + +### Tipo de saída + +```python +@dataclass(frozen=True) +class ConversationDecision: + kind: Literal[ + "answer", + "ask_confirmation", + "execute_action_queue", + "call_informative_tool", + "redirect_off_context", + "finalize", + "fallback_to_orchestrator", + ] + payload: dict[str, Any] +``` + +### API + +```python +class PolicyEngine: + def decide( + self, + state: ConversationState, + *, + intent: ExtractedIntent, + resolved_items: list[ResolvedInvoiceItem], + action_queue: ActionQueue, + ) -> ConversationDecision: ... +``` + +### Sete regras em ordem de avaliação + +A primeira regra que casa devolve a decisão. Sem fallthrough. Cada regra deve ter comentário citando o caso de uso ou incidente que cobre. + +1. **Confirmação consumida.** `state.pending_confirmation is not None and intent.kind == "confirmation_response" and intent.affirmation` → `execute_action_queue` com `payload={"queue": state.action_queue}`. +2. **Encerramento solicitado.** `intent.kind == "human_or_end"` → `finalize` com `payload={"status": "nao_resolvido"}`. +3. **Pergunta conceitual.** `intent.kind == "conceptual_question"` → `call_informative_tool` com `payload={"tool": "buscar_informacao"}`. +4. **Visão geral da fatura.** `intent.kind == "invoice_overview"` → `call_informative_tool` com `payload={"tool": "invoice_explanation"}`. +5. **Ação com items resolvidos.** `intent.requested_action and resolved_items` → `ask_confirmation` com `payload={"action_queue": action_queue}`. +6. **Ação sem items.** `intent.requested_action and not resolved_items` → `answer` com `payload={"message_key": "item_not_found"}`. +7. **Off-context terminal.** `state.off_context_count >= 3` → `finalize` com `payload={"status": "outros_assuntos"}`. + +Default: `fallback_to_orchestrator` com `payload={"reason": "uncovered_intent"}`. + +### Notas de implementação (as-built) + +A implementação atual usa um `ConversationDecision(action=..., ...)` enxuto (sem `payload` genérico) e diverge da nomenclatura aspiracional acima em alguns pontos: + +- **Regra 3 (pergunta conceitual).** Decisão `action="rag"` (RAG inline, não `call_informative_tool`). As consultas são `intent.rag_query` (lista, uma por conceito) quando presentes, senão a fala literal: `refined = tuple(q.strip() for q in intent.rag_query if q.strip()); queries = refined or ((user_text.strip(),) if user_text.strip() else ())`. As N queries são buscadas em paralelo pelo backend. Se nada sobra, `queries` vai vazia e o runtime (`_handle_rag`) devolve `fallback_to_orchestrator` com motivo `rag_no_query` — o RAG nunca é chamado com lista vazia. +- **Regra 3.5 (ação com alvo ambíguo).** Novo: `decide(..., has_ambiguity: bool)`. Quando `kind == action_request` e `has_ambiguity` (o runtime pré-computa via `InvoiceResolver.resolve(...).ambiguous` — uma menção casou com 2+ itens distintos), decisão `action="fallback_to_orchestrator"`, `reason="ambiguous_item_match"`. Avaliada **antes** da regra 5 (precedência: basta uma menção ambígua). O runtime devolve a diretiva curta em `RuntimeResult.orchestrator_directive` (candidatos = nome + final da linha, via `ResponseComposer.disambiguation_directive`); o `core.py` a anexa à fala do cliente (`RunLoopExecutor.execute(llm_directive=...)` → `build_messages`), entre marcadores `###...###`, **sem** tocar o system prompt nem o histórico salvo (mesmo padrão do `regen_flag`). O orquestrador pergunta a qual item o cliente se refere. *Governança §7: a regra refina a família `action_request` (incidente "dois-ayas"), não introduz conceito de domínio novo; mantém-se abaixo do alvo de <15 `return ConversationDecision`.* +- **Regra 5 (ação com items).** Decisão `action="ask_confirmation"` carregando a `action_queue` pré-computada (resolver + builder rodam no runtime antes do `decide`). +- **Regra 6 (ação sem items).** Implementada como decisão `action="item_not_found"` (não `answer`/`message_key`). O runtime compõe a fala via `ResponseComposer.item_not_found(intent.mentioned_items)` e devolve `handled=True`; o status segue `OPEN` para o cliente reformular. Espelha o comportamento canônico do prompt do orquestrador ("diga ao cliente que não encontrou esse serviço na fatura"). +- **Regra 1.5b (negação órfã).** Novo, espelho da Regra 1.5 (afirmação órfã): `kind == confirmation_response and intent.negation and pending_confirmation is None` → `fallback_to_orchestrator`, `reason="negation_without_pending"`. Cobre o caso em que o cliente nega sem haver pergunta de confirmação ativa. O orquestrador interpreta livremente sem directive estruturada. *Governança §7: regra simétrica à 1.5, sem novo conceito de domínio.* +- **Regra 1.6 (negação com pending sem verbo de ação).** Novo (incidentes CY0006 runs 230207/230336/230436). `kind == confirmation_response and intent.negation and pending_confirmation is not None and not intent.action_verbs` → `fallback_to_orchestrator`, `reason="confirmation_response_negation_pending"`. O runtime monta uma directive estruturada via `ResponseComposer.negation_with_pending_directive(pending)` e anexa à `HumanMessage` pelo mesmo mecanismo da Regra 3.5 (`disambiguation_directive`). A directive lista os itens da fila pendente e enumera dois caminhos para o orquestrador: (A) caminho de ação (só se cliente trouxer verbo de ação, atualmente TODO, fica para sprint futura), (B) caminho informativo default (explicar origens possíveis da cobrança como teste gratuito, engano em canal digital, aceite incidental, com pergunta aberta sem oferta proativa de cancelamento). O guard `not intent.action_verbs` deixa fallthrough natural para Regra 5 quando o cliente diz "não, cancela esses" (verbo presente). *Governança §7: regra refina a família `confirmation_response` para um sub-caso específico (negação informativa) que viola anti-proatividade quando não roteada, sem introduzir conceito de domínio novo. Precedência: depois de Regras 1 e 1.5, antes da Regra 5.* + +### Critérios de aceite + +- Um teste por regra (sete testes mínimo). +- Teste de precedência: regra 1 ganha de regra 5 quando ambas casam. +- Teste de precedência: regra 2 ganha de regra 6 quando ambas casam. +- Teste da regra 3.5: `has_ambiguity=True` em `action_request` → `ambiguous_item_match`, com precedência sobre `ask_confirmation`. +- Teste da regra 1.5b: negação órfã (`negation=True` sem `pending_confirmation`) devolve `negation_without_pending`. +- Teste da regra 1.6 default: `confirmation_response + negation + pending sem action_verbs` devolve `confirmation_response_negation_pending` com directive composta pelo composer. +- Teste da regra 1.6 fallthrough: `confirmation_response + negation + pending COM action_verbs` cai para Regra 5 (`ask_confirmation` com nova fila). +- Teste de precedência: Regra 1 (`affirmation`) ganha de Regra 1.6 (`negation`) quando ambas têm pending. +- Teste do default: state vazio com intent.kind="unknown" devolve `fallback_to_orchestrator`. +- Teste de pureza: chamar `decide` duas vezes com mesmo input devolve decisões iguais. +- Cada regra tem comentário citando o caso de uso ou incidente. + +## action_executor.py + +Executa o próximo step da fila usando os backends existentes. + +### API + +```python +class ToolAdapter: + def __init__(self, backend) -> None: ... + def invoke( + self, + step: ActionStep, + *, + remaining: list[dict[str, Any]], + ) -> dict[str, Any]: ... + + +class ActionQueueExecutor: + def __init__(self, tool_adapter: ToolAdapter) -> None: ... + def execute_next(self, state: ConversationState) -> dict[str, Any]: ... +``` + +### Mapeamento de tool para backend (ToolAdapter.invoke) + +```python +if step.tool == "cancelar_vas_avulso": + return self._backend.cancelar_vas_single( + items=self._to_cancel_items(step.items), + workflow_input={"remaining": remaining}, + ) +if step.tool == "vas_estrategico": + return self._backend.vas_strategic( + items=self._to_strategic_items(step.items), + workflow_input={"remaining": remaining}, + ) +if step.tool == "pro_rata": + return self._backend.pro_rata( + **self._to_pro_rata_args(step.items, remaining), + ) +raise ValueError(f"Unsupported action tool: {step.tool}") +``` + +### Fluxo de `execute_next` + +1. Obter `step = state.action_queue.current()`. Se `None`, devolve `{"success": True, "queue_empty": True}`. +2. Chamar `on_action_started(state, step)`. +3. Calcular `remaining = [{"tool": s.tool, "subject": s.subject} for s in state.action_queue.remaining_after_current()]`. +4. Invocar `result = self._tool_adapter.invoke(step, remaining=remaining)`. +5. Classificar `outcome` a partir do `result` (ver tabela abaixo). +6. Construir `ResolvedAction` e chamar `on_action_completed(state, resolved_action)`. +7. Devolver `result`. + +### Classificação de `ToolOutcome` + +| Condição no result | Outcome | +|---|---| +| `result.get("success") == True` e sem erros | `SUCCESS` | +| `result.get("not_found") == True` | `NO_MATCH` | +| `result.get("error") is not None` | `FAILURE` | +| Sucesso parcial em items múltiplos | `PARTIAL` | + +### Critérios de aceite + +- Teste por tool com mock de backend. +- Teste de fila vazia devolve `queue_empty=True`. +- Teste de `remaining` corretamente derivado da fila (não do histórico). +- Teste de avance do cursor após sucesso (via `on_action_completed`). +- Teste de não-avance em falha (cursor permanece para retry futuro). + +## finalization_machine.py + +Decide o que fazer depois do retorno de uma ação. Usa exclusivamente hooks para mutar `state.status`. + +### API + +```python +class FinalizationMachine: + def after_tool_result( + self, + state: ConversationState, + tool_result: dict[str, Any], + ) -> Literal["ask_next_action", "wait_workflow_input", "finalize", "continue"]: ... + + def build_finalize_payload(self, status: str, summary: str) -> dict[str, Any]: ... + + def mark_closed(self, state: ConversationState) -> None: ... +``` + +### Quatro caminhos de `after_tool_result` + +A ordem importa. Primeira condição que casa devolve. + +```python +def after_tool_result(self, state, tool_result): + if state.action_queue.current() is not None: + on_next_action_requested(state) + return "ask_next_action" + + if tool_result.get("aguardando_resposta") is True: + on_workflow_paused(state) + return "wait_workflow_input" + + if tool_result.get("recomenda_finalizacao") is True: + on_finalize_recommended(state) + return "finalize" + + on_turn_continued(state) + return "continue" +``` + +### Critérios de aceite + +- Teste por caminho (quatro testes). +- Teste de precedência: fila não-vazia ganha de `aguardando_resposta`. +- Teste de `build_finalize_payload` com status canônicos (`resolvido`, `nao_resolvido`, `outros_assuntos`). +- Teste de que `state.status` é mutado apenas via hooks (grep "state.status =" no arquivo retorna zero). + +## script_catalog.py + +Carrega scripts em YAML na inicialização e oferece seleção por trigger. + +### API + +```python +@dataclass(frozen=True) +class ScriptParameter: + name: str + format: Literal["currency_brl", "date_long", "protocol_digits", "msisdn_digits", "raw"] + + +@dataclass(frozen=True) +class Script: + id: str + trigger: str + text: str + parameters: list[ScriptParameter] + channel: dict[Channel, str] + + +class ScriptCatalog: + def __init__(self, scripts_dir: Path) -> None: ... + def select_by_trigger(self, trigger: str) -> Script | None: ... + def render( + self, + script_id: str, + params: dict[str, Any] | None = None, + channel: Channel = Channel.VOICE, + ) -> str: ... +``` + +### Formato YAML esperado + +```yaml +id: +trigger: +text: | + +parameters: + - name: + format: currency_brl | date_long | protocol_digits | msisdn_digits | raw +channel: + voice: same | + text: same | +``` + +### Carregamento + +1. Na construção, varre `scripts_dir` por arquivos `.yaml`. +2. Valida cada arquivo contra o schema. +3. Indexa por `id` e por `trigger`. Triggers duplicados causam `ValueError`. +4. Não recarrega em runtime; recarga é responsabilidade do operador via restart. + +### Render + +1. Resolve o script por `script_id` ou levanta `KeyError`. +2. Substitui placeholders no texto usando `str.format(**rendered_params)`. +3. `rendered_params` é construído chamando o channel adapter para cada parâmetro conforme `format`. +4. Se canal é `voice` e o script define alternativa de voz não-`same`, usa a alternativa. + +### Critérios de aceite + +- Teste de carga com diretório válido. +- Teste de erro com YAML malformado. +- Teste de erro com trigger duplicado. +- Teste de render com parâmetros simples. +- Teste de render com parâmetro `currency_brl` em ambos os canais. +- Teste de erro com parâmetro citado em `text` mas ausente em `parameters`. + +## response_composer.py + +Compõe falas dinâmicas e proxia falas estáticas do catálogo. + +### API + +```python +class ResponseComposer: + def __init__(self, catalog: ScriptCatalog, channel_adapter) -> None: ... + + def confirmation(self, action_queue: ActionQueue) -> str: ... + def next_action_transition(self, next_subject: str) -> str: ... + def item_not_found(self, service_name: str) -> str: ... + def from_catalog( + self, + script_id: str, + params: dict[str, Any] | None = None, + ) -> str: ... +``` + +### Composição de `confirmation` (as-built) + +> **Nota as-built.** A assinatura implementada é `ResponseComposer(*, channel: Channel = Channel.VOICE)` (o `ScriptCatalog`/`channel_adapter` da API aspiracional ainda não existem). Apenas `confirmation()` e `item_not_found(mentioned_items: list[str])` estão implementados; `next_action_transition`/`from_catalog` entram com o `ScriptCatalog`. + +`confirmation()` decide o **molde da frase** pelo conjunto de categorias presentes na fila (uma categoria → template de intenção única; duas ou mais → template de múltiplas intenções), adaptando dinamicamente **singular/plural**, **números de linha iguais ou diferentes** (agrupando itens por msisdn) e **múltiplas intenções**. Duas categorias em escopo (pro rata fora por ora): + +- **avulso** (`cancelar_vas_avulso`): verbo SEMPRE "cancelar", item-a-item com valor e número próprio. Item: `"{service}, no valor de {R$ valor}, vinculado ao número final {final}"` (juntos por `", "`). + - Intenção única: `"Vamos seguir com o cancelamento {do serviço|dos serviços} {corpo} e providenciar a retirada dos valores da fatura. Você confirma?"` +- **estratégico/bundle** (`vas_estrategico`): verbo SEMPRE "falar sobre", SEM valor, agrupado por número. 1º grupo: `"{nomes} {vinculado|vinculados} ao número final {final}"`; grupos seguintes: `", e também {do|dos serviços} {nomes} ... ao número final {final}"`. + - Intenção única: `"Entendi que você deseja falar sobre {o serviço|os serviços} {corpo}. Correto?"` +- **pro rata** (fora de escopo): fragmento estático mínimo, só para não quebrar filas legadas. + +Múltiplas intenções (2+ categorias, ordem fixa `avulso → estratégico → pro rata`): `"Entendi que você deseja " + fragmento_lead + ", " + fragmentos_seguintes + ". Correto?"`. O avulso (sempre 1º por `ACTION_ORDER`) lidera sem "e também"; os demais trazem "e também" embutido. **O verbo é fixo por bloco e nunca cruza** (avulso "cancelar"; estratégico "falar sobre", mesmo que o cliente tenha pedido cancelamento). + +`{service}`/`{nomes}` vêm dos `items` da step (`service` no avulso, `name` no estratégico) — necessário para agrupar por número. Moeda fica **canônica** (`R$ 1.234,50`; a vocalização para voz ocorre no boundary SSE); os 4 dígitos finais são vocalizados aqui no canal `VOICE` (legado preservado). Fila vazia → `""`; tool desconhecida → `ValueError`. + +### Composição de `item_not_found` + +`item_not_found(mentioned_items)` — fala determinística para `action_request` cujo resolver não casou item na fatura (Policy regra 6). Com nomes: `"Não localizei {o serviço|os serviços} {nomes} na sua fatura. Pode confirmar o nome do serviço, por favor?"`; lista vazia (cliente disse "cancela isso" sem nomear): fala genérica equivalente. + +### Regra de uso + +ScriptCatalog para falas estáticas regulatórias (palavra por palavra como definido). ResponseComposer para composições dinâmicas baseadas em fila e items. Ambos passam pelo channel adapter no fim. + +### Critérios de aceite + +- Teste do exemplo VOD + HBO Max: gera frase com dois trechos juntados. +- Teste de step único. +- Teste de step com múltiplos items na mesma tool. +- Teste de `from_catalog` proxia para o catálogo corretamente. +- Teste de `item_not_found` produz mensagem com nome do serviço. + +## serialization.py + +Serialização JSON-safe e versionamento do estado. + +### API + +```python +def serialize_state(state: ConversationState) -> dict[str, Any]: ... + +def deserialize_state(data: dict[str, Any]) -> ConversationState: ... + +def migrate_state(persisted: dict[str, Any]) -> ConversationState: ... +``` + +### Regras de serialização + +- Enums viram strings (`.value`). +- `Decimal` vira string. +- `None` em campos opcionais é omitido da saída (não aparece a chave). +- `ActionQueue` serializa `steps` e `cursor`. +- `PendingConfirmation.authorized_queue` é serializada de forma aninhada. + +### Versionamento + +- Se `persisted.get("schema_version") == SCHEMA_VERSION`, deserialização direta. +- Se for versão anterior conhecida, aplica migrações em cadeia. +- Se versão desconhecida ou ausente, devolve estado novo com `session_id` preservado. + +### Critérios de aceite + +- Round-trip: state → serialize → deserialize → state, igualdade preservada. +- Round-trip com `ActionQueue` no meio da execução (cursor > 0). +- Round-trip com `PendingConfirmation` preenchida. +- Migração de dict V0 sintético para V1 produz estado válido. +- Versão desconhecida produz estado novo sem exceção. + +## runtime.py + +Fachada que orquestra todos os componentes. + +### API + +```python +@dataclass +class RuntimeResult: + handled: bool + payload: dict[str, Any] | None = None + fallback_reason: str | None = None + + @classmethod + def handled_text(cls, text: str) -> "RuntimeResult": + return cls(handled=True, payload={"content": text}) + + @classmethod + def fallback(cls, reason: str) -> "RuntimeResult": + return cls(handled=False, fallback_reason=reason) + + +class ConversationRuntime: + def __init__( + self, + *, + intent_extractor: IntentExtractor, + invoice_resolver: InvoiceResolver, + queue_builder: ActionQueueBuilder, + confirmation_manager: ConfirmationManager, + policy_engine: PolicyEngine, + action_executor: ActionQueueExecutor, + finalization_machine: FinalizationMachine, + response_composer: ResponseComposer, + state_store: "ConversationStateStore", + ) -> None: ... + + def try_handle( + self, + *, + user_text: str, + context: dict[str, Any], + history: list, + callbacks: list, + ) -> RuntimeResult: ... + + def export_state(self) -> dict[str, Any]: ... + + def restore_state(self, snapshot: dict[str, Any]) -> None: ... +``` + +### Fluxo de `try_handle` + +``` +1. state = state_store.load(context) +2. intent = intent_extractor.extract(user_text, state=state) + # IntentExtractor lê o state (read-only) para montar IntentClassifierContext. + # Lê ANTES de on_turn_started: turn_index, pending_confirmation, + # last_completed_action e last_invoice_answer continuam refletindo o + # turno anterior, que é exatamente o que o classificador precisa. +3. on_turn_started(state, intent) + +4. confirmed_queue = confirmation_manager.consume_if_confirmed( + state, user_text, current_turn_index=context["turn_index"]) + if confirmed_queue: + result = action_executor.execute_next(state) + transition = finalization_machine.after_tool_result(state, result) + return _handle_post_action(state, result, transition) + +5. invoice_detail = context.get("invoice_detail") or {} + resolved_items = invoice_resolver.resolve_items(intent.mentioned_items, invoice_detail) + action_queue = queue_builder.build(resolved_items) if resolved_items else ActionQueue() + +6. decision = policy_engine.decide( + state, intent=intent, resolved_items=resolved_items, action_queue=action_queue) + state.decision_trace.append(_trace(intent, resolved_items, decision)) + +7. roteamento por decision.action (as-built): + - "ask_confirmation": gera texto via composer, cria pending_confirmation, retorna handled_text + - "execute_action_queue": executa next e processa transition + - "rag": chama `buscar_informacao(queries)`; query vazia ou sem resposta → fallback (`rag_no_query`/`rag_no_answer`) + - "item_not_found": compõe `ResponseComposer.item_not_found(intent.mentioned_items)`, retorna handled_text (status segue OPEN) + - "finalize": on_finalization_emitted, retorna handled_text com script de encerramento + - "fallback_to_orchestrator": injeta to_prompt_context no contexto e retorna fallback + + (As ações `call_informative_tool`/`redirect_off_context`/`answer` da API aspiracional ainda não foram ligadas; quando entrarem, somam-se aqui.) + +8. state_store.save(context, state) +``` + +**Ordem importante.** O `extract` no passo 2 é chamado **antes** do `on_turn_started` no passo 3 para que o classificador veja o estado do turno anterior intacto. Mudar essa ordem fragiliza casos de confirmação ("ok" depois de pergunta), porque o contexto perderia a referência exata. + +### Injeção do contexto no fallback + +No caminho `fallback_to_orchestrator`, o runtime injeta o resumo textual do estado no contexto antes de salvar: + +```python +if decision.kind == "fallback_to_orchestrator": + context["conversation_state_summary"] = state.to_prompt_context() + self._state_store.save(context, state) + return RuntimeResult.fallback(reason=decision.payload["reason"]) +``` + +O `RunLoopExecutor` atual passa a receber esse resumo e pode incluí-lo no prompt como seção de contexto auxiliar. + +### Critérios de aceite + +- Cinco cenários end-to-end cobertos em `test_runtime_end_to_end.py`: + 1. Cliente pede cancelar dois VAS (VOD e HBO Max), confirma, sistema executa em ordem. + 2. Cliente questiona invoice_explanation, responde SIM, sistema finaliza como resolvido. + 3. Cliente diz "não, só isso obrigada", sistema finaliza como nao_resolvido. + 4. Cliente sai do contexto três turnos, sistema finaliza como outros_assuntos. + 5. Cliente solicita ressarcimento em dobro, sistema responde com script canônico e aguarda confirmação. +- Teste de intent não coberta devolve `fallback_to_orchestrator` e injeta `conversation_state_summary` no contexto. +- Teste de export_state / restore_state preserva o estado completo entre turnos. + +## Testes + +### Estrutura + +``` +agent/conversation/tests/ +├── conftest.py # fixtures comuns +├── test_state.py +├── test_mutations.py +├── test_intent_extractor.py +├── test_invoice_resolver.py +├── test_action_queue.py +├── test_confirmation_manager.py +├── test_policy_engine.py +├── test_action_executor.py +├── test_finalization_machine.py +├── test_script_catalog.py +├── test_response_composer.py +├── test_serialization.py +└── test_runtime_end_to_end.py +``` + +### Convenções + +- Pytest como framework. +- Sem mocks de LangChain ou LangGraph nos testes da camada. +- Fixtures de `invoice_detail` em JSON dentro de `tests/fixtures/`. +- Cobertura mínima por arquivo: 90%. +- Nomes de teste no formato `test__` (ex.: `test_decide_finalize_when_client_requests_end`). + +### Cenários obrigatórios + +Os cinco cenários end-to-end são parte da definição de pronto do `runtime.py`. Sem eles, o runtime não vai para o bloco 2. + +## Integração com o agente existente + +A integração com `LangChainWorkflowAgent.run` acontece em tarefas separadas do bloco 2 do plano de execução. O pacote `agent/conversation/` por si não toca nada fora dele. Quando vier a integração, o ponto de chamada será: + +```python +# agent/infra/langchain/agent/core.py (LangChainWorkflowAgent.run) + +resumed = self._resume_handler.try_resume(...) +if resumed and resumed.early_payload is not None: + return self._apply_output_guardrail(resumed.early_payload, ...) + +runtime_result = self._conversation_runtime.try_handle( + user_text=user_text, + context=self.get_context_snapshot(), + history=self._conversation_history, + callbacks=callbacks, +) + +if runtime_result.handled: + return self._apply_output_guardrail(runtime_result.payload, ...) + +# Fallback: o context já tem conversation_state_summary injetado +return self._run_loop.execute(...) +``` + +Esse ponto de integração fica atrás do kill switch `CONVERSATION_RUNTIME_ENABLED`, padrão desligado em produção. A persistência do estado da camada entra em `export_session_state` e `restore_session_state` do `core.py` como campo `conversation_runtime`. + +### Critérios para integração + +- Sem alteração de contrato externo (endpoints, payloads, eventos emitidos). +- Reversibilidade por flag. +- Replay sobre amostra de traces reais antes da ativação efetiva. +- O `RunLoopExecutor` lê `context["conversation_state_summary"]` quando presente e injeta no prompt como seção de contexto auxiliar. + +Detalhes operacionais dessa etapa de integração não pertencem a este spec. Este documento cobre apenas o que vive dentro do pacote `agent/conversation/`. diff --git a/legacy_reference/workflows/.DS_Store b/legacy_reference/workflows/.DS_Store new file mode 100644 index 0000000..e8203fb Binary files /dev/null and b/legacy_reference/workflows/.DS_Store differ diff --git a/legacy_reference/workflows/__init__.py b/legacy_reference/workflows/__init__.py new file mode 100644 index 0000000..b325093 --- /dev/null +++ b/legacy_reference/workflows/__init__.py @@ -0,0 +1,3 @@ +from agente_contas_tim.workflows.service import WorkflowService + +__all__ = ["WorkflowService"] diff --git a/legacy_reference/workflows/actions/__init__.py b/legacy_reference/workflows/actions/__init__.py new file mode 100644 index 0000000..c75dd45 --- /dev/null +++ b/legacy_reference/workflows/actions/__init__.py @@ -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", +] diff --git a/legacy_reference/workflows/actions/common/__init__.py b/legacy_reference/workflows/actions/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/legacy_reference/workflows/actions/common/actions.py b/legacy_reference/workflows/actions/common/actions.py new file mode 100644 index 0000000..fbbf22f --- /dev/null +++ b/legacy_reference/workflows/actions/common/actions.py @@ -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', +] diff --git a/legacy_reference/workflows/actions/common/helpers.py b/legacy_reference/workflows/actions/common/helpers.py new file mode 100644 index 0000000..4381c96 --- /dev/null +++ b/legacy_reference/workflows/actions/common/helpers.py @@ -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.001–VAA.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', +] diff --git a/legacy_reference/workflows/actions/contestacao/__init__.py b/legacy_reference/workflows/actions/contestacao/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/legacy_reference/workflows/actions/contestacao/actions.py b/legacy_reference/workflows/actions/contestacao/actions.py new file mode 100644 index 0000000..ea5e7f4 --- /dev/null +++ b/legacy_reference/workflows/actions/contestacao/actions.py @@ -0,0 +1,2016 @@ +from __future__ import annotations + +import functools +import json +import logging +import os +import re +import unicodedata as ud + +from contextlib import nullcontext +from datetime import datetime +from decimal import Decimal +from typing import Any + +from agente_contas_tim.constants.ic_tags_enum import CVNTag, RCTTag, TMDTag, VAATag, VEBTag +from agente_contas_tim.integrations.rct_policy import RCTOperation, rct_tags_for_attempt +from agente_contas_tim.observability import ( + get_session_id, + langfuse_context_has_active_span, + mask_msisdn, +) +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 ( + _emit_ic, + _emit_vaa, + _first_text_from_params_or_state, + _first_value_from_params_or_state, + _normalize_bool, + _normalize_number_text, + _result_failed_or_missing_data, + _to_dict, +) +from agente_contas_tim.workflows.actions.contestacao.helpers import ( + _collect_invoice_status_texts, + _has_open_bill_from_payload, + _extrair_dia_vencimento, + _resolve_billing_cutoff_reference, + _resolve_next_cutoff_from_cut_date, + _first_invoice_date_from_payload_by_id, + _first_invoice_status_from_payload, + _find_invoice_item_by_id, + _invoice_status_texts_from_payload, + _is_plano_familia_dependent_access, + _resolve_contestation_due_date, + _resolve_refund_and_resolution_decision, + _resolve_contestation_request_rules, + _map_invoice_status_to_state, +) +from agente_contas_tim.workflows.actions.pro_rata.helpers import ( + _extract_invoice_payload_from_state_or_params, +) + +logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions") + +_CONTESTATION_API_ERROR_KEYS = ( + "contestation_error_description", +) + + +@functools.lru_cache(maxsize=1) +def _import_langfuse_get_client() -> Any: + from langfuse import get_client + return get_client + + +def _has_langfuse_credentials() -> bool: + return bool( + os.getenv("LANGFUSE_PUBLIC_KEY", "").strip() + and os.getenv("LANGFUSE_SECRET_KEY", "").strip() + ) + + +def _langfuse_client() -> Any | None: + if not _has_langfuse_credentials(): + return None + if not langfuse_context_has_active_span(): + return None + try: + client = _import_langfuse_get_client()() + except Exception: + logger.debug("langfuse.sms_get_client_failed", exc_info=True) + return None + try: + if not client.get_current_trace_id(): + return None + except Exception: + logger.debug("langfuse.sms_current_trace_lookup_failed", exc_info=True) + return None + return client + + +def _start_sms_observation( + *, + msisdn: str, + has_barcode: bool, + long_url: str, + notify_url: str, +) -> Any: + client = _langfuse_client() + if client is None: + return nullcontext(None) + try: + return client.start_as_current_observation( + name="workflow.sms.enviar", + as_type="span", + input={ + "msisdn": mask_msisdn(msisdn), + "has_barcode": has_barcode, + "long_url_configured": bool(long_url), + "notify_url_configured": bool(notify_url), + }, + metadata={ + "component": "workflow_action", + "action": "enviar_sms", + "channel": "sms", + }, + ) + except Exception: + logger.debug("langfuse.sms_start_observation_failed", exc_info=True) + return nullcontext(None) + + +def _update_sms_observation( + observation: Any | None, + *, + output: dict[str, Any], + level: str | None = None, + status_message: str | None = None, +) -> None: + if observation is None: + return + try: + kwargs: dict[str, Any] = {"output": output} + if level is not None: + kwargs["level"] = level + if status_message is not None: + kwargs["status_message"] = status_message + observation.update(**kwargs) + except Exception: + logger.debug("langfuse.sms_update_observation_failed", exc_info=True) + + +def _is_recoverable_contestation_api_error(result: Any) -> bool: + metadata = getattr(result, "metadata", {}) + if not isinstance(metadata, dict): + return False + return isinstance(metadata.get("error_body"), dict) + + +def _build_contestation_api_error_output( + result: Any, + *, + protocolo_id: str, + items: list[dict[str, Any]], +) -> dict[str, Any]: + metadata = getattr(result, "metadata", {}) + if not isinstance(metadata, dict): + metadata = {} + error_body = metadata.get("error_body") + if not isinstance(error_body, dict): + error_body = {} + provider = error_body.get("provider", {}) + if not isinstance(provider, dict): + provider = {} + + description = str(error_body.get("description") or "").strip() + provider_message = str(provider.get("errorMessage") or "").strip() + error_message = description or provider_message or str( + getattr(result, "error", "") or "Falha ao abrir contestação" + ).strip() + return { + "manual_conta_certa_indicator": False, + "contestation_id": "", + "sr": protocolo_id, + "protocolo_id": protocolo_id, + "protocol_number": protocolo_id, + "barcode": "", + "validated_by_actions": True, + "validated_items": items, + "items_response": [], + "contested_items": [], + "not_contested_items": [], + "contested_invoice_amount_open": "0", + "contested_invoice_amount": "0", + "body": error_body, + "response": {"body": error_body}, + "validation_log": [], + "contestation_error_description": error_message, + } + + +_CLOSE_SR_CHANNEL = "AIAGENTCR" +_CLOSE_SR_CLIENT_ID = "AIAGENTCR" +_CONTESTATION_SUCCESS_STATUSES = frozenset({"INICIADA", "INICIADO"}) +_CONTESTATION_SUCCESS_CORRECT_ACCOUNT_STATUSES = frozenset({"CRIAR"}) + + +def _normalize_contestation_item_name(value: Any) -> str: + normalized = ud.normalize("NFKD", str(value or "").strip()) + normalized = "".join(ch for ch in normalized if not ud.combining(ch)) + normalized = re.sub(r"[^a-zA-Z0-9]+", " ", normalized) + return re.sub(r"\s+", " ", normalized).upper().strip() + + +def _same_contestation_item_name(left: Any, right: Any) -> bool: + left_name = _normalize_contestation_item_name(left) + right_name = _normalize_contestation_item_name(right) + if not left_name or not right_name: + return False + if left_name == right_name: + return True + return left_name in right_name or right_name in left_name + + +def _sum_contested_item_amounts( + *, + requested_items: list[dict[str, Any]], + contested_items: list[dict[str, Any]], +) -> dict[str, str]: + contested_names = { + _normalize_contestation_item_name(item.get("itemName")) + for item in contested_items + if isinstance(item, dict) + } + claimed_total = Decimal("0") + validated_total = Decimal("0") + if not contested_names: + return { + "contested_invoice_amount_open": "0", + "contested_invoice_amount": "0", + } + + for item in requested_items: + if not isinstance(item, dict): + continue + item_name = _normalize_contestation_item_name( + item.get("item_name") or item.get("itemName") + ) + if not any(_same_contestation_item_name(item_name, requested_name) for requested_name in contested_names): + continue + claimed_total += Decimal(_normalize_number_text(item.get("claimed_amount", "0"))) + validated_total += Decimal( + _normalize_number_text(item.get("validated_amount", "0")) + ) + + return { + "contested_invoice_amount_open": format(claimed_total, "f"), + "contested_invoice_amount": format(validated_total, "f"), + } + + +def _is_contested_item(item: dict[str, Any]) -> bool: + status = str(item.get("status") or "").strip().upper() + if status in _CONTESTATION_SUCCESS_STATUSES: + return True + + correct_account_status = str( + item.get("correctAccountStatus") + or item.get("correctAccountstatus") + or item.get("correct_account_status") + or item.get("correctAccounStatus") + or "" + ).strip().upper() + return correct_account_status in _CONTESTATION_SUCCESS_CORRECT_ACCOUNT_STATUSES + + +def _extract_item_list(payload: dict[str, Any], key: str, camel_key: str) -> list[dict[str, Any]]: + raw = payload.get(key) + if not isinstance(raw, list): + raw = payload.get(camel_key) + if not isinstance(raw, list): + return [] + return [item for item in raw if isinstance(item, dict)] + + +def _get_sms_enviado_flag(vars_state: Any) -> bool: + if not isinstance(vars_state, dict): + return False + + sms_payload = vars_state.get("enviar_sms") + if not isinstance(sms_payload, dict): + return False + + if "sms_enviado" in sms_payload: + return bool(sms_payload.get("sms_enviado")) + if "sms_sent" in sms_payload: + return bool(sms_payload.get("sms_sent")) + if sms_payload.get("resource_url") or sms_payload.get("resourceUrl"): + return True + return False + + +def _get_sms_not_send_error_flag(vars_state: Any) -> bool: + if not isinstance(vars_state, dict): + return False + + sms_payload = vars_state.get("enviar_sms") + if not isinstance(sms_payload, dict): + return False + + return bool( + sms_payload.get("sms_not_send_error") + or sms_payload.get("sms_nao_enviado_erro") + ) + + +def _inject_complete_invoice_context( + payload: dict[str, Any], + invoice_id: str, +) -> dict[str, Any]: + invoice_id_text = str(invoice_id or "").strip() + if not invoice_id_text: + return payload + + invoice_status = _first_invoice_status_from_payload( + payload, + invoice_id=invoice_id_text, + ) + if invoice_status: + payload["invoice_status"] = invoice_status + elif invoice_id_text: + payload.pop("invoice_status", None) + + invoice_item = _find_invoice_item_by_id(payload, invoice_id_text) + if not isinstance(invoice_item, dict): + if invoice_id_text: + payload.pop("bar_code", None) + payload.pop("due_date", None) + payload.pop("issue_date", None) + return payload + + bar_code = str( + invoice_item.get("barCode") + or invoice_item.get("barcode") + or invoice_item.get("bar_code") + or "" + ).strip() + if bar_code: + payload["bar_code"] = bar_code + + due_date = str( + invoice_item.get("dueDate") + or invoice_item.get("due_date") + or "" + ).strip() + if due_date: + payload["due_date"] = due_date + + issue_date = str( + invoice_item.get("issueDate") + or invoice_item.get("issue_date") + or "" + ).strip() + if issue_date: + payload["issue_date"] = issue_date + + return payload + + +@workflow_action("consultar_perfil_fatura") +def query_profile_bill( + state: dict[str, Any], + params: dict[str, Any], + runtime: WorkflowRuntimeContext, +) -> ActionResult: + """Consulta perfil de fatura para validar forma de pagamento.""" + result = runtime.factory.create_profile_bill( + msisdn=str(params["msisdn"]), + ).execute() + if _result_failed_or_missing_data(result, state=state): + return ActionResult.fail( + result.error or "Falha ao consultar perfil de fatura", + **result.metadata, + ) + payload = _to_dict(result.data) + if isinstance(payload, dict): + method_payment = str(payload.get("method_payment", "")).strip().lower() + requires_sms = bool(payload.get("requires_sms", False)) + payload["forma_pagamento"] = method_payment + payload["requer_sms"] = requires_sms + + logger.info( + """contestacao.decisao_perfil_fatura msisdn=%s + forma_pagamento=%s requires_sms=%s""", + str(params.get("msisdn", "")), + method_payment or "", + requires_sms, + ) + return ActionResult.ok(payload, **result.metadata) + + +@workflow_action("registrar_protocolo_inicio") +def register_protocol_start( + state: dict[str, Any], + params: dict[str, Any], + runtime: WorkflowRuntimeContext, +) -> ActionResult: + """Registra protocolo no inicio do atendimento.""" + msisdn = _first_text_from_params_or_state( + state, params, "msisdn", "Msisdn", "MSISDN", + ) + if not msisdn: + return ActionResult.fail("msisdn obrigatório para registrar_protocolo_inicio") + servico = str(params.get("servico", "")).strip() + scenario = str(params.get("scenario", "")).strip().lower() + ic = str(params.get("ic", "")).strip() + 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( + "contestacao", + stage="open", + context={"servico": servico}, + ) + request_status = str(params.get("request_status", "Fechado")).strip() or "Fechado" + status = str(params.get("status", "CLOSED")).strip() or "CLOSED" + protocol_message_id = _first_text_from_params_or_state( + state, + params, + "message_id", + "messageId", + ) + input_state = state.get("input", {}) if isinstance(state, dict) else {} + protocol_message_id = _first_text_from_params_or_state( + state, + params, + "message_id", + "messageId", + ) + _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") + or input_state.get("uraCallId") + if isinstance(input_state, dict) else "") or "").strip(), + "agentId": "contas", + "channelId": str((input_state.get("channel_id") + or input_state.get("channelId") + if isinstance(input_state, dict) else "URA") or "URA").strip() + } + result = runtime.factory.create_protocol_v2( + msisdn=msisdn, + interaction_protocol="", + flag_sms=True, + social_sec_no=social_sec_no, + source="CHAT", + reason1=open_triplet.reason1 or "Solicitação", + reason2=open_triplet.reason2 or "cancelamento_vas", + reason3=open_triplet.reason3 or "Valor", + direction_contact="FROM-CLIENT", + type="CLIENTE", + request_flag=True, + request_status=request_status, + status=status, + client_id="AIAGENTCR", + ic_context=_ic_base, + message_id=protocol_message_id, + ).execute() + if _result_failed_or_missing_data(result, state=state): + if scenario == "invoice_explanation_aceite_fechado": + cvn_input_state = dict(input_state) if isinstance(input_state, dict) else {} + if not cvn_input_state.get("msisdn"): + cvn_input_state["msisdn"] = msisdn + if not cvn_input_state.get("ura_call_id"): + cvn_input_state["ura_call_id"] = _ic_base.get("uraCallId", "") + if not cvn_input_state.get("channel_id"): + cvn_input_state["channel_id"] = _ic_base.get("channelId", "URA") + _emit_ic(CVNTag.REGISTRA_ATENDIMENTO_FAIL, cvn_input_state) + _emit_ic(VEBTag.REGISTRA_ATENDIMENTO_FAIL, cvn_input_state) + return ActionResult.fail( + result.error or "Falha ao registrar protocolo", + **result.metadata, + ) + payload = _to_dict(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 "" + ) + + if scenario == "invoice_explanation_aceite_fechado": + cvn_input_state = dict(input_state) if isinstance(input_state, dict) else {} + if not cvn_input_state.get("msisdn"): + cvn_input_state["msisdn"] = msisdn + if not cvn_input_state.get("ura_call_id"): + cvn_input_state["ura_call_id"] = _ic_base.get("uraCallId", "") + if not cvn_input_state.get("channel_id"): + cvn_input_state["channel_id"] = _ic_base.get("channelId", "URA") + _emit_ic(CVNTag.REGISTRA_ATENDIMENTO_OK, cvn_input_state) + _emit_ic(VEBTag.REGISTRA_ATENDIMENTO_OK, cvn_input_state) + + output: dict[str, Any] = {"protocolo_id": protocolo_id} + if ic: + output["ic"] = ic + message_id = _first_text_from_params_or_state( + state, + params, + "message_id", + "messageId", + ) + if message_id: + output["message_id"] = message_id + + return ActionResult.ok(output, **result.metadata) + + +@workflow_action("registrar_protocolo") +def register_protocol( + state: dict[str, Any], + params: dict[str, Any], + runtime: WorkflowRuntimeContext, +) -> ActionResult: + """Registra protocolo e retorna dados para contestacao.""" + msisdn = _first_text_from_params_or_state( + state, + params, + "msisdn", + "Msisdn", + "MSISDN", + ) + if not msisdn: + return ActionResult.fail("msisdn obrigatório para registrar_protocolo") + asset_id = re.sub(r"\D", "", msisdn) + social_sec_no = re.sub( + r"\D", + "", + _first_text_from_params_or_state( + state, + params, + "social_sec_no", + "socialSecNo", + "cpf", + "customer_document", + "customerDocument", + "document", + ), + ) + scenario = str( + params.get("scenario") + or params.get("tipo_atendimento") + or "atendimento_geral" + ).strip() + open_triplet = resolve_protocol_triplet(scenario, stage="open") + request_status = str(params.get("request_status", "Aberto")).strip() or "Aberto" + status = str(params.get("status", "Encaminhado")).strip() or "Encaminhado" + protocol_message_id = _first_text_from_params_or_state( + state, + params, + "message_id", + "messageId", + ) + input_state = state.get("input", {}) if isinstance(state, dict) else {} + _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(), + } + + rct_operation = ( + RCTOperation.REG_ATEND_CONTESTACAO + if scenario.strip().lower() == "contestacao" + else RCTOperation.REG_CHAMADO_BO + ) + + result = runtime.factory.create_protocol_v2( + msisdn=msisdn, + asset_id=asset_id, + social_sec_no=social_sec_no, + channel="AIAGENTCR", + access_type="Controle", + interaction_protocol="", + flag_sms="", + source="CHAT", + reason1=open_triplet.reason1, + reason2=open_triplet.reason2, + reason3=open_triplet.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=rct_operation, + message_id=protocol_message_id, + ).execute() + if _result_failed_or_missing_data(result, state=state): + return ActionResult.fail( + result.error or "Falha ao registrar protocolo", + **result.metadata, + ) + + payload = _to_dict(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 "" + ) + + vars_state = state.get("vars", {}) if isinstance(state, dict) else {} + perfil = vars_state.get("consultar_perfil_fatura", {}) + forma_pagamento = "" + if isinstance(perfil, dict): + forma_pagamento = str( + perfil.get("forma_pagamento") + or perfil.get("method_payment") + or "" + ).strip() + + sms_enviado = _get_sms_enviado_flag(vars_state) + sms_not_send_error = _get_sms_not_send_error_flag(vars_state) + + output = { + "protocolo_id": protocolo_id, + "forma_pagamento": forma_pagamento, + "tipo_fatura": forma_pagamento, + "sms_sent": sms_enviado, + "sms_not_send_error": sms_not_send_error, + } + logger.info( + "contestacao.decisao_registrar_protocolo msisdn=%s protocolo=%s sms_enviado=%s", + str(msisdn), + protocolo_id, + sms_enviado, + ) + + return ActionResult.ok(output, **result.metadata) + + +@workflow_action("atualizar_status_sr") +def update_service_request_status( + state: dict[str, Any], + params: dict[str, Any], + runtime: WorkflowRuntimeContext, +) -> ActionResult: + """Atualiza o status de Service Request no Siebel Pós.""" + vars_state = state.get("vars", {}) if isinstance(state, dict) else {} + protocolo_id = "" + if isinstance(vars_state, dict): + protocolo_id = str( + vars_state.get("registrar_protocolo", {}).get("protocolo_id", "") + ).strip() + if not protocolo_id: + protocolo_id = str( + vars_state.get("registrar_protocolo_inicio", {}).get( + "protocolo_id", "" + ) + ).strip() + + notes = "Solicitação via chat Fechado" + if not protocolo_id: + return ActionResult.ok( + {"updated": False, "reason": "no_protocol", "protocol_closed": False} + ) + close_triplet = resolve_protocol_triplet("contestacao", stage="close", context={}) + msisdn = _first_text_from_params_or_state( + state, + params, + "msisdn", + "Msisdn", + "MSISDN", + ) + input_state = state.get("input", {}) if isinstance(state, dict) else {} + _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(), + } + contestacao_payload = vars_state.get("abrir_contestacao_cliente", {}) + if not isinstance(contestacao_payload, dict): + contestacao_payload = {} + check_invoice_payload = vars_state.get("check_invoice_status", {}) + if not isinstance(check_invoice_payload, dict): + check_invoice_payload = {} + invoice_number = _first_text_from_params_or_state( + state, + params, + "current_invoice_number", + "invoice_number", + "invoiceNumber", + "invoice_id", + "invoiceId", + ) + perfil_payload = vars_state.get("consultar_perfil_fatura", {}) + if not isinstance(perfil_payload, dict): + perfil_payload = {} + expiration_date = ( + _first_invoice_date_from_payload_by_id( + check_invoice_payload, invoice_number, "dueDate" + ) + or _first_invoice_date_from_payload_by_id( + perfil_payload, invoice_number, "dueDate" + ) + ) + invoice_status = ( + _first_invoice_status_from_payload( + check_invoice_payload, invoice_id=invoice_number + ) + or _first_invoice_status_from_payload( + perfil_payload, invoice_id=invoice_number + ) + ) + if not invoice_status: + if invoice_number: + invoice_status = "" + else: + status_list = check_invoice_payload.get("invoice_statuses") + if not isinstance(status_list, list): + status_list = list(perfil_payload.get("invoice_statuses", [])) + else: + status_list = list(status_list) + for raw_status in status_list: + status_text = str(raw_status or "").strip() + if status_text: + invoice_status = status_text + break + invoice_open_amount = _normalize_number_text( + _first_text_from_params_or_state( + state, + params, + "invoice_amount_open", + "invoiceAmountOpen", + "valor", + "amount", + ), + ) + invoice_total_amount = _normalize_number_text( + _first_text_from_params_or_state( + state, + params, + "invoice_amount", + "invoiceAmount", + "valor", + "amount", + ), + default=invoice_open_amount, + ) + if "contested_invoice_amount_open" in contestacao_payload: + invoice_open_amount = _normalize_number_text( + contestacao_payload.get("contested_invoice_amount_open"), + default="0", + ) + invoice_total_amount = _normalize_number_text( + contestacao_payload.get("contested_invoice_amount"), + default=invoice_open_amount, + ) + emission_date = ( + _first_invoice_date_from_payload_by_id( + check_invoice_payload, invoice_number, "issueDate" + ) + or _first_invoice_date_from_payload_by_id( + perfil_payload, invoice_number, "issueDate" + ) + ) + social_sec_no = re.sub( + r"\D", + "", + _first_text_from_params_or_state( + state, + params, + "social_sec_no", + "socialSecNo", + "cpf", + "customer_document", + "customerDocument", + ), + ) + tracking_result = runtime.factory.create_tracking_activities( + msisdn=msisdn, + social_sec_no=social_sec_no, + protocol_number=protocolo_id, + invoice_emission_date=emission_date, + invoice_expiration_date=expiration_date, + invoice_number=invoice_number, + invoice_status=_map_invoice_status_to_state(invoice_status), + invoice_open_amount=invoice_open_amount, + invoice_total_amount=invoice_total_amount, + activity_type="Contestação", + activity_status="Aberto", + activity_id="", + ).execute() + if not tracking_result.success: + return ActionResult.fail( + tracking_result.error or "Falha ao enviar trackingActivities", + **tracking_result.metadata, + ) + + status = "Fechado" + status_date = datetime.now().strftime("%d/%m/%Y %H:%M:%S") + service_request_message_id = _first_text_from_params_or_state( + state, + params, + "message_id", + "messageId", + ) + result = runtime.factory.create_service_request_status( + channel=_CLOSE_SR_CHANNEL, + status=status, + protocol_number=protocolo_id, + reason1=close_triplet.reason1 or "Reclamação", + reason2=close_triplet.reason2 or "Conta", + reason3=close_triplet.reason3 or "Valor", + notes=notes, + msisdn=msisdn, + date=status_date, + client_id=_CLOSE_SR_CLIENT_ID, + ura_call_id=str((input_state.get("ura_call_id") if isinstance(input_state, dict) + else "") or "").strip(), + ic_context=_ic_base, + message_id=service_request_message_id, + rct_operation=RCTOperation.REG_ATEND_STATUS_SR, + ).execute() + + if not result.success: + _emit_vaa(VAATag.STATUS_SR_FAIL, _ic_base, gsm=msisdn) + return ActionResult.fail( + result.error or "Falha ao atualizar status da SR", + **result.metadata, + ) + _emit_vaa(VAATag.STATUS_SR_OK, _ic_base, gsm=msisdn) + + payload = _to_dict(result.data) + input_state = state.get("input", {}) if isinstance(state, dict) else {} + vars_state = state.get("vars", {}) if isinstance(state, dict) else {} + if not isinstance(input_state, dict): + input_state = {} + if not isinstance(vars_state, dict): + vars_state = {} + + sms_enviado = _get_sms_enviado_flag(vars_state) + sms_not_send_error = _get_sms_not_send_error_flag(vars_state) + + _emit_vaa(VAATag.STATUS_SR_COM_SMS if sms_enviado else VAATag.STATUS_SR_SEM_SMS, + _ic_base, gsm=msisdn) + perfil_payload = vars_state.get("consultar_perfil_fatura", {}) + check_payload = vars_state.get("check_invoice_status", {}) + requires_sms = ( + bool( + perfil_payload.get("requires_sms") + or perfil_payload.get("requer_sms") + ) + if isinstance(perfil_payload, dict) + else False + ) + has_open_bill = ( + bool( + _has_open_bill_from_payload( + check_payload, + invoice_id=invoice_number, + default=False, + ) + if isinstance(check_payload, dict) + else False + ) + or bool( + _has_open_bill_from_payload( + perfil_payload, + invoice_id=invoice_number, + default=False, + ) + if isinstance(perfil_payload, dict) + else False + ) + ) + payment_method = "" + invoice_status_texts: list[str] = [] + if isinstance(perfil_payload, dict): + payment_method = str( + perfil_payload.get("method_payment") + or perfil_payload.get("forma_pagamento") + or "" + ).strip() + invoice_status_texts.extend( + _invoice_status_texts_from_payload(perfil_payload, invoice_id=invoice_number) + ) + if isinstance(check_payload, dict): + invoice_status_texts.extend( + _invoice_status_texts_from_payload(check_payload, invoice_id=invoice_number) + ) + + if not invoice_status_texts: + if isinstance(perfil_payload, dict): + invoice_status_texts.extend(_collect_invoice_status_texts(perfil_payload)) + if isinstance(check_payload, dict): + invoice_status_texts.extend(_collect_invoice_status_texts(check_payload)) + + if not invoice_number: + has_open_bill = ( + bool(check_payload.get("has_open_bill")) + if isinstance(check_payload, dict) + else bool( + perfil_payload.get("has_open_bill") + if isinstance(perfil_payload, dict) + else False + ) + ) + decision = _resolve_refund_and_resolution_decision( + payment_method=payment_method, + has_open_bill=has_open_bill, + invoice_status_texts=invoice_status_texts, + ) + + contestacao_payload = vars_state.get("abrir_contestacao_cliente", {}) + if not isinstance(contestacao_payload, dict): + contestacao_payload = {} + check_invoice_payload = vars_state.get("check_invoice_status", {}) + if not isinstance(check_invoice_payload, dict): + check_invoice_payload = {} + invoice_number = _first_text_from_params_or_state( + state, + params, + "current_invoice_number", + "invoice_number", + "invoiceNumber", + "invoice_id", + "invoiceId", + ) + perfil_payload = vars_state.get("consultar_perfil_fatura", {}) + if not isinstance(perfil_payload, dict): + perfil_payload = {} + expiration_date = ( + _first_invoice_date_from_payload_by_id( + check_invoice_payload, invoice_number, "dueDate" + ) + or _first_invoice_date_from_payload_by_id( + perfil_payload, invoice_number, "dueDate" + ) + ) + invoice_status = ( + _first_invoice_status_from_payload( + check_invoice_payload, invoice_id=invoice_number + ) + or _first_invoice_status_from_payload( + perfil_payload, invoice_id=invoice_number + ) + ) + if not invoice_status: + if invoice_number: + invoice_status = "" + else: + status_list = check_invoice_payload.get("invoice_statuses") + if not isinstance(status_list, list): + status_list = list(perfil_payload.get("invoice_statuses", [])) + else: + status_list = list(status_list) + for raw_status in status_list: + status_text = str(raw_status or "").strip() + if status_text: + invoice_status = status_text + break + invoice_open_amount = _normalize_number_text( + _first_text_from_params_or_state( + state, + params, + "invoice_amount_open", + "invoiceAmountOpen", + "valor", + "amount", + ), + ) + invoice_total_amount = _normalize_number_text( + _first_text_from_params_or_state( + state, + params, + "invoice_amount", + "invoiceAmount", + "valor", + "amount", + ), + default=invoice_open_amount, + ) + emission_date = ( + _first_invoice_date_from_payload_by_id( + check_invoice_payload, invoice_number, "issueDate" + ) + or _first_invoice_date_from_payload_by_id( + perfil_payload, invoice_number, "issueDate" + ) + ) + social_sec_no = re.sub( + r"\D", + "", + _first_text_from_params_or_state( + state, + params, + "social_sec_no", + "socialSecNo", + "cpf", + "customer_document", + "customerDocument", + ), + ) + + format_text = str(decision["format_text"]) + result_output: dict[str, Any] = { + "updated": True, + "protocol_closed": status == "Fechado", + "format_text": format_text, + "resolution_type": str(decision["resolution_type"]), + "sms_enviado": sms_enviado, + "sms_sent": sms_enviado, + "sms_not_send_error": sms_not_send_error, + "requires_sms": requires_sms, + "has_open_bill": has_open_bill, + "decision_reason": str(decision["decision_reason"]), + "data_credito_proxima_fatura": str( + input_state.get("data_credito_proxima_fatura", "") + ).strip(), + "contestation_id": str( + contestacao_payload.get("contestation_id", "") + ).strip(), + "barcode": str(contestacao_payload.get("barcode", "")).strip(), + "items_response": contestacao_payload.get("items_response", []), + "payment_method": payment_method, + **payload, + } + result_output["body"] = contestacao_payload.get("body", {}) + result_output["items_response"] = contestacao_payload.get("items_response", []) + result_output["contested_items"] = contestacao_payload.get("contested_items", []) + result_output["not_contested_items"] = contestacao_payload.get( + "not_contested_items", [] + ) + result_output["contested_invoice_amount_open"] = str( + contestacao_payload.get("contested_invoice_amount_open", "0") + ) + result_output["contested_invoice_amount"] = str( + contestacao_payload.get("contested_invoice_amount", "0") + ) + for key in _CONTESTATION_API_ERROR_KEYS: + if key in contestacao_payload: + result_output[key] = contestacao_payload[key] + + if protocolo_id: + result_output["protocol_number"] = protocolo_id + result_output["sr"] = str( + contestacao_payload.get("sr", protocolo_id) + ).strip() + + return ActionResult.ok(result_output, **result.metadata) + + +@workflow_action("check_invoice_status") +def query_complete_invoices( + state: dict[str, Any], + params: dict[str, Any], + runtime: WorkflowRuntimeContext, +) -> ActionResult: + """Consulta faturas do cliente (aberta/fechada).""" + vars_state = state.get("vars", {}) if isinstance(state, dict) else {} + invoice_id = _first_text_from_params_or_state( + state, + params, + "invoice_id", + "invoiceId", + "current_invoice_number", + "currentInvoiceNumber", + "invoice_number", + "invoiceNumber", + ) + cached = ( + vars_state.get("check_invoice_status", {}) + if isinstance(vars_state, dict) + else {} + ) + if isinstance(cached, dict) and cached: + logger.info( + "contestacao.check_invoice_status cache_hit=True " + "reutilizando_resultado_complete_invoices" + ) + return ActionResult.ok( + _inject_complete_invoice_context(dict(cached), invoice_id) + ) + + perfil_payload = ( + vars_state.get("consultar_perfil_fatura", {}) + if isinstance(vars_state, dict) + else {} + ) + if isinstance(perfil_payload, dict) and "has_open_bill" in perfil_payload: + logger.info( + "contestacao.check_invoice_status cache_hit=True " + "origem=consultar_perfil_fatura" + ) + payload = { + "has_open_bill": bool(perfil_payload.get("has_open_bill")), + "payment_method": str( + perfil_payload.get("method_payment") + or perfil_payload.get("forma_pagamento") + or "" + ).strip(), + "payment_type_id": perfil_payload.get("payment_type_id"), + "invoice_statuses": perfil_payload.get("invoice_statuses", []), + "open_invoices": perfil_payload.get("open_invoices", []), + "closed_invoices": perfil_payload.get("closed_invoices", []), + "bar_code": str(perfil_payload.get("bar_code", "")).strip(), + "due_date": str(perfil_payload.get("due_date", "")).strip(), + "invoice_status": "", + } + return ActionResult.ok( + _inject_complete_invoice_context(payload, invoice_id) + ) + + result = runtime.factory.create_complete_invoices( + msisdn=str(params["msisdn"]), + ).execute() + if _result_failed_or_missing_data(result, state=state): + return ActionResult.fail( + result.error or "Falha ao consultar faturas", + **result.metadata, + ) + payload = _to_dict(result.data) + if isinstance(payload, dict): + payload = _inject_complete_invoice_context(payload, invoice_id) + return ActionResult.ok(payload, **result.metadata) + + +@workflow_action("abrir_contestacao_cliente") +def open_customer_contestation( + state: dict[str, Any], + params: dict[str, Any], + runtime: WorkflowRuntimeContext, +) -> ActionResult: + """Abre contestação no endpoint /interactions/v1/customerContestation.""" + msisdn = _first_text_from_params_or_state( + state, + params, + "msisdn", + "Msisdn", + "MSISDN", + ) + if not msisdn: + return ActionResult.fail("msisdn obrigatório para abrir_contestacao_cliente") + + input_state = state.get("input", {}) if isinstance(state, dict) else {} + factory_config = getattr(runtime.factory, "_config", None) + contestation_user_id = str( + getattr(factory_config, "customer_contestation_user_id", "") or "" + ).strip() + contestation_channel_id = contestation_user_id or "AIAGENTCR" + _ic_base: dict[str, Any] = { + "sessionId": str(get_session_id() or ""), + "gsm": msisdn, + "ani": str((input_state.get("ani") if isinstance(input_state, dict) + else "") or "").strip(), + "uraCallId": str((input_state.get("ura_call_id") + or input_state.get("uraCallId") + if isinstance(input_state, dict) else "") or "").strip(), + "agentId": "contas", + "channelId": str((input_state.get("channel_id") + or input_state.get("channelId") + if isinstance(input_state, dict) else "URA") or "URA").strip(), + } + + vars_state = state.get("vars", {}) if isinstance(state, dict) else {} + protocolo_id = _first_text_from_params_or_state( + state, + params, + "protocolo_id", + "protocol_number", + "protocolNumber", + "sr", + ) + if not protocolo_id and isinstance(vars_state, dict): + register_payload = vars_state.get("registrar_protocolo", {}) + if isinstance(register_payload, dict): + protocolo_id = str(register_payload.get("protocolo_id", "")).strip() + if not protocolo_id: + return ActionResult.fail("Protocolo obrigatório para abrir contestação") + + customer_id = _first_text_from_params_or_state( + state, + params, + "customer_id", + "customerId", + ) + if not customer_id: + return ActionResult.fail("customer_id obrigatório para abrir contestação") + + 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 ActionResult.fail("invoice_number obrigatório para abrir contestação") + + social_sec_no = re.sub( + r"\D", + "", + _first_text_from_params_or_state( + state, + params, + "social_sec_no", + "socialSecNo", + "customer_document", + "customerDocument", + ), + ) + + invoice_payload = _extract_invoice_payload_from_state_or_params(state, params) + contestation_request_rules = _resolve_contestation_request_rules( + state, + params, + runtime, + msisdn=msisdn, + invoice_number=invoice_number, + invoice_payload=invoice_payload, + ) + validation_error = str(contestation_request_rules.get("validation_error") or "") + if validation_error: + validation_log = contestation_request_rules.get("validation_log", []) + return ActionResult.fail(validation_error, validation_log=validation_log) + + if contestation_request_rules.get("item_validation_skipped"): + logger.warning( + "contestacao.pro_rata.warning validacao_desativada " + "reason=pro_rata items=%s", + [ + item.get("item_name", "") + for item in contestation_request_rules.get("items", []) + ], + ) + + validation_log = contestation_request_rules.get("validation_log", []) + for item_log in validation_log: + logger.info("contestacao.validacao_item %s", item_log) + items = contestation_request_rules.get("items", []) + customer_status = str(contestation_request_rules.get("customer_status") or "") + invoice_status = str(contestation_request_rules.get("invoice_status") or "") + invoice_due_date = str(contestation_request_rules.get("invoice_due_date") or "") + invoice_amount_open = str( + contestation_request_rules.get("invoice_amount_open") or "" + ) + invoice_amount = str(contestation_request_rules.get("invoice_amount") or "") + refund_option = str(contestation_request_rules.get("refund_option") or "") + manual_conta_certa_indicator = bool( + contestation_request_rules.get("manual_conta_certa_indicator") + ) + tipo_atendimento = ( + _first_text_from_params_or_state( + state, + params, + "tipo_atendimento", + "tipoAtendimento", + ) + .strip() + .lower() + ) + rct_operation = RCTOperation.CONTESTACAO + if tipo_atendimento == "pro_rata": + rct_operation = RCTOperation.CONTESTACAO_CONTROLE + elif tipo_atendimento == "valor_divergente": + rct_operation = RCTOperation.CONTESTACAO_VALOR_DIVERGENTE + + result = runtime.factory.create_customer_contestation( + msisdn=msisdn, + sr=protocolo_id, + social_sec_no=social_sec_no, + customer_id=customer_id, + invoice_number=invoice_number, + user_id=contestation_user_id or contestation_channel_id, + customer_id_current=_first_text_from_params_or_state( + state, + params, + "customer_id_current", + "customerIdCurrent", + ), + customer_type=( + _first_text_from_params_or_state( + state, + params, + "customer_type", + "customerType", + ) + or "2" + ), + customer_status=customer_status, + invoice_status=invoice_status, + invoice_amount_open=invoice_amount_open, + invoice_amount=invoice_amount, + invoice_due_date=invoice_due_date, + 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=refund_option or "0", + double_refund=_normalize_bool( + _first_value_from_params_or_state( + state, + params, + "double_refund", + "doubleRefund", + ), + default=False, + ), + manual_conta_certa_indicator=manual_conta_certa_indicator, + items=items, + message_id=_first_text_from_params_or_state( + state, + params, + "message_id", + "messageId", + ), + client_id="AIAGENTCR", + ic_context=_ic_base, + rct_operation=rct_operation, + ).execute() + + if _result_failed_or_missing_data(result, state=state): + _emit_vaa(VAATag.CONTESTA_VAS_AVULSO_FAIL, _ic_base, gsm=msisdn) + if _is_recoverable_contestation_api_error(result): + action_response = _build_contestation_api_error_output( + result, + protocolo_id=protocolo_id, + items=items, + ) + logger.warning( + "contestacao.api_error_recoverable msisdn=%s sr=%s " + "description=%s", + msisdn, + protocolo_id, + action_response["contestation_error_description"], + ) + return ActionResult.ok(action_response, **result.metadata) + return ActionResult.fail( + result.error or "Falha ao abrir contestação", + **result.metadata, + ) + + payload = _to_dict(result.data) + response = payload.get("response", {}) if isinstance(payload, dict) else {} + if not isinstance(response, dict): + response = {} + response_body = response.get("body", {}) + if not isinstance(response_body, dict): + response_body = response + if bool(response.get("dry_run")): + logger.info( + "contestacao.dry_run payload_preview=%s", + response.get("request_preview", {}), + ) + resolved_barcode = str( + payload.get("barcode") + or payload.get("codigo_boleto") + or payload.get("codigoBoleto") + or payload.get("bar_code") + or payload.get("barCode") + or response.get("barcode") + or response.get("codigo_boleto") + or response.get("codigoBoleto") + or response.get("bar_code") + or response.get("barCode") + or response_body.get("barcode") + or response_body.get("codigo_boleto") + or response_body.get("codigoBoleto") + or response_body.get("bar_code") + or response_body.get("barCode") + or "" + ).strip() + # Invalid placeholder: if every barcode digit is zero, do not trigger SMS. + only_digits = re.sub(r"\D", "", resolved_barcode) + if only_digits and set(only_digits) == {"0"}: + resolved_barcode = "" + if resolved_barcode: + _emit_vaa(VAATag.CODIGO_BARRAS_ELEGIVEL, _ic_base, gsm=msisdn) + + items_response = payload.get("items_response", []) + if not isinstance(items_response, list): + items_response = response.get("itemsResponse", []) + if not isinstance(items_response, list): + items_response = response_body.get("itemsResponse", []) + if not isinstance(items_response, list): + items_response = [] + items_response = [item for item in items_response if isinstance(item, dict)] + payload_contested_items = _extract_item_list( + payload, + "contested_items", + "contestedItems", + ) + response_contested_items = _extract_item_list( + response_body, + "contested_items", + "contestedItems", + ) + response_contested_items += _extract_item_list( + response, + "contested_items", + "contestedItems", + ) + contested_items = [ + item + for item in items_response + if _is_contested_item(item) + ] + not_contested_items = [ + item + for item in items_response + if not _is_contested_item(item) + ] + known_contested_names: set[str] = { + _normalize_contestation_item_name(item.get("itemName")) + for item in contested_items + } + for item in payload_contested_items + response_contested_items: + normalized = _normalize_contestation_item_name( + item.get("itemName") or item.get("item_name") + ) + if normalized and normalized not in known_contested_names: + contested_items.append(item) + known_contested_names.add(normalized) + + contested_amounts = _sum_contested_item_amounts( + requested_items=items, + contested_items=contested_items, + ) + action_response = { + "manual_conta_certa_indicator": bool(manual_conta_certa_indicator), + "contestation_id": str( + payload.get("contestation_id") + or response.get("contestationId") + or response_body.get("contestationId") + or "" + ).strip(), + "sr": str( + payload.get("sr") + or response.get("sr") + or response_body.get("sr") + or protocolo_id + ).strip(), + "barcode": resolved_barcode, + "validated_by_actions": True, + "validated_items": items, + "items_response": items_response, + "contested_items": contested_items, + "not_contested_items": not_contested_items, + **contested_amounts, + "body": response_body, + "response": response, + "validation_log": validation_log, + } + logger.info( + "contestacao.actions.response %s", + json.dumps(action_response, ensure_ascii=False, default=str), + ) + return ActionResult.ok(action_response, **result.metadata) + + +@workflow_action("consultar_contrato_corte") +def check_billing_cutoff( + state: dict[str, Any], + params: dict[str, Any], + runtime: WorkflowRuntimeContext, +) -> ActionResult: + """Consulta contrato e aplica regra de data de corte. + + Regra: + - data da reclamação >= dia_corte -> data_corte_flag = "0" + - data da reclamação < dia_corte -> data_corte_flag = "1" + """ + msisdn = _first_text_from_params_or_state( + state, params, "msisdn", "Msisdn", "MSISDN", + ) + if not msisdn: + return ActionResult.fail("msisdn obrigatório para consultar_contrato_corte") + + result = runtime.factory.create_contrato( + msisdn=msisdn, + ).execute() + if _result_failed_or_missing_data(result, state=state): + return ActionResult.fail( + result.error or "Falha ao consultar contrato", + **result.metadata, + ) + + payload = _to_dict(result.data) + plano_familia_acesso_dependente = _is_plano_familia_dependent_access( + state, + params, + payload if isinstance(payload, dict) else None, + ) + billing = payload.get("billing_profile", {}) + if not isinstance(billing, dict): + billing = {} + date_info = billing.get("date", {}) + if not isinstance(date_info, dict): + date_info = {} + dia_corte = 0 + try: + dia_corte = int(date_info.get("cutoffDay") or 0) + except (ValueError, TypeError): + dia_corte = 0 + + due_date_raw = _resolve_contestation_due_date(state, params) + dia_vencimento = _extrair_dia_vencimento(due_date_raw) + + data_reclamacao = datetime.now() + dia_reclamacao = data_reclamacao.day + cutoff_reference: datetime | None = None + vars_state = state.get("vars", {}) if isinstance(state, dict) else {} + invoice_number = _first_text_from_params_or_state( + state, + params, + "invoice_id", + "invoiceId", + "current_invoice_number", + "currentInvoiceNumber", + "invoice_number", + "invoiceNumber", + ) + check_invoice_payload = ( + vars_state.get("check_invoice_status", {}) + if isinstance(vars_state, dict) + else {} + ) + normalized_cut_date = "" + if isinstance(check_invoice_payload, dict): + normalized_cut_date = _first_invoice_date_from_payload_by_id( + check_invoice_payload, + invoice_number, + "cutDate", + "cutoffDate", + ) + + if normalized_cut_date: + try: + parsed_cut_date = datetime.strptime( + normalized_cut_date, + "%Y-%m-%dT%H:%M:%S.%fZ", + ) + except ValueError: + apos_data_corte, cutoff_reference = _resolve_billing_cutoff_reference( + dia_corte, + reference_datetime=data_reclamacao, + ) + else: + apos_data_corte, cutoff_reference = _resolve_next_cutoff_from_cut_date( + parsed_cut_date, + reference_datetime=data_reclamacao, + ) + else: + apos_data_corte, cutoff_reference = _resolve_billing_cutoff_reference( + dia_corte, + reference_datetime=data_reclamacao, + ) + data_corte_referencia = ( + cutoff_reference.strftime("%Y-%m-%d") if cutoff_reference else "" + ) + + data_corte_flag = "0" if apos_data_corte else "1" + abrir_conta_certa_manual = bool(apos_data_corte) + operador_decisao = "maior ou igual a" if apos_data_corte else "menor que" + acao_decisao = ( + "abrir Conta Certa Manual" + if abrir_conta_certa_manual + else "seguir fluxo sem Conta Certa Manual" + ) + decisao_conta_certa = ( + f"Data atual [{data_reclamacao.strftime('%Y-%m-%d')}] e {operador_decisao} " + f"data de corte [{data_corte_referencia or '-'}], {acao_decisao}." + ) + contestacao_sucesso = False + conta_certa_status = "NAO_SE_APLICA" + if isinstance(vars_state, dict): + contestacao_payload = vars_state.get("abrir_contestacao_cliente", {}) + if isinstance(contestacao_payload, dict): + items_response = contestacao_payload.get("items_response", []) + if isinstance(items_response, list): + for item in items_response: + if not isinstance(item, dict): + continue + raw_status = item.get("correctAccountStatus") + if raw_status is None: + raw_status = item.get("correctAccounStatus") + normalized = str(raw_status or "").strip().upper() + if normalized: + conta_certa_status = normalized + if normalized == "CRIAR": + contestacao_sucesso = True + break + + input_state = state.get("input", {}) if isinstance(state, dict) else {} + _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(), + } + _emit_vaa( + VAATag.CONTESTACAO_OK if contestacao_sucesso else VAATag.CONTESTACAO_FAIL, + _ic_base, + gsm=msisdn, + extra_metadata={ + "decisaoContaCerta": decisao_conta_certa, + "dataCorteReferencia": data_corte_referencia, + "dataReclamacao": data_reclamacao.strftime("%Y-%m-%d"), + "abrirContaCertaManual": abrir_conta_certa_manual, + "diaCorte": dia_corte, + "diaReclamacao": dia_reclamacao, + }, + ) + + logger.info( + "consultar_contrato_corte msisdn=%s dia_corte=%s dia_reclamacao=%s " + "data_corte_referencia=%s apos_data_corte=%s contestacao_sucesso=%s " + "conta_certa_status=%s dia_vencimento=%s", + msisdn, + dia_corte, + dia_reclamacao, + data_corte_referencia, + apos_data_corte, + contestacao_sucesso, + conta_certa_status, + dia_vencimento, + ) + logger.info("consultar_contrato_corte decisao_conta_certa=%s", decisao_conta_certa) + + return ActionResult.ok( + { + "apos_data_corte": apos_data_corte, + "data_corte_flag": data_corte_flag, + "abrir_conta_certa_manual": abrir_conta_certa_manual, + "decisao_conta_certa": decisao_conta_certa, + "contestation_success": contestacao_sucesso, + "conta_certa_status": conta_certa_status, + "plano_familia_acesso_dependente": plano_familia_acesso_dependente, + "dia_corte": dia_corte, + "dia_vencimento": dia_vencimento, + }, + **result.metadata, + ) + + +@workflow_action("abrir_sr_conta_certa_manual") +def open_manual_conta_certa_sr( + state: dict[str, Any], + params: dict[str, Any], + runtime: WorkflowRuntimeContext, +) -> ActionResult: + """Abre SR de Conta Certa Manual quando contestação ocorre após data de corte.""" + msisdn = _first_text_from_params_or_state( + state, params, "msisdn", "Msisdn", "MSISDN", + ) + if not msisdn: + return ActionResult.fail("msisdn obrigatório para abrir_sr_conta_certa_manual") + + social_sec_no = re.sub( + r"\D", "", + _first_text_from_params_or_state( + state, params, "social_sec_no", "socialSecNo", + ), + ) + dia_vencimento = str(params.get("dia_vencimento", "")).strip() + vars_state = state.get("vars", {}) if isinstance(state, dict) else {} + if not dia_vencimento and isinstance(vars_state, dict): + consultar_contrato_corte = vars_state.get("consultar_contrato_corte", {}) + if isinstance(consultar_contrato_corte, dict): + dia_vencimento = str( + consultar_contrato_corte.get("dia_vencimento", "") + ).strip() + if not dia_vencimento: + dia_vencimento = _resolve_contestation_due_date(state, params) + dia_vencimento = _extrair_dia_vencimento(dia_vencimento) + open_triplet = resolve_protocol_triplet( + "conta_certa_manual", + stage="open", + context={"dia_vencimento": dia_vencimento}, + ) + request_status = (str(params.get("request_status", "Encaminhado")).strip() + or "Encaminhado") + status = str(params.get("status", "Encaminhado")).strip() or "Encaminhado" + protocol_message_id = _first_text_from_params_or_state( + state, + params, + "message_id", + "messageId", + ) + input_state = state.get("input", {}) if isinstance(state, dict) else {} + service_request_notes = ( + str(params.get("service_request_notes", "")).strip() + or "Solicitação via chat" + ) + contestacao_payload = ( + vars_state.get("abrir_contestacao_cliente", {}) + if isinstance(vars_state, dict) + else {} + ) + contested_items = ( + contestacao_payload.get("contested_items", []) + if isinstance(contestacao_payload, dict) + else [] + ) + contested_services: list[str] = [] + if isinstance(contested_items, list): + for item in contested_items: + if not isinstance(item, dict): + continue + service_name = str( + item.get("itemName") + or item.get("item_name") + or item.get("name") + or "" + ).strip() + if service_name and service_name not in contested_services: + contested_services.append(service_name) + if contested_services: + contested_summary = f"Serviços contestados: {' | '.join(contested_services)}" + service_request_notes = f"{service_request_notes} | {contested_summary}" + + _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(), + } + + result = runtime.factory.create_protocol_v2( + msisdn=msisdn, + interaction_protocol="", + social_sec_no=social_sec_no, + source="CHAT", + reason1=open_triplet.reason1 or "Processo Interno", + reason2=open_triplet.reason2 or "Conta", + reason3=open_triplet.reason3 or "Valor", + direction_contact="FROM-CLIENT", + type="CLIENTE", + request_flag=True, + request_status=request_status, + status=status, + service_request_notes=service_request_notes, + channel="AIAGENTCR", + client_id="AIAGENTCR", + ic_context=_ic_base, + rct_operation=RCTOperation.REG_CHAMADO_BO, + message_id=protocol_message_id, + ).execute() + + if _result_failed_or_missing_data(result, state=state): + return ActionResult.fail( + result.error or "Falha ao abrir SR Conta Certa Manual", + **result.metadata, + ) + + payload = _to_dict(result.data) + response = payload.get("response", {}) if isinstance(payload, dict) else {} + if not isinstance(response, dict): + response = {} + sr_conta_certa_id = str( + response.get("interactionProtocol") + or response.get("protocolId") + or response.get("protocolo_id") + or "" + ).strip() + protocol_status = status + + logger.info( + "abrir_sr_conta_certa_manual msisdn=%s dia_venc=%s sr=%s", + msisdn, dia_vencimento, sr_conta_certa_id, + ) + + return ActionResult.ok( + { + "dia_vencimento": dia_vencimento, + "status": protocol_status, + }, + **result.metadata, + ) + + +@workflow_action("enviar_sms") +def send_sms( + state: dict[str, Any], + params: dict[str, Any], + runtime: WorkflowRuntimeContext, +) -> ActionResult: + """Envia SMS via invoices/v1/smsBarcode.""" + dados = params.get("dados", {}) + vars_state = state.get("vars", {}) if isinstance(state, dict) else {} + contestacao_payload = ( + vars_state.get("abrir_contestacao_cliente", {}) + if isinstance(vars_state, dict) + else {} + ) + barcode = ( + str(contestacao_payload.get("barcode", "")).strip() + if isinstance(contestacao_payload, dict) + else "" + ) + # Quando houver barcode retornado pela contestação, o SMS deve + # obrigatoriamente carregar esse código no corpo da mensagem. + if barcode: + message = f"Codigo do boleto: {barcode}" + else: + message = str( + params.get("message", "") + or dados.get("texto_sms", "") + ).strip() + long_url = str( + params.get("long_url") + or params.get("longURL") + or "https://meutim.com.br" + ).strip() + notify_url = str( + params.get("notify_url") + or params.get("notifyURL") + or "http://10.114.200.57:9003/teste" + ).strip() + input_state = state.get("input", {}) if isinstance(state, dict) else {} + _ic_base: dict[str, Any] = { + "sessionId": str(get_session_id() or ""), + "gsm": str(params["msisdn"]), + "ani": str((input_state.get("ani") if isinstance(input_state, dict) + else "") or "").strip(), + "uraCallId": str((input_state.get("ura_call_id") + or input_state.get("uraCallId") + if isinstance(input_state, dict) else "") or "").strip(), + "agentId": "contas", + "channelId": str((input_state.get("channel_id") + or input_state.get("channelId") + if isinstance(input_state, dict) else "URA") or "URA").strip() + } + + with _start_sms_observation( + msisdn=str(params["msisdn"]), + has_barcode=bool(barcode), + long_url=long_url, + notify_url=notify_url, + ) as sms_observation: + try: + result = runtime.factory.create_sms( + msisdn=str(params["msisdn"]), + message=message, + sender_address=str(params.get("sender_address", "324")), + sender_name=str(params.get("sender_name", "TIM Brasil")), + long_url=long_url, + notify_url=notify_url, + ic_context=_ic_base, + ).execute() + command_failed = _result_failed_or_missing_data(result, state=state) + result_error = result.error or "Falha ao enviar SMS" + result_metadata = result.metadata + payload = ( + _to_dict(result.data) + if getattr(result, "data", None) is not None + else {} + ) + except Exception as exc: + logger.exception("Erro inesperado ao enviar SMS") + command_failed = True + result_error = f"Falha ao enviar SMS: {exc}" + result_metadata = {} + payload = {} + + ic_context_sms_only = dict(_ic_base) + if str(ic_context_sms_only.get("channelId", "")).strip().upper() == "URA": + ic_context_sms_only["channelId"] = "SMS" + + if not isinstance(payload, dict): + payload = {} + + resource_url = str(payload.get("resource_url", "") or payload.get("resourceUrl", "")).strip() + if not resource_url: + resource_url = "" + + if command_failed or not resource_url: + _emit_vaa(VAATag.SMS_FAIL, ic_context_sms_only, gsm=str(params["msisdn"])) + _emit_vaa( + "sms_nao_enviado_erro", + ic_context_sms_only, + gsm=str(params["msisdn"]), + extra_metadata={"error": result_error}, + ) + payload.update( + { + "sms_enviado": False, + "sms_sent": False, + "sms_not_send_error": True, + "sms_nao_enviado_erro": True, + "resource_url": resource_url, + "error": result_error, + } + ) + _update_sms_observation( + sms_observation, + output={ + "success": False, + "sms_enviado": False, + "sms_sent": False, + "sms_not_send_error": True, + "error": result_error, + }, + level="ERROR", + status_message=result_error, + ) + return ActionResult.ok( + payload, + **result_metadata, + ) + _emit_vaa(VAATag.SMS_OK, _ic_base, gsm=str(params["msisdn"])) + payload["resource_url"] = resource_url + payload["sms_enviado"] = True + payload["sms_sent"] = True + payload["sms_not_send_error"] = False + payload["sms_nao_enviado_erro"] = False + _update_sms_observation( + sms_observation, + output={ + "success": True, + "sms_enviado": True, + "sms_sent": True, + "sms_not_send_error": False, + "resource_url_present": bool(resource_url), + }, + ) + return ActionResult.ok(payload, **result_metadata) + + +__all__ = [ + 'query_profile_bill', + 'register_protocol_start', + 'register_protocol', + 'update_service_request_status', + 'query_complete_invoices', + 'open_customer_contestation', + 'check_billing_cutoff', + 'open_manual_conta_certa_sr', + 'send_sms' +] diff --git a/legacy_reference/workflows/actions/contestacao/helpers.py b/legacy_reference/workflows/actions/contestacao/helpers.py new file mode 100644 index 0000000..3ed8911 --- /dev/null +++ b/legacy_reference/workflows/actions/contestacao/helpers.py @@ -0,0 +1,1207 @@ +from __future__ import annotations + +import logging +import re +import unicodedata +from decimal import Decimal +from calendar import monthrange + +from datetime import datetime +from typing import Any + +from agente_contas_tim.guardrails import validate_contestation_items +from agente_contas_tim.workflows.actions.registry import WorkflowRuntimeContext +from agente_contas_tim.workflows.actions.common.helpers import ( + _contains_any_keyword, + _first_text_from_params_or_state, + _first_value_from_params_or_state, + _map_customer_status_code, + _normalize_bool, + _normalize_number_text, + _normalize_service_name, + _result_failed_or_missing_data, + _text_from_keys, + _to_bool, + _to_dict, +) + +logger = logging.getLogger(__name__) + +_VENCIMENTOS_CONTA_CERTA = (1, 7, 10, 12, 15, 20, 25, 30) + + +def _resolve_billing_cutoff_reference( + cutoff_day: int, + *, + reference_datetime: datetime | None = None, +) -> tuple[bool, datetime | None]: + if cutoff_day <= 0: + return False, None + + reference = reference_datetime or datetime.now() + current_last_day = monthrange(reference.year, reference.month)[1] + current_cutoff_day = min(cutoff_day, current_last_day) + cutoff_reference = datetime( + reference.year, + reference.month, + current_cutoff_day, + ) + + if reference < cutoff_reference: + previous_month = reference.month - 1 + previous_year = reference.year + if previous_month == 0: + previous_month = 12 + previous_year -= 1 + + previous_last_day = monthrange(previous_year, previous_month)[1] + previous_cutoff_day = min(cutoff_day, previous_last_day) + cutoff_reference = datetime( + previous_year, + previous_month, + previous_cutoff_day, + ) + + return reference >= cutoff_reference, cutoff_reference + + +def _resolve_next_cutoff_from_cut_date( + cut_date: datetime, + *, + reference_datetime: datetime | None = None, +) -> tuple[bool, datetime]: + """Retorna se a data de referência já passou do próximo corte. + + Regra: + - manualContaCertaIndicator = False quando sysdate <= (cutDate + 1 mês) + - manualContaCertaIndicator = True quando sysdate > (cutDate + 1 mês) + """ + reference = reference_datetime or datetime.now() + next_month = cut_date.month + 1 + next_year = cut_date.year + if next_month == 13: + next_month = 1 + next_year += 1 + next_last_day = monthrange(next_year, next_month)[1] + next_day = min(cut_date.day, next_last_day) + next_cutoff = datetime(next_year, next_month, next_day) + return reference > next_cutoff, next_cutoff + + + +def _is_missing_amount_value(value: Any) -> bool: + text = str(value).strip().lower() + if not text or text in {"null", "none", "nan"}: + return True + normalized = _normalize_number_text(value, default="") + return normalized in {"", "0", "-0"} + + +def _first_invoice_status_from_items(items: Any) -> str: + if not isinstance(items, (list, tuple)): + return "" + for item in items: + if not isinstance(item, dict): + continue + for key in ("status", "invoiceStatus", "invoice_status", "invoice_status_desc"): + status = str(item.get(key) or "").strip() + if status: + return status + return "" + + +def _invoice_identifier(value: Any) -> str: + return re.sub(r"\D", "", str(value or "").strip()) + + +def _invoice_item_matches_id(item: dict[str, Any], invoice_id: Any) -> bool: + target = _invoice_identifier(invoice_id) + if not target: + return False + for key in ( + "id", + "invoice_id", + "invoiceId", + "invoiceNumber", + "invoice_number", + "current_invoice_number", + "currentInvoiceNumber", + ): + if _invoice_identifier(item.get(key)) == target: + return True + return False + + +def _invoice_items_from_payload(payload: Any) -> list[dict[str, Any]]: + if not isinstance(payload, dict): + return [] + out: list[dict[str, Any]] = [] + for key in ("paymentItems", "payment_items", "open_invoices", "closed_invoices"): + items = payload.get(key, []) + if not isinstance(items, (list, tuple)): + continue + out.extend(item for item in items if isinstance(item, dict)) + return out + + +def _find_invoice_item_by_id(payload: Any, invoice_id: Any) -> dict[str, Any] | None: + for item in _invoice_items_from_payload(payload): + if _invoice_item_matches_id(item, invoice_id): + return item + return None + + +def _to_tracking_datetime(value: Any) -> str: + raw = str(value or "").strip() + if not raw: + return "" + raw = raw.replace("Z", "") + if "T" in raw: + raw = raw.split("T", 1)[0] + for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d"): + try: + parsed = datetime.strptime(raw, fmt) + return parsed.strftime("%Y-%m-%dT00:00:00.00Z") + except ValueError: + continue + return "" + + +def _parse_tracking_date(value: Any) -> datetime | None: + """Converte datas de payload de invoices para datetime (sem horário).""" + iso_candidate = _to_tracking_datetime(value) + if not iso_candidate: + return None + try: + return datetime.strptime(iso_candidate, "%Y-%m-%dT%H:%M:%S.%fZ") + except ValueError: + return None + + +def _parse_tracking_day(value: Any) -> int | None: + parsed_cut_date = _parse_tracking_date(value) + if parsed_cut_date is None: + return None + return parsed_cut_date.day + + +def _first_invoice_date_from_items(items: Any, *keys: str) -> str: + if not isinstance(items, (list, tuple)): + return "" + for item in items: + if not isinstance(item, dict): + continue + for key in keys: + formatted = _to_tracking_datetime(item.get(key)) + if formatted: + return formatted + return "" + + +def _first_invoice_date_from_payload_by_id( + payload: Any, + invoice_id: Any, + *keys: str, +) -> str: + item = _find_invoice_item_by_id(payload, invoice_id) + if not item: + return "" + return _first_invoice_date_from_items([item], *keys) + + +def _first_invoice_text_from_payload_by_id( + payload: Any, + invoice_id: Any, + *keys: str, +) -> str: + item = _find_invoice_item_by_id(payload, invoice_id) + if not item: + return "" + for key in keys: + text = str(item.get(key) or "").strip() + if text: + return text + return "" + + +def _normalize_due_date_yyyymmdd(value: Any) -> str: + text = str(value).strip() + if not text: + return "" + digits = re.sub(r"\D", "", text) + if len(digits) != 8: + return "" + if "/" in text or "-" in text: + day = digits[:2] + month = digits[2:4] + year = digits[4:] + if day.isdigit() and month.isdigit() and year.isdigit(): + if int(day) <= 31 and int(month) <= 12: + return f"{year}{month}{day}" + return digits + + +def _is_dacc_cartao_parcelamento(payment_method: Any) -> bool: + text = str(payment_method or "").strip().lower() + if not text: + return False + # Aceita variações com/sem acento e separadores. + return _contains_any_keyword( + text, + ( + "dacc", + "cartao", + "cartão", + "parcelamento", + ), + ) + + +def _is_invoice_paid_status(status_text: Any) -> bool: + return _contains_any_keyword( + str(status_text or ""), + ("pago", "quitado", "paid"), + ) + + +def _is_invoice_unpaid_status(status_text: Any) -> bool: + return _contains_any_keyword( + str(status_text or ""), + ("em aberto", "a vencer", "nao pago", "não pago", "pendente", "aberto"), + ) + + +def _is_invoice_contested_status(status_text: Any) -> bool: + return _contains_any_keyword( + str(status_text or ""), + ("contest",), + ) + + +def _map_invoice_status_to_state(value: Any) -> str: + text = str(value or "").strip().upper() + if not text: + return "" + text = unicodedata.normalize("NFKD", text) + text = "".join(c for c in text if not unicodedata.combining(c)) + text = text.replace("/", " ") + text = re.sub(r"\s+", " ", text).strip() + + direct_map = { + "PAGO": "Fechada", + "ABERTO CC": "Fechada", + "EM ATRASO CC": "Fechada", + "EM ATRASO DEB AUT": "Fechada", + "ABERTO DEB AUT": "Fechada", + "EM ATRASO PIX": "Fechada", + "ABERTO PIX": "Fechada", + "A VENCER": "Aberto", + "EM ACORDO": "Aberto", + "EM ATRASO": "Aberto", + "EM ABERTO": "Aberto", + "EM PROCESSAMENTO": "Fechada", + "CONSULTA INDISPONIVEL": "Fechada", + "PARCELADO": "Negociada", + "CANCELADO EXPIRADO": "Fechada", + } + return direct_map.get(text, "") + + +def _map_invoice_status_code(value: Any) -> str: + text = str(value or "").strip().lower() + if not text: + return "" + return "1" if "abert" in text else "0" + + +def _first_invoice_status_from_payload(payload: Any, *, invoice_id: Any = "") -> str: + if not isinstance(payload, dict): + return "" + matched_item = _find_invoice_item_by_id(payload, invoice_id) + invoice_status = _first_invoice_status_from_items([matched_item]) + if invoice_status: + return invoice_status + + invoice_id_text = _invoice_identifier(invoice_id) + has_invoice_filter = bool(invoice_id_text) + if has_invoice_filter: + # Se o fluxo já definiu a fatura, somente itens dessa fatura são válidos. + return "" + + invoice_status = str(payload.get("invoice_status") or "").strip() + if invoice_status: + return invoice_status + invoice_status = ( + _first_invoice_status_from_items(payload.get("open_invoices")) + or _first_invoice_status_from_items(payload.get("closed_invoices")) + ) + if invoice_status: + return invoice_status + statuses = payload.get("invoice_statuses", []) + if not isinstance(statuses, (list, tuple)): + return "" + for raw_status in statuses: + status_text = str(raw_status or "").strip() + if status_text: + return status_text + return "" + + +def _collect_invoice_status_texts(payload: dict[str, Any]) -> list[str]: + out: list[str] = [] + if not isinstance(payload, dict): + return out + statuses = payload.get("invoice_statuses", []) + if isinstance(statuses, list): + for raw in statuses: + text = str(raw or "").strip() + if text: + out.append(text) + for key in ("open_invoices", "closed_invoices"): + items = payload.get(key, []) + if not isinstance(items, (list, tuple)): + continue + for item in items: + if not isinstance(item, dict): + continue + for status_key in ( + "status", + "invoiceStatus", + "invoice_status", + "invoice_status_desc", + ): + text = str(item.get(status_key) or "").strip() + if text: + out.append(text) + break + return out + + +def _invoice_status_texts_from_payload( + payload: Any, + *, + invoice_id: Any = "", +) -> list[str]: + has_invoice_filter = bool(_invoice_identifier(invoice_id)) + item = _find_invoice_item_by_id(payload, invoice_id) + if item: + status = _first_invoice_status_from_items([item]) + return [status] if status else [] + if has_invoice_filter: + return [] + return _collect_invoice_status_texts(payload if isinstance(payload, dict) else {}) + + +def _has_open_bill_from_payload( + payload: Any, + *, + invoice_id: Any = "", + default: bool = False, +) -> bool: + item = _find_invoice_item_by_id(payload, invoice_id) + if item: + status = _first_invoice_status_from_items([item]) + return _is_invoice_unpaid_status(status) + if isinstance(payload, dict) and "has_open_bill" in payload: + return bool(payload.get("has_open_bill")) + return default + + +def _resolve_refund_and_resolution_decision( + *, + payment_method: str, + has_open_bill: bool, + invoice_status_texts: list[str], +) -> dict[str, Any]: + is_dacc = _is_dacc_cartao_parcelamento(payment_method) + is_contested = any(_is_invoice_contested_status(s) for s in invoice_status_texts) + is_paid = any(_is_invoice_paid_status(s) for s in invoice_status_texts) and not has_open_bill + is_unpaid = has_open_bill or any(_is_invoice_unpaid_status(s) for s in invoice_status_texts) + + should_generate_boleto = is_unpaid and not is_dacc and not is_contested + + refund_option = "1" if should_generate_boleto else "0" + format_text = "sms" if should_generate_boleto else "conta_futura" + resolution_type = "new_boleto" if should_generate_boleto else "credit_bill" + decision_reason = "credito_conta_futura" + if should_generate_boleto: + decision_reason = "fatura_nao_paga" + elif is_dacc: + decision_reason = "forma_pagamento_dacc_cartao_parcelamento" + elif is_contested: + decision_reason = "fatura_ja_contestada" + elif is_paid: + decision_reason = "fatura_paga" + + logger.info( + "contestacao.decisao_refund_decision " + "payment_method=%s has_open_bill=%s is_dacc=%s is_contested=%s is_paid=%s is_unpaid=%s " + "refund_option=%s format_text=%s resolution_type=%s decision_reason=%s", + payment_method, + has_open_bill, + is_dacc, + is_contested, + is_paid, + is_unpaid, + "1" if should_generate_boleto else "0", + "sms" if should_generate_boleto else "conta_futura", + "new_boleto" if should_generate_boleto else "credit_bill", + decision_reason, + ) + + return { + "refund_option": refund_option, + "format_text": format_text, + "resolution_type": resolution_type, + "decision_reason": decision_reason, + "is_dacc": is_dacc, + "is_paid": is_paid, + "is_unpaid": is_unpaid, + "is_contested": is_contested, + } + + +def _resolve_contestation_due_date( + state: dict[str, Any], + params: dict[str, Any], +) -> str: + raw_due_date = _first_text_from_params_or_state( + state, + params, + "current_invoice_due_date", + "currentInvoiceDueDate", + "CurrentInvoiceDueDate", + "invoice_due_date", + "invoiceDueDate", + "InvoiceDueDate", + ) + if raw_due_date: + return _normalize_due_date_yyyymmdd(raw_due_date) + + vars_state = state.get("vars", {}) if isinstance(state, dict) else {} + invoice_id = _first_text_from_params_or_state( + state, + params, + "invoice_id", + "invoiceId", + "current_invoice_number", + "currentInvoiceNumber", + "invoice_number", + "invoiceNumber", + ) + has_invoice_filter = bool(_invoice_identifier(invoice_id)) + profile_payload = ( + vars_state.get("consultar_perfil_fatura", {}) + if isinstance(vars_state, dict) + else {} + ) + if isinstance(profile_payload, dict): + profile_due_date_by_id = _first_invoice_text_from_payload_by_id( + profile_payload, + invoice_id, + "dueDate", + "due_date", + ) + if profile_due_date_by_id: + return _normalize_due_date_yyyymmdd(profile_due_date_by_id) + + profile_due_date = str( + profile_payload.get("due_date") + or profile_payload.get("dueDate") + or "" + ).strip() + if profile_due_date and not has_invoice_filter: + return _normalize_due_date_yyyymmdd(profile_due_date) + + check_payload = ( + vars_state.get("check_invoice_status", {}) + if isinstance(vars_state, dict) + else {} + ) + if not isinstance(check_payload, dict): + return "" + + check_due_date_by_id = _first_invoice_text_from_payload_by_id( + check_payload, + invoice_id, + "dueDate", + "due_date", + ) + if check_due_date_by_id: + return _normalize_due_date_yyyymmdd(check_due_date_by_id) + + raw_due_date = str( + check_payload.get("due_date") or check_payload.get("dueDate") or "" + ).strip() + if raw_due_date and not has_invoice_filter: + return _normalize_due_date_yyyymmdd(raw_due_date) + + if has_invoice_filter: + return "" + + for invoice_key in ("open_invoices", "closed_invoices"): + invoices = check_payload.get(invoice_key, []) + if not isinstance(invoices, list): + continue + for invoice in invoices: + if not isinstance(invoice, dict): + continue + candidate = str( + invoice.get("dueDate") + or invoice.get("due_date") + or "" + ).strip() + if candidate: + return _normalize_due_date_yyyymmdd(candidate) + + return "" + + +def _extrair_dia_vencimento(due_date: str) -> str: + """Extrai o dia de vencimento e retorna o valor válido mais próximo. + + Aceita formatos YYYYMMDD, YYYY-MM-DD, DD/MM/YYYY. + Retorna string com zero-fill: "01", "07", "10", "12", "15", "20", "25", "30". + """ + text = str(due_date).strip() + if not text: + return "01" + digits = re.sub(r"\D", "", text) + day: int | None = None + if len(digits) == 8: + if "/" in text or (len(text) >= 3 and text[2] in "/-"): + # DD/MM/YYYY ou DD-MM-YYYY + day = int(digits[:2]) + else: + # YYYYMMDD + day = int(digits[6:8]) + elif len(digits) >= 2: + day = int(digits[-2:]) + if day is None or day < 1 or day > 31: + return "01" + closest = min(_VENCIMENTOS_CONTA_CERTA, key=lambda v: abs(v - day)) + return f"{closest:02d}" + + +def _is_plano_familia(value: Any) -> bool: + if isinstance(value, str): + normalized = _normalize_service_name(value) + return "familia" in normalized or "family" in normalized + if isinstance(value, dict): + for key in ("is_family", "isFamily", "familia", "family"): + if key in value and _normalize_bool(value.get(key), default=False): + return True + text = _text_from_keys( + value, + ( + "desc", + "name", + "item_name", + "nome_plano", + "plan_name", + "planName", + "offer_name", + "offerName", + "commercialName", + "productName", + ), + ) + normalized = _normalize_service_name(text) + return "familia" in normalized or "family" in normalized + + +def _iter_plan_candidates(source: Any) -> list[Any]: + if not isinstance(source, dict): + return [] + candidates: list[Any] = [] + for key in ( + "planos", + "plans", + "plan", + "plano", + "nome_plano", + "nomePlano", + "plan_name", + "planName", + "offer", + "oferta", + "offers", + "ofertas", + "product", + "products", + "mainProduct", + "service", + "services", + ): + value = source.get(key) + if isinstance(value, list): + candidates.extend(value) + elif value is not None: + candidates.append(value) + return candidates + + +def _has_plano_familia(*sources: Any) -> bool: + for source in sources: + for candidate in _iter_plan_candidates(source): + if _is_plano_familia(candidate): + return True + if _is_plano_familia(source): + return True + return False + + +def _is_dependent_access_value(value: Any) -> bool: + if isinstance(value, bool): + return value + text = _normalize_service_name(str(value or "")) + return "depend" in text + + +def _has_dependent_access(*sources: Any) -> bool: + for source in sources: + if not isinstance(source, dict): + continue + for key in ( + "dependent", + "dependente", + "is_dependent", + "isDependent", + "tipo_acesso", + "tipoAcesso", + "access_type", + "accessType", + "line_role", + "lineRole", + "role", + "holder_type", + "holderType", + "subscriber_type", + "subscriberType", + ): + if key in source and _is_dependent_access_value(source.get(key)): + return True + for candidate in _iter_plan_candidates(source): + if isinstance(candidate, dict) and _has_dependent_access(candidate): + return True + return False + + +def _is_plano_familia_dependent_access( + state: dict[str, Any], + params: dict[str, Any], + contract_payload: dict[str, Any] | None = None, +) -> bool: + input_state = state.get("input", {}) if isinstance(state, dict) else {} + sources = [ + params if isinstance(params, dict) else {}, + input_state if isinstance(input_state, dict) else {}, + ] + if isinstance(contract_payload, dict): + sources.append(contract_payload) + contract = contract_payload.get("contract") + if isinstance(contract, dict): + sources.append(contract) + billing_profile = contract_payload.get("billing_profile") + if isinstance(billing_profile, dict): + sources.append(billing_profile) + return _has_plano_familia(*sources) and _has_dependent_access(*sources) + + +def _resolve_manual_conta_certa_indicator_default( + state: dict[str, Any], + params: dict[str, Any], + runtime: WorkflowRuntimeContext, + *, + msisdn: str, +) -> bool: + invoice_id = _first_text_from_params_or_state( + state, + params, + "invoice_id", + "invoiceId", + "current_invoice_number", + "currentInvoiceNumber", + "invoice_number", + "invoiceNumber", + ) + + if _is_plano_familia_dependent_access(state, params): + return True + + vars_state = state.get("vars", {}) if isinstance(state, dict) else {} + if isinstance(vars_state, dict): + check_invoice_payload = vars_state.get("check_invoice_status", {}) + if isinstance(check_invoice_payload, dict): + invoice_item = ( + _find_invoice_item_by_id(check_invoice_payload, invoice_id) + if invoice_id + else None + ) + if isinstance(invoice_item, dict): + for key in ("cutDate", "cutoffDate"): + parsed_cut_date = _parse_tracking_date(invoice_item.get(key)) + if parsed_cut_date is not None: + return _resolve_next_cutoff_from_cut_date( + parsed_cut_date + )[0] + parsed_cut_day = _parse_tracking_day(invoice_item.get(key)) + if parsed_cut_day is not None: + return _resolve_billing_cutoff_reference(parsed_cut_day)[0] + + cutoff_payload = vars_state.get("consultar_contrato_corte", {}) + if isinstance(cutoff_payload, dict): + if _normalize_bool( + cutoff_payload.get("plano_familia_acesso_dependente"), + default=False, + ): + return True + data_corte_flag = str(cutoff_payload.get("data_corte_flag") or "").strip() + if data_corte_flag == "0": + return True + if data_corte_flag == "1": + return False + + try: + dia_corte = int(cutoff_payload.get("dia_corte") or 0) + except (TypeError, ValueError): + dia_corte = 0 + if dia_corte > 0: + return _resolve_billing_cutoff_reference(dia_corte)[0] + + if not msisdn: + return False + + result = runtime.factory.create_contrato(msisdn=msisdn).execute() + if _result_failed_or_missing_data(result, state=state): + return False + payload = _to_dict(result.data) + if _is_plano_familia_dependent_access(state, params, payload): + return True + billing = payload.get("billing_profile", {}) if isinstance(payload, dict) else {} + if not isinstance(billing, dict): + billing = {} + date_info = billing.get("date", {}) + if not isinstance(date_info, dict): + date_info = {} + try: + dia_corte = int(date_info.get("cutoffDay") or 0) + except (TypeError, ValueError): + dia_corte = 0 + if dia_corte <= 0: + return False + return _resolve_billing_cutoff_reference(dia_corte)[0] + + +def _resolve_contestation_request_rules( + state: dict[str, Any], + params: dict[str, Any], + runtime: WorkflowRuntimeContext, + *, + msisdn: str, + invoice_number: str, + invoice_payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Resolve campos de negócio usados no abrir_contestacao_cliente.""" + vars_state = state.get("vars", {}) if isinstance(state, dict) else {} + items = _build_contestation_items(state, params) + if not items: + return { + "validation_error": "items obrigatório para abrir contestação", + "validation_log": [], + "item_validation_skipped": False, + "items": [], + "customer_status": "1", + "invoice_status": "0", + "invoice_due_date": "", + "invoice_amount_open": "0", + "invoice_amount": "0", + "refund_option": "0", + "manual_conta_certa_indicator": False, + } + + claimed_total = Decimal("0") + validated_total = Decimal("0") + for item in items: + claimed_amount = _normalize_number_text(item.get("claimed_amount", "0")) + validated_amount = _normalize_number_text(item.get("validated_amount", "0")) + claimed_total += Decimal(claimed_amount) + validated_total += Decimal(validated_amount) + logger.info( + "contestacao.item_values_sum msisdn=%s total_claimed=%s total_validated=%s itens=%s", + msisdn, + format(claimed_total, "f"), + format(validated_total, "f"), + [ + { + "item_name": item.get("item_name", ""), + "claimed_amount": _normalize_number_text( + item.get("claimed_amount", "") + ), + "validated_amount": _normalize_number_text( + item.get("validated_amount", "") + ), + } + for item in items + ], + ) + + customer_status = _first_text_from_params_or_state( + state, + params, + "customer_status", + "customerStatus", + ) + if not customer_status: + contract = _first_value_from_params_or_state(state, params, "contract") + if isinstance(contract, dict): + customer_status = str(contract.get("msisdn_status", "")).strip() + customer_status = _map_customer_status_code(customer_status) or "1" + + invoice_status = _first_text_from_params_or_state( + state, + params, + "invoice_status", + "invoiceStatus", + ) + if not invoice_status and isinstance(vars_state, dict): + invoice_status = _first_invoice_status_from_payload( + vars_state.get("check_invoice_status", {}), + invoice_id=invoice_number, + ) + if not invoice_status: + return { + "validation_error": "invoiceStatus da fatura obrigatório para abrir contestação", + "validation_log": [], + "item_validation_skipped": False, + "items": items, + "customer_status": customer_status, + "invoice_status": "0", + "invoice_due_date": "", + "invoice_amount_open": format(claimed_total, "f"), + "invoice_amount": format(validated_total, "f"), + "refund_option": "0", + "manual_conta_certa_indicator": False, + } + refund_option = _first_text_from_params_or_state( + state, + params, + "refund_option", + "refundOption", + ) + if not refund_option: + has_open_bill = False + payment_method = "" + invoice_status_texts: list[str] = [] + if isinstance(vars_state, dict): + perfil_payload = vars_state.get("consultar_perfil_fatura", {}) + if isinstance(perfil_payload, dict): + has_open_bill = _has_open_bill_from_payload( + perfil_payload, + invoice_id=invoice_number, + default=has_open_bill, + ) + payment_method = str( + perfil_payload.get("method_payment") + or perfil_payload.get("forma_pagamento") + or "" + ).strip() + invoice_status_texts.extend( + _invoice_status_texts_from_payload( + perfil_payload, + invoice_id=invoice_number, + ) + ) + check_payload = vars_state.get("check_invoice_status", {}) + if isinstance(check_payload, dict): + has_open_bill = _has_open_bill_from_payload( + check_payload, + invoice_id=invoice_number, + default=has_open_bill, + ) + invoice_status_texts.extend( + _invoice_status_texts_from_payload( + check_payload, + invoice_id=invoice_number, + ) + ) + decision = _resolve_refund_and_resolution_decision( + payment_method=payment_method, + has_open_bill=has_open_bill, + invoice_status_texts=invoice_status_texts, + ) + refund_option = str(decision["refund_option"]) + + invoice_due_date = _resolve_contestation_due_date(state, params) + + raw_invoice_amount_open = _first_text_from_params_or_state( + state, + params, + "invoice_amount_open", + "invoiceAmountOpen", + ) + raw_invoice_amount = _first_text_from_params_or_state( + state, + params, + "invoice_amount", + "invoiceAmount", + "valor", + "amount", + ) + + claimed_total_str = format(claimed_total, "f") + validated_total_str = format(validated_total, "f") + + if _is_missing_amount_value(raw_invoice_amount_open): + invoice_amount_open = claimed_total_str + else: + invoice_amount_open = _normalize_number_text( + raw_invoice_amount_open, + default=claimed_total_str, + ) + + if _is_missing_amount_value(raw_invoice_amount): + invoice_amount = validated_total_str + else: + invoice_amount = _normalize_number_text( + raw_invoice_amount, + default=validated_total_str, + ) + + resolved_manual_conta_certa_indicator = _resolve_manual_conta_certa_indicator_default( + state, + params, + runtime, + msisdn=msisdn, + ) + raw_manual_indicator = _first_value_from_params_or_state( + state, + params, + "manual_conta_certa_indicator", + "manualContaCertaIndicator", + ) + manual_conta_certa_indicator = ( + resolved_manual_conta_certa_indicator + or _normalize_bool(raw_manual_indicator, default=False) + ) + + validation_log: list[dict[str, Any]] = [] + item_validation_skipped = _should_skip_contestation_item_validation( + state, + params, + items, + ) + if item_validation_skipped: + validation_log = [] + else: + resolved_invoice_payload = invoice_payload if isinstance(invoice_payload, dict) else {} + items, validation_log, validation_error = validate_contestation_items( + items=items, + invoice_payload=resolved_invoice_payload, + ) + if validation_error: + return { + "validation_error": validation_error, + "validation_log": validation_log, + "item_validation_skipped": item_validation_skipped, + "items": items, + "customer_status": customer_status, + "invoice_status": invoice_status, + "invoice_due_date": invoice_due_date, + "invoice_amount_open": invoice_amount_open, + "invoice_amount": invoice_amount, + "refund_option": refund_option, + "manual_conta_certa_indicator": manual_conta_certa_indicator, + } + + return { + "validation_error": "", + "validation_log": validation_log, + "item_validation_skipped": item_validation_skipped, + "items": items, + "customer_status": customer_status, + "invoice_status": invoice_status, + "invoice_due_date": invoice_due_date, + "invoice_amount_open": invoice_amount_open, + "invoice_amount": invoice_amount, + "refund_option": refund_option, + "manual_conta_certa_indicator": manual_conta_certa_indicator, + } + + +def _should_skip_contestation_item_validation( + state: dict[str, Any], + params: dict[str, Any], + items: list[dict[str, Any]], +) -> bool: + if _to_bool( + _first_value_from_params_or_state( + state, + params, + "skip_invoice_item_validation", + "skipInvoiceItemValidation", + ) + ): + return True + + scenario = _first_text_from_params_or_state( + state, + params, + "tipo_atendimento", + "scenario", + ) + scenario_key = _normalize_service_name(scenario).replace(" ", "_") + if scenario_key == "pro_rata": + return True + + return bool(items) and all( + str(item.get("item_type", "")).strip().upper() == "PRO_RATA" + for item in items + ) + +def _build_contestation_items( + state: dict[str, Any], + params: dict[str, Any], +) -> list[dict[str, Any]]: + def _resolve_amount_with_fallback( + primary: Any, + fallback: Any, + ) -> str: + if _is_missing_amount_value(primary) and not _is_missing_amount_value(fallback): + return _normalize_number_text(fallback, default="0") + if _is_missing_amount_value(primary): + return "0" + return _normalize_number_text(primary, default="0") + + raw_items = _first_value_from_params_or_state( + state, + params, + "items", + ) + items: list[dict[str, Any]] = [] + if isinstance(raw_items, list): + + def _first_present_amount(raw_item: dict[str, Any], *keys: str) -> Any: + for key in keys: + value = raw_item.get(key) + if not _is_missing_amount_value(value): + return value + return None + + for raw_item in raw_items: + if not isinstance(raw_item, dict): + continue + item_name = str( + raw_item.get("item_name") + or raw_item.get("itemName") + or raw_item.get("name") + or raw_item.get("servico") + or raw_item.get("service_name") + or "" + ).strip() + if not item_name: + continue + item_type = str( + raw_item.get("item_type") + or raw_item.get("itemType") + or raw_item.get("type") + or "VAS_AVULSO" + ).strip() or "VAS_AVULSO" + claimed_raw = _first_present_amount( + raw_item, + "claimed_amount", + "claimedAmount", + ) + validated_raw = _first_present_amount( + raw_item, + "validated_amount", + "validatedAmount", + ) + claimed_amount = _resolve_amount_with_fallback( + claimed_raw, + validated_raw, + ) + validated_amount = _resolve_amount_with_fallback( + validated_raw, + claimed_raw, + ) + items.append( + { + "item_name": item_name, + "item_type": item_type, + "claimed_amount": claimed_amount, + "validated_amount": validated_amount, + } + ) + + if items: + return items + + servico = _first_text_from_params_or_state( + state, + params, + "servico", + "service_name", + "item_name", + "itemName", + "name", + ) + valor = _first_text_from_params_or_state( + state, + params, + "valor", + "amount", + "price", + "claimed_amount", + "claimedAmount", + "validated_amount", + "validatedAmount", + ) + if not servico: + return [] + normalized_value = _normalize_number_text(valor, default="0") + return [ + { + "item_name": servico, + "item_type": "VAS_AVULSO", + "claimed_amount": normalized_value, + "validated_amount": normalized_value, + } + ] + + +__all__ = [ + '_first_invoice_status_from_items', + '_invoice_identifier', + '_invoice_item_matches_id', + '_invoice_items_from_payload', + '_find_invoice_item_by_id', + '_to_tracking_datetime', + '_first_invoice_date_from_items', + '_first_invoice_date_from_payload_by_id', + '_first_invoice_text_from_payload_by_id', + '_normalize_due_date_yyyymmdd', + '_is_dacc_cartao_parcelamento', + '_is_invoice_paid_status', + '_is_invoice_unpaid_status', + '_is_invoice_contested_status', + '_map_invoice_status_to_state', + '_map_invoice_status_code', + '_first_invoice_status_from_payload', + '_collect_invoice_status_texts', + '_invoice_status_texts_from_payload', + '_has_open_bill_from_payload', + '_resolve_refund_and_resolution_decision', + '_resolve_contestation_due_date', + '_extrair_dia_vencimento', + '_is_plano_familia', + '_iter_plan_candidates', + '_has_plano_familia', + '_is_dependent_access_value', + '_has_dependent_access', + '_is_plano_familia_dependent_access', + '_resolve_manual_conta_certa_indicator_default', + '_resolve_contestation_request_rules', + '_should_skip_contestation_item_validation', + '_build_contestation_items', +] diff --git a/legacy_reference/workflows/actions/discovery.py b/legacy_reference/workflows/actions/discovery.py new file mode 100644 index 0000000..0e7aafe --- /dev/null +++ b/legacy_reference/workflows/actions/discovery.py @@ -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) diff --git a/legacy_reference/workflows/actions/inputs.py b/legacy_reference/workflows/actions/inputs.py new file mode 100644 index 0000000..013d9ea --- /dev/null +++ b/legacy_reference/workflows/actions/inputs.py @@ -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 diff --git a/legacy_reference/workflows/actions/invoice_explanation/__init__.py b/legacy_reference/workflows/actions/invoice_explanation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/legacy_reference/workflows/actions/invoice_explanation/actions.py b/legacy_reference/workflows/actions/invoice_explanation/actions.py new file mode 100644 index 0000000..721e810 --- /dev/null +++ b/legacy_reference/workflows/actions/invoice_explanation/actions.py @@ -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', +] diff --git a/legacy_reference/workflows/actions/invoice_explanation/helpers.py b/legacy_reference/workflows/actions/invoice_explanation/helpers.py new file mode 100644 index 0000000..e46c156 --- /dev/null +++ b/legacy_reference/workflows/actions/invoice_explanation/helpers.py @@ -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', +] diff --git a/legacy_reference/workflows/actions/pro_rata/__init__.py b/legacy_reference/workflows/actions/pro_rata/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/legacy_reference/workflows/actions/pro_rata/actions.py b/legacy_reference/workflows/actions/pro_rata/actions.py new file mode 100644 index 0000000..944d2dc --- /dev/null +++ b/legacy_reference/workflows/actions/pro_rata/actions.py @@ -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', +] diff --git a/legacy_reference/workflows/actions/pro_rata/helpers.py b/legacy_reference/workflows/actions/pro_rata/helpers.py new file mode 100644 index 0000000..c0ea74b --- /dev/null +++ b/legacy_reference/workflows/actions/pro_rata/helpers.py @@ -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\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\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\d{2})/(?P\d{2})\s+a\s+" + r"(?P\d{2})/(?P\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', +] diff --git a/legacy_reference/workflows/actions/rag/__init__.py b/legacy_reference/workflows/actions/rag/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/legacy_reference/workflows/actions/rag/actions.py b/legacy_reference/workflows/actions/rag/actions.py new file mode 100644 index 0000000..1e9283d --- /dev/null +++ b/legacy_reference/workflows/actions/rag/actions.py @@ -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', +] diff --git a/legacy_reference/workflows/actions/rag/helpers.py b/legacy_reference/workflows/actions/rag/helpers.py new file mode 100644 index 0000000..24007a5 --- /dev/null +++ b/legacy_reference/workflows/actions/rag/helpers.py @@ -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', +] diff --git a/legacy_reference/workflows/actions/registry.py b/legacy_reference/workflows/actions/registry.py new file mode 100644 index 0000000..c22cc72 --- /dev/null +++ b/legacy_reference/workflows/actions/registry.py @@ -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 diff --git a/legacy_reference/workflows/actions/tim_actions.py b/legacy_reference/workflows/actions/tim_actions.py new file mode 100644 index 0000000..fae94d5 --- /dev/null +++ b/legacy_reference/workflows/actions/tim_actions.py @@ -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 diff --git a/legacy_reference/workflows/actions/vas_avulso/__init__.py b/legacy_reference/workflows/actions/vas_avulso/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/legacy_reference/workflows/actions/vas_avulso/actions.py b/legacy_reference/workflows/actions/vas_avulso/actions.py new file mode 100644 index 0000000..4df143c --- /dev/null +++ b/legacy_reference/workflows/actions/vas_avulso/actions.py @@ -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', +] diff --git a/legacy_reference/workflows/actions/vas_avulso/helpers.py b/legacy_reference/workflows/actions/vas_avulso/helpers.py new file mode 100644 index 0000000..76b6a96 --- /dev/null +++ b/legacy_reference/workflows/actions/vas_avulso/helpers.py @@ -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', +] diff --git a/legacy_reference/workflows/actions/vas_estrategico/__init__.py b/legacy_reference/workflows/actions/vas_estrategico/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/legacy_reference/workflows/actions/vas_estrategico/actions.py b/legacy_reference/workflows/actions/vas_estrategico/actions.py new file mode 100644 index 0000000..13f337f --- /dev/null +++ b/legacy_reference/workflows/actions/vas_estrategico/actions.py @@ -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', +] diff --git a/legacy_reference/workflows/actions/vas_estrategico/helpers.py b/legacy_reference/workflows/actions/vas_estrategico/helpers.py new file mode 100644 index 0000000..810cbb8 --- /dev/null +++ b/legacy_reference/workflows/actions/vas_estrategico/helpers.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import logging + +from datetime import ( + datetime, + timezone, +) +from typing import Any +from agente_contas_tim.constants.ic_tags_enum import VEBTag +from agente_contas_tim.integrations import agent_framework_bridge +from agente_contas_tim.observability import get_session_id +from agente_contas_tim.text_utils import vocalize_digits +from agente_contas_tim.workflows.actions.common.helpers import ( + _final_msisdn, + _format_list_pt_br, +) + +logger = logging.getLogger(__name__) + + +def _build_vas_lines_from_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + grouped: dict[str, dict[str, Any]] = {} + for item in items: + if not isinstance(item, dict): + continue + msisdn = str(item.get("msisdn", "")).strip() + item_type = str(item.get("type", "")).strip().lower() + name = str(item.get("name", "")).strip() + if not msisdn or not name or item_type not in {"bundle", "estrategico"}: + continue + current = grouped.setdefault( + msisdn, + { + "msisdn": msisdn, + "items": [], + "bundle_names": [], + "estrategico_names": [], + }, + ) + current["items"].append( + {"type": item_type, "msisdn": msisdn, "name": name} + ) + if item_type == "bundle": + current["bundle_names"].append(name) + else: + current["estrategico_names"].append(name) + return list(grouped.values()) + + +def _build_vas_initial_message( + lines: list[dict[str, Any]], +) -> str: + sections: list[str] = [] + estrategicos_mencionados: list[str] = [] + for line in lines: + if not isinstance(line, dict): + continue + msisdn = str(line.get("msisdn", "")).strip() + final_line = _final_msisdn(msisdn) + bundle_names = [ + str(name).strip() + for name in line.get("bundle_names", []) + if str(name).strip() + ] + estrategico_names = [ + str(name).strip() + for name in line.get("estrategico_names", []) + if str(name).strip() + ] + if bundle_names: + services_txt = _format_list_pt_br(bundle_names) + if len(bundle_names) == 1: + bundle_txt = ( + f"Na linha final {final_line}, o serviço {services_txt} " + "está incluído no plano e não gera cobrança adicional, além disso, não pode ser cancelado." + ) + else: + bundle_txt = ( + f"Na linha final {final_line}, os serviços {services_txt} " + "estão incluídos no plano e não geram cobrança adicional, além disso, não podem ser cancelados." + ) + # Linha só com bundle (sem estratégico): a explicação precisa convidar + # a resposta do cliente, igual ao estratégico, para o workflow pausar + # em "sanei sua dúvida?" e NÃO finalizar/registrar protocolo no mesmo + # turno. Em linha mista, a pergunta já vem na seção estratégica. + if not estrategico_names: + bundle_txt += " Com essa explicação, sanei sua dúvida?" + sections.append(bundle_txt) + if estrategico_names: + services_txt = _format_list_pt_br(estrategico_names) + estrategicos_mencionados.extend(estrategico_names) + if len(estrategico_names) == 1: + sections.append( + f"O {services_txt} só é ativado depois que você confirma a contratação. " + "No momento da contratação, é enviado um código de verificação para a sua linha, e a " + "ativação acontece somente depois dessa confirmação. Como essa " + "validação foi feita com sucesso, a cobrança é considerada válida e, por isso, " + "não conseguimos retirar ou ressarcir esse valor da fatura. Com essa explicação, sanei sua dúvida?" + ) + + else: + sections.append( + f"Os {services_txt} só são ativados depois que você confirma a contratação. " + "No momento da contratação, é enviado um código de verificação para a sua linha, e a " + "ativação acontece somente depois dessa confirmação. Como essa " + "validação foi feita com sucesso, a cobrança é considerada válida e, por isso, " + "não conseguimos retirar ou ressarcir esse valor da fatura. Com essa explicação, sanei sua dúvida?" + ) + + if not sections: + return ( + "Não encontrei itens válidos para a tratativa de VAS estratégico. " + "Poderia revisar os dados informados?" + ) + return " ".join(sections) + + +def _build_vas_accept_message(_lines: list[dict[str, Any]]) -> str: + # Após o cliente aceitar manter o serviço, o fluxo apenas registra + # o atendimento e devolve o controle ao agente roteador sem + # mensagem adicional ao cliente. + return "" + + +def _build_vas_bundle_close_message(lines: list[dict[str, Any]]) -> str: + """Mensagem de fechamento do fluxo de bundle, usada DEPOIS que o cliente + responde ao "sanei sua dúvida?". Reafirma de forma breve que o serviço é + incluso no plano e não cancelável, sem repetir a explicação completa nem a + pergunta (que já foram ditas na pausa). O sufixo de protocolo e o + encerramento são anexados por ``registrar_atendimento_vas_estrategico``.""" + names: list[str] = [] + for line in lines: + if not isinstance(line, dict): + continue + names.extend( + str(name).strip() + for name in line.get("bundle_names", []) + if str(name).strip() + ) + if not names: + return "" + services_txt = _format_list_pt_br(names) + if len(names) == 1: + return ( + f"Como expliquei, o serviço {services_txt} é incluso no seu plano e " + "não há cobrança a remover." + ) + return ( + f"Como expliquei, os serviços {services_txt} são inclusos no seu plano e " + "não há cobrança a remover." + ) + + +def _format_protocolo_suffix_vas( + protocolos_por_linha: list[dict[str, Any]], +) -> str: + """Formata o sufixo 'Seu número de protocolo é X.' para anexar à mensagem. + + O número é vocalizado (dígitos como palavra, letras isoladas) para que + o TTS leia cada caractere individualmente. + """ + protocolos = [ + str(p.get("protocolo_id", "")).strip() + for p in protocolos_por_linha + if isinstance(p, dict) and str(p.get("protocolo_id", "")).strip() + ] + if not protocolos: + return "" + spoken = [vocalize_digits(p) for p in protocolos] + if len(spoken) == 1: + return f"Seu número de protocolo é {spoken[0]}." + return f"Seus números de protocolo são {_format_list_pt_br(spoken)}." + + +def _is_vas_cancelamento_info_path( + state: dict[str, Any], + *, + mensagem_base: str, +) -> bool: + vars_state = state.get("vars", {}) if isinstance(state, dict) else {} + if not isinstance(vars_state, dict): + return False + explicar_payload = vars_state.get("explicar_cancelamento", {}) + if not isinstance(explicar_payload, dict): + return False + return ( + bool(mensagem_base) + and str(explicar_payload.get("mensagem", "") or "").strip() + == mensagem_base + ) + + +def _emit_veb_005_cancelamento_info( + *, + input_state: dict[str, Any], + msisdn: str, + mensagem_base: str, +) -> None: + try: + metadata: dict[str, Any] = { + "sessionId": str(get_session_id() or ""), + "tag": VEBTag.INFO_CANCELAMENTO, + "eventDate": int(datetime.now(timezone.utc).timestamp() * 1000), + "uraCallId": str(input_state.get("ura_call_id", "") or "").strip(), + "gsm": str(msisdn or "").strip(), + "agentId": "contas", + "channelId": str( + input_state.get("channel_id") + or input_state.get("channelId") + or "URA" + ).strip() + or "URA", + "llmResponse": mensagem_base, + } + 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 + customer_message = str( + input_state.get("customer_message") + or input_state.get("customerMessage") + or "" + ).strip() + if customer_message: + metadata["customerMessage"] = customer_message + agent_framework_bridge.event(VEBTag.INFO_CANCELAMENTO, metadata=metadata) + except Exception: + logger.debug("registrar_atendimento_vas_estrategico: falha ao emitir VEB.005", exc_info=True) + + +__all__ = [ + '_build_vas_lines_from_items', + '_build_vas_initial_message', + '_build_vas_accept_message', + '_format_protocolo_suffix_vas', + '_is_vas_cancelamento_info_path', + '_emit_veb_005_cancelamento_info', +] diff --git a/legacy_reference/workflows/compiler.py b/legacy_reference/workflows/compiler.py new file mode 100644 index 0000000..7ff0ab5 --- /dev/null +++ b/legacy_reference/workflows/compiler.py @@ -0,0 +1,880 @@ +from __future__ import annotations + +import functools +import logging +import os +from contextlib import nullcontext +from copy import deepcopy +from dataclasses import replace +from typing import Any, TypedDict + +from agente_contas_tim.observability import ( + emit_workflow_step, + langfuse_context_has_active_span, +) +from agente_contas_tim.workflows.actions.registry import ( + ActionRegistry, + WorkflowRuntimeContext, +) +from agente_contas_tim.workflows.conditions import ( + evaluate_condition, + resolve_value, +) +from agente_contas_tim.workflows.contracts import ( + EdgeDef, + NodeDef, + PauseDef, + WorkflowDef, +) +from agente_contas_tim.workflows.exceptions import ( + WorkflowConfigurationError, + WorkflowInputError, +) +from agente_contas_tim.workflows.runtime_types import ActionResult +from agente_contas_tim.workflows.templating import render_template + +logger = logging.getLogger(__name__) + +FINISH_NODE = "__workflow_finish__" + +_ACTION_LABELS: dict[str, str] = { + "consulta_vas": "Consulta VAS na linha", + "cancelar_vas_single": "Cancelamento do serviço", + "consultar_perfil_fatura": "Consulta tipo de fatura", + "check_invoice_status": "Check invoice status", + "enviar_sms": "Envio de SMS com boleto", + "atualizar_status_sr": "Atualização status SR", + "consultar_divergencia": "Consulta divergência", + "bloquear_vas": "Bloqueio de VAS", + "bloquear_vas_single": "Bloqueio de VAS", + "cancelar_vas": "Cancelamento de VAS", + "avaliar_proxima_acao": "Avaliação próxima ação", + "resolve_capability": "Resolução de capability", + "preparar_vas_estrategico": "Preparacao VAS estrategico", + "montar_explicacao_cancelamento_vas_estrategico": ( + "Explicacao cancelamento VAS estrategico" + ), + "registrar_atendimento_vas_estrategico": ( + "Registro de atendimento VAS estrategico" + ), +} + + +@functools.lru_cache(maxsize=1) +def _import_langfuse_get_client() -> Any: + from langfuse import get_client + return get_client + + +@functools.lru_cache(maxsize=1) +def _import_langfuse_callback_handler() -> Any: + from langfuse.langchain import CallbackHandler + return CallbackHandler + + +def _has_langfuse_credentials() -> bool: + return bool( + os.getenv("LANGFUSE_PUBLIC_KEY", "").strip() + and os.getenv("LANGFUSE_SECRET_KEY", "").strip() + ) + + +def _start_workflow_observation( + *, + name: str, + input: dict[str, Any] | None = None, + metadata: dict[str, Any] | None = None, + parent_span_id: str | None = None, +) -> Any: + if not _has_langfuse_credentials(): + return nullcontext(None) + try: + return _import_langfuse_get_client()().start_as_current_observation( + name=name, + as_type="span", + input=input, + metadata=metadata, + ) + except Exception: + logger.debug( + "langfuse.workflow_start_observation_failed name=%s", + name, + exc_info=True, + ) + return nullcontext(None) + + +def _update_workflow_observation( + observation: Any | None, + *, + output: dict[str, Any] | None = None, + level: str | None = None, + status_message: str | None = None, +) -> None: + if observation is None: + return + try: + kwargs: dict[str, Any] = {} + if output is not None: + kwargs["output"] = output + if level is not None: + kwargs["level"] = level + if status_message is not None: + kwargs["status_message"] = status_message + observation.update(**kwargs) + except Exception: + logger.debug("langfuse.workflow_update_failed", exc_info=True) + + +def _build_workflow_llm_callbacks( + *, + parent_span_id: str | None = None, +) -> tuple[Any, ...]: + if not _has_langfuse_credentials(): + return () + if not langfuse_context_has_active_span(): + return () + + try: + client = _import_langfuse_get_client()() + trace_id = str(client.get_current_trace_id() or "").strip() + if not trace_id: + return () + trace_context: dict[str, str] = {"trace_id": trace_id} + if parent_span_id: + trace_context["parent_span_id"] = parent_span_id + return (_import_langfuse_callback_handler()(trace_context=trace_context),) + except Exception: + logger.debug("langfuse.workflow_callback_handler_failed", exc_info=True) + return () + + +def _summarize_mapping_keys(value: Any) -> list[str]: + if not isinstance(value, dict): + return [] + return sorted(str(key) for key in value.keys()) + + +def _action_observation_input( + *, + params: dict[str, Any], +) -> dict[str, Any]: + return { + "input_keys": _summarize_mapping_keys(params), + } + + +def _action_observation_output( + *, + result: ActionResult, + next_target: str | None = None, + paused: bool = False, + pause_def: PauseDef | None = None, +) -> dict[str, Any]: + output: dict[str, Any] = { + "success": result.success, + "output_keys": _summarize_mapping_keys(result.output), + "metadata_keys": _summarize_mapping_keys(result.metadata), + } + if isinstance(result.output, dict): + human_validation_text = result.output.get("texto_validacao_humana") + if isinstance(human_validation_text, str) and human_validation_text.strip(): + output["texto_validacao_humana"] = human_validation_text.strip() + if result.error: + output["error"] = result.error + if next_target is not None: + output["next_target"] = next_target + if paused and pause_def is not None: + output["status"] = "WAITING_INPUT" + output["resume_from"] = pause_def.resume_from + output["expected_input_key"] = pause_def.expected_input.key + return output + + +def _workflow_metadata( + *, + workflow_name: str, + workflow_version: int, + node_id: str | None = None, + action_name: str | None = None, + label: str | None = None, + stage: str | None = None, +) -> dict[str, Any]: + metadata: dict[str, Any] = { + "workflow_name": workflow_name, + "workflow_version": str(workflow_version), + } + if node_id: + metadata["node_id"] = node_id + if action_name: + metadata["action"] = action_name + if label: + metadata["label"] = label + if stage: + metadata["stage"] = stage + return metadata + + +class WorkflowState(TypedDict, total=False): + input: dict[str, Any] + vars: dict[str, Any] + trace: list[dict[str, Any]] + status: str + current_node: str | None + last_node: str | None + pending_interrupt: dict[str, Any] | None + final_data: Any + final_error: str | None + final_metadata: dict[str, Any] + + +def compile_workflow( + definition: WorkflowDef, + *, + action_registry: ActionRegistry, + runtime: WorkflowRuntimeContext, + checkpointer: Any, +) -> Any: + try: + from langgraph.graph import END, START, StateGraph + from langgraph.types import Command + except ModuleNotFoundError as exc: # pragma: no cover - depende do ambiente + raise ModuleNotFoundError( + "langgraph nao esta instalado. Adicione a dependencia para usar workflows." + ) from exc + + nodes_by_id = {node.id: node for node in definition.nodes} + edges_by_source: dict[str, list[EdgeDef]] = {} + for edge in sorted(definition.edges, key=lambda item: item.priority): + edges_by_source.setdefault(edge.source, []).append(edge) + + builder = StateGraph(WorkflowState) + + def finish_node(state: WorkflowState) -> dict[str, Any]: + # Preserve the terminal state set by the last workflow step. + # Returning an empty payload here can drop status/final_data in some + # graph runtimes/checkpointer combinations, making the service read + # a FAILED default at the end. + return dict(state) + + builder.add_node(FINISH_NODE, finish_node) + builder.add_edge(FINISH_NODE, END) + + for node in definition.nodes: + builder.add_node( + node.id, + _make_action_node( + definition=definition, + node=node, + outgoing_edges=edges_by_source.get(node.id, []), + nodes_by_id=nodes_by_id, + action_registry=action_registry, + runtime=runtime, + command_type=Command, + ), + ) + if node.pause is not None and node.pause.enabled: + builder.add_node( + _pause_node_name(node.id), + _make_pause_node( + node=node, + command_type=Command, + ), + ) + + builder.add_edge(START, definition.start) + return builder.compile(checkpointer=checkpointer) + + +def build_initial_state( + definition: WorkflowDef, input_payload: dict[str, Any] +) -> WorkflowState: + return WorkflowState( + input=deepcopy(input_payload), + vars={}, + trace=[], + status="RUNNING", + current_node=definition.start, + last_node=None, + pending_interrupt=None, + final_data=None, + final_error=None, + final_metadata={ + "workflow": definition.name, + "version": definition.version, + }, + ) + + +def _make_action_node( + *, + definition: WorkflowDef, + node: NodeDef, + outgoing_edges: list[EdgeDef], + nodes_by_id: dict[str, NodeDef], + action_registry: ActionRegistry, + runtime: WorkflowRuntimeContext, + command_type: Any, +): + def action_node(state: WorkflowState) -> Any: + context = _build_context(state) + params = render_template(node.input, context) + + try: + handler = action_registry.get(node.action) + except ValueError as exc: + raise WorkflowConfigurationError(str(exc)) from exc + + action_label = _ACTION_LABELS.get( + node.action, node.action, + ) + logger.info( + "workflow.iniciou | %s", + action_label, + ) + step_metadata = _workflow_metadata( + workflow_name=definition.name, + workflow_version=definition.version, + node_id=node.id, + action_name=node.action, + label=action_label, + stage="step", + ) + with _start_workflow_observation( + name=f"workflow.step.{node.id}", + input=_action_observation_input(params=params), + metadata=step_metadata, + ) as step_observation: + emit_workflow_step({ + "stage": "step_started", + "step": node.id, + "label": action_label, + }) + step_parent_span_id = ( + str(getattr(step_observation, "id", "")).strip() or None + ) + step_runtime = replace( + runtime, + llm_callbacks=_build_workflow_llm_callbacks( + parent_span_id=step_parent_span_id, + ), + llm_metadata=step_metadata, + ) + try: + action_result = handler( + state, params, step_runtime, + ) + except Exception as exc: + _update_workflow_observation( + step_observation, + output={"error": str(exc)}, + level="ERROR", + status_message=str(exc), + ) + raise + updated_state = _apply_action_result( + state, node.id, node.action, + action_result, + ) + logger.info( + "workflow.finalizou | %s | success=%s", + action_label, action_result.success, + ) + emit_workflow_step({ + "stage": "step_completed", + "step": node.id, + "label": action_label, + "success": action_result.success, + }) + + if not action_result.success: + logger.error( + "workflow.step.failed workflow=%s version=%s node=%s " + "action=%s error=%s metadata=%s", + definition.name, + definition.version, + node.id, + node.action, + action_result.error, + action_result.metadata, + ) + updated_state["status"] = "FAILED" + updated_state["current_node"] = None + updated_state["final_data"] = None + updated_state["final_error"] = action_result.error + updated_state["pending_interrupt"] = None + updated_state["final_metadata"] = { + "workflow": definition.name, + "version": definition.version, + "failed_node": node.id, + **action_result.metadata, + } + _update_workflow_observation( + step_observation, + output=_action_observation_output( + result=action_result, + next_target=FINISH_NODE, + ), + level="ERROR", + status_message=action_result.error or "workflow step failed", + ) + return command_type(update=updated_state, goto=FINISH_NODE) + + if node.pause is not None and node.pause.enabled: + pause_state = _build_pause_state( + definition=definition, + state=updated_state, + node=node, + result=action_result, + parent_span_id=step_parent_span_id, + ) + if pause_state is not None: + _update_workflow_observation( + step_observation, + output=_action_observation_output( + result=action_result, + next_target=_pause_node_name(node.id), + paused=True, + pause_def=node.pause, + ), + ) + return command_type( + update=pause_state, + goto=_pause_node_name(node.id), + ) + + target = _resolve_next_target( + outgoing_edges, + updated_state, + workflow_name=definition.name, + workflow_version=definition.version, + parent_span_id=step_parent_span_id, + ) + if target is None or target == "END": + logger.info("workflow.end node=%s", node.id) + updated_state["status"] = "COMPLETED" + updated_state["current_node"] = None + updated_state["final_data"] = action_result.output + updated_state["final_error"] = None + updated_state["pending_interrupt"] = None + updated_state["final_metadata"] = { + "workflow": definition.name, + "version": definition.version, + "last_node": node.id, + **action_result.metadata, + } + _update_workflow_observation( + step_observation, + output=_action_observation_output( + result=action_result, + next_target="END", + ), + ) + return command_type(update=updated_state, goto=FINISH_NODE) + + if target not in nodes_by_id: + raise WorkflowConfigurationError( + f"Destino {target!r} nao encontrado para o node {node.id!r}" + ) + + updated_state["status"] = "RUNNING" + updated_state["current_node"] = target + updated_state["pending_interrupt"] = None + _update_workflow_observation( + step_observation, + output=_action_observation_output( + result=action_result, + next_target=target, + ), + ) + return command_type(update=updated_state, goto=target) + + return action_node + + +def _make_pause_node(*, node: NodeDef, command_type: Any): + pause_def = node.pause + assert pause_def is not None + + def pause_node(state: WorkflowState) -> Any: + try: + from langgraph.errors import GraphInterrupt + from langgraph.types import interrupt + except ModuleNotFoundError as exc: # pragma: no cover - depende do ambiente + raise ModuleNotFoundError( + "langgraph nao esta instalado. " + "Adicione a dependencia para usar workflows." + ) from exc + + pending = state.get("pending_interrupt") + if not isinstance(pending, dict): + raise WorkflowConfigurationError( + f"Node de pausa {node.id!r} sem pending_interrupt no estado" + ) + + graph_interrupt: GraphInterrupt | None = None + with _start_workflow_observation( + name="workflow.resume", + input={ + "node_id": node.id, + "expected_input_key": pause_def.expected_input.key, + "resume_from": pause_def.resume_from, + }, + metadata={ + "node_id": node.id, + "resume_from": pause_def.resume_from, + "stage": "resume", + }, + ) as resume_observation: + input_date: dict[str, Any] | None = None + try: + resume_payload = interrupt(pending["payload"]) + input_date = dict(state.get("input", {})) + resumed_input = _resume_input_dict( + resume_payload, + pause_def.expected_input.key, + ) + input_date.update(resumed_input) + _normalize_and_validate_input(input_date, pause_def) + except GraphInterrupt as exc: + graph_interrupt = exc + _update_workflow_observation( + resume_observation, + output={ + "status": "WAITING_INPUT", + "resume_from": pause_def.resume_from, + "expected_input_key": pause_def.expected_input.key, + }, + ) + except Exception as exc: + _update_workflow_observation( + resume_observation, + output={"error": str(exc)}, + level="ERROR", + status_message=str(exc), + ) + raise + + if graph_interrupt is None: + if input_date is None: + raise WorkflowConfigurationError( + f"Node de pausa {node.id!r} nao recebeu input de retomada" + ) + updated_state = _clone_state(state) + updated_state["input"] = input_date + updated_state["status"] = "RUNNING" + updated_state["pending_interrupt"] = None + updated_state["current_node"] = pause_def.resume_from + updated_state["trace"].append( + { + "node_id": node.id, + "action": "resume_input", + "success": True, + "error": None, + } + ) + + if pause_def.resume_from == "END": + updated_state["status"] = "COMPLETED" + updated_state["current_node"] = None + updated_state["final_data"] = updated_state.get("vars", {}).get(node.id) + updated_state["final_error"] = None + _update_workflow_observation( + resume_observation, + output={ + "resume_from": "END", + "normalized_input_keys": _summarize_mapping_keys(input_date), + }, + ) + return command_type(update=updated_state, goto=FINISH_NODE) + + _update_workflow_observation( + resume_observation, + output={ + "resume_from": pause_def.resume_from, + "normalized_input_keys": _summarize_mapping_keys(input_date), + }, + ) + return command_type(update=updated_state, goto=pause_def.resume_from) + + if graph_interrupt is not None: + raise graph_interrupt + raise WorkflowConfigurationError( + f"Node de pausa {node.id!r} finalizou sem estado valido de retomada" + ) + + return pause_node + + +def _apply_action_result( + state: WorkflowState, + node_id: str, + action_name: str, + result: ActionResult, +) -> WorkflowState: + updated_state = _clone_state(state) + vars_state = dict(updated_state.get("vars", {})) + vars_state[node_id] = deepcopy(result.output) + updated_state["vars"] = vars_state + + trace = list(updated_state.get("trace", [])) + trace.append( + { + "node_id": node_id, + "action": action_name, + "success": result.success, + "error": result.error, + } + ) + updated_state["trace"] = trace + updated_state["last_node"] = node_id + updated_state["current_node"] = node_id + return updated_state + + +def _build_pause_state( + *, + definition: WorkflowDef, + state: WorkflowState, + node: NodeDef, + result: ActionResult, + parent_span_id: str | None = None, +) -> WorkflowState | None: + pause_def = node.pause + if pause_def is None: + return None + + pause_context = _build_context(state, output=result.output) + if not evaluate_condition(pause_def.when, pause_context): + return None + + payload = resolve_value(pause_def.return_from, pause_context) + if payload is None: + raise WorkflowConfigurationError( + "pause.return_from=" + f"{pause_def.return_from!r} nao resolveu valor " + f"no node {node.id!r}" + ) + + updated_state = _clone_state(state) + updated_state["status"] = "WAITING_INPUT" + updated_state["current_node"] = node.id + updated_state["pending_interrupt"] = { + "node_id": node.id, + "payload": payload, + "resume_from": pause_def.resume_from, + "expected_input_key": pause_def.expected_input.key, + "allowed_values": list(pause_def.expected_input.allowed_values), + "normalize": pause_def.expected_input.normalize, + } + updated_state["final_metadata"] = { + "workflow": definition.name, + "version": definition.version, + "paused_at": node.id, + "resume_from": pause_def.resume_from, + "expected_input_key": pause_def.expected_input.key, + "allowed_values": list(pause_def.expected_input.allowed_values), + "normalize": pause_def.expected_input.normalize, + **result.metadata, + } + with _start_workflow_observation( + name="workflow.pause", + input={ + "node_id": node.id, + "resume_from": pause_def.resume_from, + "expected_input_key": pause_def.expected_input.key, + }, + metadata=_workflow_metadata( + workflow_name=definition.name, + workflow_version=definition.version, + node_id=node.id, + action_name=node.action, + stage="pause", + ), + parent_span_id=parent_span_id, + ) as pause_observation: + _update_workflow_observation( + pause_observation, + output={ + "resume_from": pause_def.resume_from, + "expected_input_key": pause_def.expected_input.key, + "allowed_values": list(pause_def.expected_input.allowed_values), + }, + ) + return updated_state + + +def _resolve_next_target( + outgoing_edges: list[EdgeDef], + state: WorkflowState, + *, + workflow_name: str = "", + workflow_version: int = 0, + parent_span_id: str | None = None, +) -> str | None: + context = _build_context(state) + current = state.get("current_node", "?") + + # Extrair info do perfil de fatura para logs descritivos + vars_state = context.get("vars", {}) + perfil = vars_state.get("consultar_perfil_fatura", {}) + forma_pagamento = "" + if isinstance(perfil, dict): + forma_pagamento = str( + perfil.get("forma_pagamento", "") + ).strip() + + for edge in outgoing_edges: + matched = evaluate_condition(edge.when, context) + if matched: + decision = "" + # Decisões de fatura + if ( + current == "consultar_perfil_fatura" + and forma_pagamento + ): + if edge.target == "registrar_protocolo": + decision = ( + f"Fatura tipo {forma_pagamento}" + " → crédito na próxima fatura" + ) + else: + decision = ( + f"Fatura tipo {forma_pagamento}" + " → verificando se está aberta" + ) + elif current == "check_invoice_status": + if edge.target == "enviar_sms": + decision = ( + "Fatura aberta" + " → enviando SMS com boleto" + ) + else: + decision = ( + "Fatura fechada" + " → crédito na próxima fatura" + ) + + if decision: + logger.info( + "workflow.decisao | %s", decision, + ) + emit_workflow_step({ + "stage": "decision", + "from": current, + "to": edge.target, + "label": decision, + }) + else: + logger.info( + "workflow.route | %s → %s", + current, edge.target, + ) + with _start_workflow_observation( + name="workflow.decision", + input={ + "from": current, + "to": edge.target, + }, + metadata=_workflow_metadata( + workflow_name=workflow_name or "workflow", + workflow_version=workflow_version or 0, + node_id=str(current), + stage="decision", + label=decision or f"{current} -> {edge.target}", + ), + parent_span_id=parent_span_id, + ) as decision_observation: + _update_workflow_observation( + decision_observation, + output={ + "matched": True, + "from": current, + "to": edge.target, + "label": decision or "", + }, + ) + return edge.target + return None + + +def _normalize_and_validate_input( + input_date: dict[str, Any], pause_def: PauseDef +) -> None: + expected = pause_def.expected_input + if expected.key not in input_date: + raise WorkflowInputError( + f"Input obrigatorio ausente para continuar fluxo: {expected.key}" + ) + + value = input_date[expected.key] + normalized = _normalize_input_value(value, expected.normalize) + input_date[expected.key] = normalized + + if expected.allowed_values: + allowed = { + str(_normalize_input_value(item, expected.normalize)) + for item in expected.allowed_values + } + if str(normalized) not in allowed: + raise WorkflowInputError( + f"Valor invalido para {expected.key}: {normalized!r}. " + f"Permitidos: {sorted(allowed)}" + ) + + +def _normalize_input_value(value: Any, normalize_mode: str | None) -> Any: + if not isinstance(value, str) or normalize_mode is None: + return value + if normalize_mode == "strip": + return value.strip() + if normalize_mode == "upper": + return value.upper() + if normalize_mode == "lower": + return value.lower() + if normalize_mode == "upper_strip": + return value.strip().upper() + if normalize_mode == "lower_strip": + return value.strip().lower() + return value + + +def _resume_input_dict(resume_payload: Any, expected_input_key: str) -> dict[str, Any]: + if isinstance(resume_payload, dict): + return dict(resume_payload) + return {expected_input_key: resume_payload} + + +def _build_context( + state: WorkflowState, + *, + output: dict[str, Any] | None = None, +) -> dict[str, Any]: + return { + "input": deepcopy(state.get("input", {})), + "vars": deepcopy(state.get("vars", {})), + "trace": deepcopy(state.get("trace", [])), + "status": state.get("status"), + "current_node": state.get("current_node"), + "last_node": state.get("last_node"), + "output": deepcopy(output or {}), + } + + +def _clone_state(state: WorkflowState) -> WorkflowState: + return WorkflowState( + input=deepcopy(state.get("input", {})), + vars=deepcopy(state.get("vars", {})), + trace=deepcopy(state.get("trace", [])), + status=str(state.get("status", "RUNNING")), + current_node=state.get("current_node"), + last_node=state.get("last_node"), + pending_interrupt=deepcopy(state.get("pending_interrupt")), + final_data=deepcopy(state.get("final_data")), + final_error=state.get("final_error"), + final_metadata=deepcopy(state.get("final_metadata", {})), + ) + + +def _pause_node_name(node_id: str) -> str: + return f"__pause__{node_id}" diff --git a/legacy_reference/workflows/conditions.py b/legacy_reference/workflows/conditions.py new file mode 100644 index 0000000..f924331 --- /dev/null +++ b/legacy_reference/workflows/conditions.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Any + +from agente_contas_tim.workflows.exceptions import WorkflowConfigurationError + + +def get_path_value(data: dict[str, Any], path: str) -> Any: + """Resolve paths simples no formato $.a.b.c.""" + if not path.startswith("$."): + return path + + current: Any = data + for part in path[2:].split("."): + if isinstance(current, dict): + current = current.get(part) + continue + return None + return current + + +def resolve_value(value: Any, context: dict[str, Any]) -> Any: + if isinstance(value, str) and value.startswith("$."): + return get_path_value(context, value) + return value + + +def evaluate_condition( + condition: dict[str, Any] | None, context: dict[str, Any] +) -> bool: + if condition is None: + return True + + if "eq" in condition: + left, right = condition["eq"] + return resolve_value(left, context) == resolve_value(right, context) + + if "neq" in condition: + left, right = condition["neq"] + return resolve_value(left, context) != resolve_value(right, context) + + if "all" in condition: + return all(evaluate_condition(item, context) for item in condition["all"]) + + if "any" in condition: + return any(evaluate_condition(item, context) for item in condition["any"]) + + if "exists" in condition: + return resolve_value(condition["exists"], context) is not None + + if "in" in condition: + left, right = condition["in"] + return resolve_value(left, context) in resolve_value(right, context) + + if "not_in" in condition: + left, right = condition["not_in"] + return resolve_value(left, context) not in resolve_value(right, context) + + raise WorkflowConfigurationError(f"Operador de condição não suportado: {condition}") diff --git a/legacy_reference/workflows/contracts.py b/legacy_reference/workflows/contracts.py new file mode 100644 index 0000000..46edec5 --- /dev/null +++ b/legacy_reference/workflows/contracts.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from agente_contas_tim.workflows.exceptions import WorkflowConfigurationError + +NormalizeMode = Literal[ + "upper", + "lower", + "strip", + "upper_strip", + "lower_strip", +] + + +class PauseExpectedInputDef(BaseModel): + key: str = Field(..., min_length=1) + allowed_values: list[str] = Field(default_factory=list) + normalize: NormalizeMode | None = None + + +class PauseDef(BaseModel): + enabled: bool = True + when: dict[str, Any] | None = None + return_from: str = Field(..., min_length=1) + expected_input: PauseExpectedInputDef + resume_from: str = Field(..., min_length=1) + + +class NodeDef(BaseModel): + id: str = Field(..., min_length=1) + action: str = Field(..., min_length=1) + input: dict[str, Any] = Field(default_factory=dict) + pause: PauseDef | None = None + + +class EdgeDef(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + source: str = Field(..., alias="from", min_length=1) + target: str = Field(..., alias="to", min_length=1) + when: dict[str, Any] | None = None + priority: int = 100 + + +class WorkflowDef(BaseModel): + name: str = Field(..., min_length=1) + version: int = Field(..., ge=1) + start: str = Field(..., min_length=1) + nodes: list[NodeDef] + edges: list[EdgeDef] + + @model_validator(mode="after") + def validate_graph(self) -> WorkflowDef: + node_ids = {node.id for node in self.nodes} + if self.start not in node_ids: + raise WorkflowConfigurationError( + f"start={self.start!r} não existe em nodes" + ) + + for edge in self.edges: + if edge.source not in node_ids: + raise WorkflowConfigurationError( + f"edge.from={edge.source!r} não existe em nodes" + ) + if edge.target != "END" and edge.target not in node_ids: + raise WorkflowConfigurationError( + f"edge.to={edge.target!r} não existe em nodes" + ) + + for node in self.nodes: + if node.pause is None: + continue + if ( + node.pause.resume_from != "END" + and node.pause.resume_from not in node_ids + ): + raise WorkflowConfigurationError( + f"pause.resume_from={node.pause.resume_from!r} não existe em nodes" + ) + + return self diff --git a/legacy_reference/workflows/conversational/contestacao_tool.active.yaml b/legacy_reference/workflows/conversational/contestacao_tool.active.yaml new file mode 100644 index 0000000..b825518 --- /dev/null +++ b/legacy_reference/workflows/conversational/contestacao_tool.active.yaml @@ -0,0 +1 @@ +version: 1 diff --git a/legacy_reference/workflows/conversational/contestacao_tool.v1.yaml b/legacy_reference/workflows/conversational/contestacao_tool.v1.yaml new file mode 100644 index 0000000..e3fb188 --- /dev/null +++ b/legacy_reference/workflows/conversational/contestacao_tool.v1.yaml @@ -0,0 +1,170 @@ +name: contestacao_tool +version: 1 +start: registrar_protocolo + +nodes: + # 1. Registrar protocolo — primeiro passo ao entrar em contestação + - id: registrar_protocolo + action: registrar_protocolo + input: + msisdn: $.input.msisdn + social_sec_no: $.input.social_sec_no + scenario: "contestacao" + request_status: "Aberto" + status: "OPENED" + + # 2. Consultar perfil de fatura (forma de pagamento) + - id: consultar_perfil_fatura + action: consultar_perfil_fatura + input: + msisdn: $.input.msisdn + + # 3. Enviar SMS com código do boleto + - id: enviar_sms + action: enviar_sms + input: + msisdn: $.input.msisdn + message: $.input.mensagem_sms + + # 3.1 Consultar status da fatura (CompleteInvoices) + - id: check_invoice_status + action: check_invoice_status + input: + msisdn: $.input.msisdn + invoice_id: $.input.invoice_id + current_invoice_number: $.input.current_invoice_number + + # 4. Abrir contestação no serviço dedicado + - id: abrir_contestacao_cliente + action: abrir_contestacao_cliente + input: + msisdn: $.input.msisdn + social_sec_no: $.input.social_sec_no + customer_id: $.input.customer_id + current_invoice_number: $.input.current_invoice_number + invoice_id: $.input.invoice_id + current_invoice_due_date: $.input.current_invoice_due_date + servico: $.input.servico + valor: $.input.valor + descricao: $.input.descricao + items: $.input.items + protocolo_id: $.vars.registrar_protocolo.protocolo_id + customer_type: $.input.customer_type + customer_status: $.input.customer_status + invoice_status: $.vars.check_invoice_status.invoice_status + invoice_amount_open: $.input.invoice_amount_open + invoice_amount: $.input.invoice_amount + contestation_type: $.input.contestation_type + adjust_reason: $.input.adjust_reason + refund_option: $.input.refund_option + manual_conta_certa_indicator: $.input.manual_conta_certa_indicator + double_refund: $.input.double_refund + user_id: $.input.user_id + message_id: $.input.message_id + client_id: $.input.client_id + tipo_atendimento: $.input.tipo_atendimento + skip_invoice_item_validation: $.input.skip_invoice_item_validation + + # 5. Consultar contrato para verificar data de corte + - id: consultar_contrato_corte + action: consultar_contrato_corte + input: + msisdn: $.input.msisdn + current_invoice_due_date: $.input.current_invoice_due_date + + # 6. Abrir SR de Conta Certa Manual (quando após data de corte) + - id: abrir_sr_conta_certa_manual + action: abrir_sr_conta_certa_manual + input: + msisdn: $.input.msisdn + social_sec_no: $.input.social_sec_no + dia_vencimento: $.vars.consultar_contrato_corte.dia_vencimento + request_status: "Encaminhado" + status: "Encaminhado" + + # 7. Atualizar status do protocolo principal — último passo + - id: atualizar_status_sr + action: atualizar_status_sr + input: + msisdn: $.input.msisdn + social_sec_no: $.input.social_sec_no + protocolo_id: $.vars.registrar_protocolo.protocolo_id + dia_vencimento: $.vars.abrir_sr_conta_certa_manual.dia_vencimento + scenario: "contestacao" + channel: "AIAGENTCR" + status: "Fechado" + client_id: "AIAGENTCR" + + # 8. Atualiza status do protocolo principal após Conta Certa Manual + - id: atualizar_status_sr_registro + action: atualizar_status_sr + input: + msisdn: $.input.msisdn + social_sec_no: $.input.social_sec_no + protocolo_id: $.vars.abrir_sr_conta_certa_manual.sr_conta_certa_id + scenario: "conta_certa_manual" + status: "Encaminhado" + client_id: "AIAGENTCR" + +edges: + # Protocolo registrado → consultar perfil de fatura + - from: registrar_protocolo + to: consultar_perfil_fatura + + # Consultou perfil → consulta status da fatura (CompleteInvoices) + - from: consultar_perfil_fatura + to: check_invoice_status + priority: 10 + + # Todas as formas → abrir contestação + - from: check_invoice_status + to: abrir_contestacao_cliente + priority: 10 + + # Após contestação: quando necessário, enviar SMS + - from: abrir_contestacao_cliente + to: enviar_sms + priority: 10 + when: + all: + - exists: $.vars.abrir_contestacao_cliente.barcode + - neq: [$.vars.abrir_contestacao_cliente.barcode, ""] + + # Após contestação (sem SMS) → verificar data de corte + - from: abrir_contestacao_cliente + to: consultar_contrato_corte + priority: 99 + + # Após SMS → verificar data de corte + - from: enviar_sms + to: consultar_contrato_corte + + # Após data de corte → abrir SR Conta Certa Manual + - from: consultar_contrato_corte + to: abrir_sr_conta_certa_manual + priority: 10 + when: + all: + - eq: [$.vars.consultar_contrato_corte.apos_data_corte, true] + - eq: [$.vars.consultar_contrato_corte.contestation_success, true] + - any: + - eq: [$.input.manual_conta_certa_indicator, true] + - eq: [$.vars.abrir_contestacao_cliente.manual_conta_certa_indicator, true] + + # Antes da data de corte → ir direto para fechar protocolo + - from: consultar_contrato_corte + to: atualizar_status_sr + priority: 99 + + # Após SR manual → fechar protocolo + - from: abrir_sr_conta_certa_manual + to: atualizar_status_sr_registro + priority: 10 + + # Protocolo principal de contestação atualizado após Conta Certa Manual → END + - from: atualizar_status_sr_registro + to: END + + # Protocolo atualizado → END + - from: atualizar_status_sr + to: END diff --git a/legacy_reference/workflows/conversational/invoice_explanation.active.yaml b/legacy_reference/workflows/conversational/invoice_explanation.active.yaml new file mode 100644 index 0000000..22817d2 --- /dev/null +++ b/legacy_reference/workflows/conversational/invoice_explanation.active.yaml @@ -0,0 +1 @@ +version: 2 diff --git a/legacy_reference/workflows/conversational/invoice_explanation.v2.yaml b/legacy_reference/workflows/conversational/invoice_explanation.v2.yaml new file mode 100644 index 0000000..832d0f5 --- /dev/null +++ b/legacy_reference/workflows/conversational/invoice_explanation.v2.yaml @@ -0,0 +1,85 @@ +name: invoice_explanation +version: 2 +start: preparar + +nodes: + - id: preparar + action: preparar_invoice_explanation + input: + msisdn: $.input.msisdn + customer_id: $.input.customer_id + current_invoice_number: $.input.current_invoice_number + past_invoice_number: $.input.past_invoice_number + current_invoice_due_date: $.input.current_invoice_due_date + past_invoice_due_date: $.input.past_invoice_due_date + channel: $.input.channel + tentativa_anterior: $.vars.preparar.tentativa + invoice_explanation_base: $.input.invoice_explanation_base + + - id: formatar + action: formatar_invoice_explanation + input: + explicacao_base: $.vars.preparar.explicacao_base + invoice_detail: $.input.invoice_detail + pause: + enabled: true + when: + eq: [$.output.await_user_input, true] + return_from: $.output.mensagem + expected_input: + key: resposta_usuario + allowed_values: ["SIM", "NAO", "OUTRO"] + normalize: upper_strip + resume_from: decisao + + - id: decisao + action: no_op + input: {} + + - id: registrar_sim + action: registrar_atendimento_invoice_explanation + input: + resposta_usuario: SIM + ic: VEB.003 + + - id: registrar_protocolo_aceite + action: registrar_protocolo_inicio + input: + msisdn: $.input.msisdn + scenario: invoice_explanation_aceite_fechado + request_status: Fechado + status: CLOSED + + - id: registrar_nao + action: registrar_atendimento_invoice_explanation + input: + resposta_usuario: NAO + ic: VEB.004 + +edges: + - from: preparar + to: formatar + + - from: formatar + to: decisao + + - from: decisao + to: registrar_sim + priority: 10 + when: + eq: [$.input.resposta_usuario, SIM] + + - from: decisao + to: registrar_nao + priority: 20 + when: + eq: [$.input.resposta_usuario, NAO] + + - from: registrar_sim + to: registrar_protocolo_aceite + + - from: registrar_protocolo_aceite + to: END + + - from: registrar_nao + to: END diff --git a/legacy_reference/workflows/conversational/pro_rata.active.yaml b/legacy_reference/workflows/conversational/pro_rata.active.yaml new file mode 100644 index 0000000..0a70aff --- /dev/null +++ b/legacy_reference/workflows/conversational/pro_rata.active.yaml @@ -0,0 +1 @@ +version: 3 diff --git a/legacy_reference/workflows/conversational/pro_rata.v2.yaml b/legacy_reference/workflows/conversational/pro_rata.v2.yaml new file mode 100644 index 0000000..069779c --- /dev/null +++ b/legacy_reference/workflows/conversational/pro_rata.v2.yaml @@ -0,0 +1,210 @@ +name: pro_rata +version: 2 +start: preparar + +nodes: + - id: preparar + action: preparar_pro_rata + input: + msisdn: $.input.msisdn + planos: $.input.planos + has_plano_controle: $.input.has_plano_controle + + - id: formatar + action: formatar_pro_rata + input: + mensagem_base: $.vars.preparar.mensagem + pause: + enabled: true + when: + eq: [$.vars.preparar.await_user_input, true] + return_from: $.output.mensagem + expected_input: + key: resposta_usuario + allowed_values: ["SIM", "NAO", "OUTRO"] + normalize: upper_strip + resume_from: decisao_esclarecimento + + - id: decisao_esclarecimento + action: no_op + input: {} + + - id: ofertar_ajuste + action: formatar_pro_rata + input: + mensagem_base: $.vars.preparar.mensagem_oferta_ajuste + pause: + enabled: true + return_from: $.output.mensagem + expected_input: + key: resposta_usuario + allowed_values: ["SIM", "NAO", "OUTRO"] + normalize: upper_strip + resume_from: decisao_ajuste + + - id: decisao_ajuste + action: no_op + input: {} + + - id: reperguntar_esclarecimento + action: formatar_pro_rata + input: + mensagem_base: $.vars.preparar.mensagem_reperguntar_esclarecimento + pause: + enabled: true + return_from: $.output.mensagem + expected_input: + key: resposta_usuario + allowed_values: ["SIM", "NAO", "OUTRO"] + normalize: upper_strip + resume_from: decisao_esclarecimento + + - id: reperguntar_ajuste + action: formatar_pro_rata + input: + mensagem_base: $.vars.preparar.mensagem_reperguntar_ajuste + pause: + enabled: true + return_from: $.output.mensagem + expected_input: + key: resposta_usuario + allowed_values: ["SIM", "NAO", "OUTRO"] + normalize: upper_strip + resume_from: decisao_ajuste + + - id: registrar_nao_controle + action: registrar_atendimento_pro_rata + input: + msisdn: $.input.msisdn + mensagem_base: $.vars.formatar.mensagem + caminho: nao_controle + + - id: registrar_aceitou + action: registrar_atendimento_pro_rata + input: + msisdn: $.input.msisdn + mensagem_base: $.vars.preparar.mensagem_pos_aceite + caminho: aceitou + ic: VEB.003 + + - id: registrar_recusou_ajuste + action: registrar_atendimento_pro_rata + input: + msisdn: $.input.msisdn + social_sec_no: $.input.social_sec_no + mensagem_base: $.vars.preparar.mensagem_ajuste_recusado + caminho: recusou_ajuste + ic: VEB.003 + + - id: executar_contestacao + action: executar_contestacao_plano_controle + input: + msisdn: $.input.msisdn + social_sec_no: $.input.social_sec_no + customer_id: $.input.customer_id + current_invoice_number: $.input.current_invoice_number + current_invoice_due_date: $.input.current_invoice_due_date + customer_type: $.input.customer_type + customer_status: $.input.customer_status + invoice_status: $.input.invoice_status + adjust_reason: $.input.adjust_reason + refund_option: $.input.refund_option + manual_conta_certa_indicator: $.input.manual_conta_certa_indicator + items: $.vars.definir_devolucao.items + invoice_amount_open: $.vars.definir_devolucao.invoice_amount_open + invoice_amount: $.vars.definir_devolucao.invoice_amount + devolucao: $.vars.definir_devolucao + + - id: definir_devolucao + action: definir_devolucao_ajuste_pro_rata + input: + msisdn: $.input.msisdn + planos: $.input.planos + invoice_id: $.input.invoice_id + customer_id: $.input.customer_id + invoice_detail: $.input.invoice_detail + invoice_period: $.input.invoice_period + invoice_emissao: $.input.invoice_emissao + + - id: orientar_pagamento + action: orientar_pagamento_pro_rata + input: + msisdn: $.input.msisdn + devolucao: $.vars.executar_contestacao.devolucao + + - id: registrar_atendimento + action: registrar_atendimento_pro_rata + input: + msisdn: $.input.msisdn + social_sec_no: $.input.social_sec_no + mensagem_base: $.vars.orientar_pagamento.mensagem + devolucao: $.vars.orientar_pagamento.devolucao + caminho: nao_aceitou + ic: VEB.004 + +edges: + - from: preparar + to: formatar + + - from: formatar + to: decisao_esclarecimento + priority: 10 + when: + eq: [$.vars.preparar.await_user_input, true] + + - from: formatar + to: registrar_nao_controle + priority: 20 + + - from: decisao_esclarecimento + to: registrar_aceitou + priority: 10 + when: + eq: [$.input.resposta_usuario, SIM] + + - from: decisao_esclarecimento + to: ofertar_ajuste + priority: 20 + when: + eq: [$.input.resposta_usuario, NAO] + + - from: decisao_esclarecimento + to: reperguntar_esclarecimento + priority: 30 + + - from: decisao_ajuste + to: definir_devolucao + priority: 10 + when: + eq: [$.input.resposta_usuario, SIM] + + - from: decisao_ajuste + to: registrar_recusou_ajuste + priority: 20 + when: + eq: [$.input.resposta_usuario, NAO] + + - from: decisao_ajuste + to: reperguntar_ajuste + priority: 30 + + - from: definir_devolucao + to: executar_contestacao + + - from: executar_contestacao + to: orientar_pagamento + + - from: orientar_pagamento + to: registrar_atendimento + + - from: registrar_nao_controle + to: END + + - from: registrar_aceitou + to: END + + - from: registrar_recusou_ajuste + to: END + + - from: registrar_atendimento + to: END diff --git a/legacy_reference/workflows/conversational/pro_rata.v3.yaml b/legacy_reference/workflows/conversational/pro_rata.v3.yaml new file mode 100644 index 0000000..9cc11e7 --- /dev/null +++ b/legacy_reference/workflows/conversational/pro_rata.v3.yaml @@ -0,0 +1,104 @@ +name: pro_rata +version: 3 +start: preparar + +nodes: + - id: preparar + action: preparar_pro_rata + input: + msisdn: $.input.msisdn + planos: $.input.planos + has_plano_controle: $.input.has_plano_controle + + - id: formatar + action: formatar_pro_rata + input: + mensagem_base: $.vars.preparar.mensagem + pause: + enabled: true + when: + eq: [$.vars.preparar.await_user_input, true] + return_from: $.output.mensagem + expected_input: + key: resposta_usuario + allowed_values: ["SIM", "NAO", "OUTRO"] + normalize: upper_strip + resume_from: decisao_esclarecimento + + - id: decisao_esclarecimento + action: no_op + input: {} + + - id: devolver_orquestrador + action: no_op + input: {} + + - id: reperguntar_esclarecimento + action: formatar_pro_rata + input: + mensagem_base: $.vars.preparar.mensagem_reperguntar_esclarecimento + pause: + enabled: true + return_from: $.output.mensagem + expected_input: + key: resposta_usuario + allowed_values: ["SIM", "NAO", "OUTRO"] + normalize: upper_strip + resume_from: decisao_esclarecimento + + - id: registrar_nao_controle + action: registrar_atendimento_pro_rata + input: + msisdn: $.input.msisdn + mensagem_base: $.vars.formatar.mensagem + caminho: nao_controle + + - id: registrar_aceitou + action: registrar_atendimento_pro_rata + input: + msisdn: $.input.msisdn + mensagem_base: $.vars.preparar.mensagem_pos_aceite + caminho: aceitou + ic: VEB.003 + +edges: + - from: preparar + to: formatar + + - from: formatar + to: decisao_esclarecimento + priority: 10 + when: + eq: [$.vars.preparar.await_user_input, true] + + - from: formatar + to: registrar_nao_controle + priority: 20 + + - from: decisao_esclarecimento + to: registrar_aceitou + priority: 10 + when: + eq: [$.input.resposta_usuario, SIM] + + - from: decisao_esclarecimento + to: devolver_orquestrador + priority: 20 + when: + eq: [$.input.resposta_usuario, NAO] + + - from: decisao_esclarecimento + to: reperguntar_esclarecimento + priority: 30 + + - from: devolver_orquestrador + to: END + + - from: reperguntar_esclarecimento + to: decisao_esclarecimento + + - from: registrar_nao_controle + to: END + + - from: registrar_aceitou + to: END diff --git a/legacy_reference/workflows/conversational/vas_estrategico.active.yaml b/legacy_reference/workflows/conversational/vas_estrategico.active.yaml new file mode 100644 index 0000000..0a70aff --- /dev/null +++ b/legacy_reference/workflows/conversational/vas_estrategico.active.yaml @@ -0,0 +1 @@ +version: 3 diff --git a/legacy_reference/workflows/conversational/vas_estrategico.v3.yaml b/legacy_reference/workflows/conversational/vas_estrategico.v3.yaml new file mode 100644 index 0000000..4816ee3 --- /dev/null +++ b/legacy_reference/workflows/conversational/vas_estrategico.v3.yaml @@ -0,0 +1,132 @@ +name: vas_estrategico +version: 3 +start: preparar + +nodes: + - id: preparar + action: preparar_vas_estrategico + input: + items: $.input.items + linhas: $.input.linhas + pause: + enabled: true + when: + eq: [$.output.await_user_input, true] + return_from: $.output.mensagem + expected_input: + key: resposta_usuario + allowed_values: ["SIM", "NAO", "OUTRO"] + normalize: upper_strip + resume_from: decisao + + - id: resposta_bundle + action: montar_resposta_texto + input: + dados: + texto_usuario: $.vars.preparar.mensagem_bundle_fechamento + + - id: decisao + action: no_op + input: {} + + - id: resposta_sim + action: montar_resposta_texto + input: + dados: + texto_usuario: $.vars.preparar.mensagem_pos_aceite + + - id: explicar_cancelamento + action: montar_explicacao_cancelamento_vas_estrategico + input: + linhas: $.vars.preparar.linhas + orientacoes_cancelamento: $.input.orientacoes_cancelamento + + - id: registrar_sim + action: registrar_atendimento_vas_estrategico + input: + linhas: $.vars.preparar.linhas + mensagem_base: $.vars.resposta_sim.mensagem + request_status: "Fechado" + status: "CLOSED" + + - id: registrar_nao + action: registrar_atendimento_vas_estrategico + input: + linhas: $.vars.preparar.linhas + mensagem_base: $.vars.explicar_cancelamento.mensagem + request_status: "Fechado" + status: "CLOSED" + + - id: registrar_bundle + action: registrar_atendimento_vas_estrategico + input: + linhas: $.vars.preparar.linhas + mensagem_base: $.vars.resposta_bundle.mensagem + request_status: "Fechado" + status: "CLOSED" + + - id: registrar_outro + action: registrar_atendimento_vas_estrategico + input: + linhas: $.vars.preparar.linhas + mensagem_base: "Atendimento encerrado sem confirmação de manutenção do serviço." + request_status: "Fechado" + status: "CLOSED" + +edges: + - from: preparar + to: decisao + priority: 10 + when: + eq: [$.vars.preparar.await_user_input, true] + + - from: preparar + to: resposta_bundle + priority: 20 + + # Bundle puro (sem item estratégico): após a pausa em "sanei sua dúvida?", o + # cliente respondeu. O agente não cancela bundle, então fecha com a reafirmação + # breve (resposta_bundle) e registra/finaliza, independente de SIM/NAO. Tem + # precedência (priority menor) sobre os ramos SIM/NAO do estratégico. + - from: decisao + to: resposta_bundle + priority: 5 + when: + eq: [$.vars.preparar.has_estrategico_items, false] + + - from: decisao + to: resposta_sim + priority: 10 + when: + eq: [$.input.resposta_usuario, SIM] + + - from: decisao + to: explicar_cancelamento + priority: 20 + when: + eq: [$.input.resposta_usuario, NAO] + + - from: decisao + to: registrar_outro + priority: 99 + + - from: resposta_sim + to: registrar_sim + + - from: explicar_cancelamento + to: registrar_nao + + - from: registrar_sim + to: END + + - from: registrar_nao + to: END + + - from: resposta_bundle + to: registrar_bundle + + - from: registrar_bundle + to: END + + - from: registrar_outro + to: END diff --git a/legacy_reference/workflows/exceptions.py b/legacy_reference/workflows/exceptions.py new file mode 100644 index 0000000..2efcf26 --- /dev/null +++ b/legacy_reference/workflows/exceptions.py @@ -0,0 +1,25 @@ +from __future__ import annotations + + +class WorkflowError(Exception): + """Erro base dos workflows configuráveis.""" + + +class WorkflowConfigurationError(WorkflowError): + """Erro de configuração do workflow (arquivo/schema inválido).""" + + +class WorkflowNotFoundError(WorkflowError): + """Workflow não encontrado no repositório.""" + + +class WorkflowExecutionNotFoundError(WorkflowError): + """Execução de workflow inexistente para o execution_id informado.""" + + +class WorkflowExecutionStateError(WorkflowError): + """Estado da execução não permite a operação solicitada.""" + + +class WorkflowInputError(WorkflowError): + """Input inválido durante a execução/retomada do workflow.""" diff --git a/legacy_reference/workflows/execution_store.py b/legacy_reference/workflows/execution_store.py new file mode 100644 index 0000000..f95d528 --- /dev/null +++ b/legacy_reference/workflows/execution_store.py @@ -0,0 +1,477 @@ +from __future__ import annotations + +from contextlib import contextmanager +from datetime import datetime +from threading import Lock +from typing import Any, Iterator, Protocol, runtime_checkable + +from agente_contas_tim.workflows.exceptions import ( + WorkflowExecutionNotFoundError, + WorkflowExecutionStateError, +) +from agente_contas_tim.workflows.runtime_types import ( + ExecutionStatus, + WorkflowExecutionRecord, +) + + +@runtime_checkable +class ExecutionStore(Protocol): + def create( + self, + execution_id: str, + workflow_name: str, + workflow_version: int, + *, + session_id: str | None = None, + started_by_message_id: str | None = None, + ) -> WorkflowExecutionRecord: ... + + def get(self, execution_id: str) -> WorkflowExecutionRecord: ... + + def mark_status( + self, + execution_id: str, + *, + status: ExecutionStatus, + current_node: str | None, + resume_from: str | None, + expected_input_key: str | None, + ) -> WorkflowExecutionRecord: ... + + def claim_resume( + self, + execution_id: str, + workflow_name: str, + workflow_version: int | None, + *, + session_id: str | None = None, + message_id: str | None = None, + ) -> WorkflowExecutionRecord: ... + + def close(self) -> None: ... + + +class InMemoryExecutionStore: + """Execution store em memória (sem persistência, para dev/teste).""" + + def __init__(self) -> None: + self._records: dict[str, WorkflowExecutionRecord] = {} + self._lock = Lock() + + def create( + self, + execution_id: str, + workflow_name: str, + workflow_version: int, + *, + session_id: str | None = None, + started_by_message_id: str | None = None, + ) -> WorkflowExecutionRecord: + now = datetime.now() + record = WorkflowExecutionRecord( + execution_id=execution_id, + workflow_name=workflow_name, + workflow_version=workflow_version, + status="RUNNING", + current_node=None, + resume_from=None, + expected_input_key=None, + created_at=now, + updated_at=now, + ) + with self._lock: + self._records[execution_id] = record + return record + + def get(self, execution_id: str) -> WorkflowExecutionRecord: + with self._lock: + record = self._records.get(execution_id) + if record is None: + raise WorkflowExecutionNotFoundError( + f"execution_id={execution_id!r} nao encontrado" + ) + return record + + def mark_status( + self, + execution_id: str, + *, + status: ExecutionStatus, + current_node: str | None, + resume_from: str | None, + expected_input_key: str | None, + ) -> WorkflowExecutionRecord: + with self._lock: + record = self._records.get(execution_id) + if record is None: + raise WorkflowExecutionNotFoundError( + f"execution_id={execution_id!r} nao encontrado" + ) + updated = WorkflowExecutionRecord( + execution_id=record.execution_id, + workflow_name=record.workflow_name, + workflow_version=record.workflow_version, + status=status, + current_node=current_node, + resume_from=resume_from, + expected_input_key=expected_input_key, + created_at=record.created_at, + updated_at=datetime.now(), + ) + self._records[execution_id] = updated + return updated + + def claim_resume( + self, + execution_id: str, + workflow_name: str, + workflow_version: int | None, + *, + session_id: str | None = None, + message_id: str | None = None, + ) -> WorkflowExecutionRecord: + with self._lock: + record = self._records.get(execution_id) + if record is None: + raise WorkflowExecutionNotFoundError( + f"execution_id={execution_id!r} nao encontrado" + ) + if record.workflow_name != workflow_name: + raise WorkflowExecutionStateError( + "workflow_name informado nao corresponde ao execution_id" + ) + if ( + workflow_version is not None + and record.workflow_version != workflow_version + ): + raise WorkflowExecutionStateError( + "version informada nao corresponde a execucao existente" + ) + if record.status != "WAITING_INPUT": + raise WorkflowExecutionStateError( + f"Execucao {execution_id} nao esta aguardando input" + ) + updated = WorkflowExecutionRecord( + execution_id=record.execution_id, + workflow_name=record.workflow_name, + workflow_version=record.workflow_version, + status="RUNNING", + current_node=record.current_node, + resume_from=record.resume_from, + expected_input_key=record.expected_input_key, + created_at=record.created_at, + updated_at=datetime.now(), + ) + self._records[execution_id] = updated + return record + + def close(self) -> None: + pass + + +class PostgresExecutionStore: + """Persistencia duravel e lock atomico para execucoes de workflow em Postgres.""" + + def __init__(self, dsn: str) -> None: + self._dsn = dsn + self._setup_lock = Lock() + self._setup() + + def create( + self, + execution_id: str, + workflow_name: str, + workflow_version: int, + *, + session_id: str | None = None, + started_by_message_id: str | None = None, + ) -> WorkflowExecutionRecord: + with ( + self._connect() as conn, + conn.cursor(row_factory=self._dict_row_factory()) as cur, + ): + cur.execute( + """ + INSERT INTO workflow_execution ( + execution_id, + workflow_name, + workflow_version, + status, + current_node, + resume_from, + expected_input_key, + created_at, + updated_at + ) VALUES (%s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) + RETURNING + execution_id, + workflow_name, + workflow_version, + status, + current_node, + resume_from, + expected_input_key, + created_at, + updated_at + """, + ( + execution_id, + workflow_name, + workflow_version, + "RUNNING", + None, + None, + None, + ), + ) + row = cur.fetchone() + return self._row_to_record(row) + + def get(self, execution_id: str) -> WorkflowExecutionRecord: + with ( + self._connect() as conn, + conn.cursor(row_factory=self._dict_row_factory()) as cur, + ): + cur.execute( + """ + SELECT + execution_id, + workflow_name, + workflow_version, + status, + current_node, + resume_from, + expected_input_key, + created_at, + updated_at + FROM workflow_execution + WHERE execution_id = %s + """, + (execution_id,), + ) + row = cur.fetchone() + + if row is None: + raise WorkflowExecutionNotFoundError( + f"execution_id={execution_id!r} nao encontrado" + ) + return self._row_to_record(row) + + def mark_status( + self, + execution_id: str, + *, + status: ExecutionStatus, + current_node: str | None, + resume_from: str | None, + expected_input_key: str | None, + ) -> WorkflowExecutionRecord: + with ( + self._connect() as conn, + conn.cursor(row_factory=self._dict_row_factory()) as cur, + ): + cur.execute( + """ + UPDATE workflow_execution + SET status = %s, + current_node = %s, + resume_from = %s, + expected_input_key = %s, + updated_at = NOW() + WHERE execution_id = %s + RETURNING + execution_id, + workflow_name, + workflow_version, + status, + current_node, + resume_from, + expected_input_key, + created_at, + updated_at + """, + ( + status, + current_node, + resume_from, + expected_input_key, + execution_id, + ), + ) + row = cur.fetchone() + + if row is None: + raise WorkflowExecutionNotFoundError( + f"execution_id={execution_id!r} nao encontrado" + ) + return self._row_to_record(row) + + def claim_resume( + self, + execution_id: str, + workflow_name: str, + workflow_version: int | None, + *, + session_id: str | None = None, + message_id: str | None = None, + ) -> WorkflowExecutionRecord: + with ( + self._connect() as conn, + conn.cursor(row_factory=self._dict_row_factory()) as cur, + ): + cur.execute( + """ + SELECT + execution_id, + workflow_name, + workflow_version, + status, + current_node, + resume_from, + expected_input_key, + created_at, + updated_at + FROM workflow_execution + WHERE execution_id = %s + FOR UPDATE + """, + (execution_id,), + ) + row = cur.fetchone() + + if row is None: + raise WorkflowExecutionNotFoundError( + f"execution_id={execution_id!r} nao encontrado" + ) + + record = self._row_to_record(row) + if record.workflow_name != workflow_name: + raise WorkflowExecutionStateError( + "workflow_name informado nao corresponde ao execution_id" + ) + if ( + workflow_version is not None + and record.workflow_version != workflow_version + ): + raise WorkflowExecutionStateError( + "version informada nao corresponde a execucao existente" + ) + if record.status != "WAITING_INPUT": + raise WorkflowExecutionStateError( + f"Execucao {execution_id} nao esta aguardando input" + ) + + cur.execute( + """ + UPDATE workflow_execution + SET status = %s, + updated_at = NOW() + WHERE execution_id = %s + AND status = %s + RETURNING + execution_id, + workflow_name, + workflow_version, + status, + current_node, + resume_from, + expected_input_key, + created_at, + updated_at + """, + ("RUNNING", execution_id, "WAITING_INPUT"), + ) + updated = cur.fetchone() + if updated is None: + raise WorkflowExecutionStateError( + f"Execucao {execution_id} foi retomada por outra requisicao" + ) + + return self._row_to_record(updated) + + def close(self) -> None: + return None + + def _setup(self) -> None: + with self._setup_lock: + with self._connect() as conn, conn.cursor() as cur: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS workflow_execution ( + execution_id TEXT PRIMARY KEY, + workflow_name TEXT NOT NULL, + workflow_version INTEGER NOT NULL, + status TEXT NOT NULL, + current_node TEXT, + resume_from TEXT, + expected_input_key TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) + cur.execute( + """ + CREATE INDEX IF NOT EXISTS idx_workflow_execution_status + ON workflow_execution (status) + """ + ) + + @contextmanager + def _connect(self) -> Iterator[Any]: + psycopg = self._import_psycopg() + conn = psycopg.connect(self._dsn, autocommit=False) + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + + @staticmethod + def _import_psycopg() -> Any: + try: + import psycopg + except ModuleNotFoundError as exc: # pragma: no cover - depende do ambiente + raise ModuleNotFoundError( + "psycopg nao esta instalado. " + "Adicione a dependencia para usar workflows em PostgreSQL." + ) from exc + return psycopg + + @staticmethod + def _dict_row_factory() -> Any: + try: + from psycopg.rows import dict_row + except ModuleNotFoundError as exc: # pragma: no cover - depende do ambiente + raise ModuleNotFoundError( + "psycopg nao esta instalado. " + "Adicione a dependencia para usar workflows em PostgreSQL." + ) from exc + return dict_row + + @staticmethod + def _row_to_record(row: Any) -> WorkflowExecutionRecord: + if row is None: + raise WorkflowExecutionNotFoundError("Registro de workflow nao encontrado") + + return WorkflowExecutionRecord( + execution_id=str(row["execution_id"]), + workflow_name=str(row["workflow_name"]), + workflow_version=int(row["workflow_version"]), + status=str(row["status"]), # type: ignore[arg-type] + current_node=row["current_node"], + resume_from=row["resume_from"], + expected_input_key=row["expected_input_key"], + created_at=PostgresExecutionStore._as_datetime(row["created_at"]), + updated_at=PostgresExecutionStore._as_datetime(row["updated_at"]), + ) + + @staticmethod + def _as_datetime(value: Any) -> datetime: + if isinstance(value, datetime): + return value + return datetime.fromisoformat(str(value)) diff --git a/legacy_reference/workflows/oracle_checkpoint.py b/legacy_reference/workflows/oracle_checkpoint.py new file mode 100644 index 0000000..64bdcc4 --- /dev/null +++ b/legacy_reference/workflows/oracle_checkpoint.py @@ -0,0 +1,763 @@ +"""LangGraph checkpoint saver backed by Oracle ADB.""" + +from __future__ import annotations + +from collections.abc import Iterator, Mapping, Sequence +from copy import deepcopy +import logging +from random import random +from typing import Any + +from langgraph.checkpoint.base import ( + BaseCheckpointSaver, + ChannelVersions, + Checkpoint, + CheckpointMetadata, + CheckpointTuple, + WRITES_IDX_MAP, + get_checkpoint_id, + get_serializable_checkpoint_metadata, +) +from langgraph.checkpoint.serde.types import _DeltaSnapshot + +from agente_contas_tim.repositories.oracle_state_connection import ( + OracleStateConnectionFactory, + is_unique_constraint_error, + normalize_json, + set_blob_inputsizes, + read_json_value, + set_json_inputsizes, +) + + +_DEFAULT_NS = "__default__" +_PRIMITIVE_TYPES = (str, int, float, bool) +logger = logging.getLogger(__name__) + + +class OracleCheckpointSaver(BaseCheckpointSaver): + """Synchronous LangGraph checkpointer persisted in Oracle native JSON/BLOB.""" + + _REQUIRED_TABLES = { + "TB_CONT_WORKFLOW_CHECKPOINT", + "TB_CONT_WORKFLOW_CHECKPOINT_WRITE", + "TB_CONT_WORKFLOW_CHECKPOINT_BLOB", + } + + def __init__(self, connection_factory: OracleStateConnectionFactory) -> None: + super().__init__() + self._connection_factory = connection_factory + self._connection_factory.validate_required_tables(self._REQUIRED_TABLES) + + def setup(self) -> None: + self._connection_factory.validate_required_tables(self._REQUIRED_TABLES) + + def get_tuple(self, config: Mapping[str, Any]) -> CheckpointTuple | None: + configurable = dict(config.get("configurable", {}) or {}) + thread_id = str(configurable["thread_id"]) + checkpoint_ns = _to_db_ns(str(configurable.get("checkpoint_ns", ""))) + checkpoint_id = get_checkpoint_id(config) # type: ignore[arg-type] + + with self._connection_factory.connect() as conn, conn.cursor() as cur: + if checkpoint_id: + cur.execute( + """ + SELECT + thread_id, + checkpoint_ns, + checkpoint_id, + parent_checkpoint_id, + checkpoint_json, + metadata_json + FROM tb_cont_workflow_checkpoint + WHERE thread_id = :thread_id + AND ( + checkpoint_ns = :checkpoint_ns + OR ( + :checkpoint_ns = :default_checkpoint_ns + AND checkpoint_ns IS NULL + ) + ) + AND checkpoint_id = :checkpoint_id + """, + { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "default_checkpoint_ns": _DEFAULT_NS, + "checkpoint_id": checkpoint_id, + }, + ) + else: + cur.execute( + """ + SELECT + thread_id, + checkpoint_ns, + checkpoint_id, + parent_checkpoint_id, + checkpoint_json, + metadata_json + FROM tb_cont_workflow_checkpoint + WHERE thread_id = :thread_id + AND ( + checkpoint_ns = :checkpoint_ns + OR ( + :checkpoint_ns = :default_checkpoint_ns + AND checkpoint_ns IS NULL + ) + ) + ORDER BY checkpoint_id DESC + FETCH FIRST 1 ROWS ONLY + """, + { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "default_checkpoint_ns": _DEFAULT_NS, + }, + ) + row = _fetchone_dict(cur) + if row is None: + return None + return self._load_checkpoint_tuple(cur, row) + + def list( + self, + config: Mapping[str, Any] | None, + *, + filter: dict[str, Any] | None = None, + before: Mapping[str, Any] | None = None, + limit: int | None = None, + ) -> Iterator[CheckpointTuple]: + clauses: list[str] = [] + params: dict[str, Any] = {} + configurable = dict((config or {}).get("configurable", {}) or {}) + if configurable.get("thread_id"): + clauses.append("thread_id = :thread_id") + params["thread_id"] = str(configurable["thread_id"]) + if "checkpoint_ns" in configurable: + clauses.append( + "(checkpoint_ns = :checkpoint_ns OR " + "(:checkpoint_ns = :default_checkpoint_ns " + "AND checkpoint_ns IS NULL))" + ) + params["checkpoint_ns"] = _to_db_ns( + str(configurable.get("checkpoint_ns", "")) + ) + params["default_checkpoint_ns"] = _DEFAULT_NS + if before: + before_id = get_checkpoint_id(before) # type: ignore[arg-type] + if before_id: + clauses.append("checkpoint_id < :before_checkpoint_id") + params["before_checkpoint_id"] = before_id + + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + query = f""" + SELECT + thread_id, + checkpoint_ns, + checkpoint_id, + parent_checkpoint_id, + checkpoint_json, + metadata_json + FROM tb_cont_workflow_checkpoint + {where} + ORDER BY checkpoint_id DESC + """ + + emitted = 0 + with self._connection_factory.connect() as conn, conn.cursor() as cur: + cur.execute(query, params) + rows = [_row_to_dict(cur, row) for row in cur.fetchall()] + for row in rows: + metadata = read_json_value(row.get("metadata_json")) or {} + if filter and not _metadata_matches(metadata, filter): + continue + yield self._load_checkpoint_tuple(cur, row) + emitted += 1 + if limit is not None and emitted >= limit: + return + + def put( + self, + config: Mapping[str, Any], + checkpoint: Checkpoint, + metadata: CheckpointMetadata, + new_versions: ChannelVersions, + ) -> dict[str, Any]: + configurable = dict(config["configurable"]) + thread_id = str(configurable.pop("thread_id")) + checkpoint_ns = str(configurable.pop("checkpoint_ns", "")) + parent_checkpoint_id = configurable.pop("checkpoint_id", None) + checkpoint_id = str(checkpoint["id"]) + db_checkpoint_ns = _to_db_ns(checkpoint_ns) + + checkpoint_copy = deepcopy(checkpoint) + channel_values = dict(checkpoint_copy.get("channel_values") or {}) + checkpoint_copy["channel_values"] = channel_values + blob_values: dict[str, Any] = {} + for channel, value in list(channel_values.items()): + if isinstance(value, _DeltaSnapshot): + blob_values[channel] = value + channel_values[channel] = True + continue + if value is None or isinstance(value, _PRIMITIVE_TYPES): + continue + blob_values[channel] = channel_values.pop(channel) + + try: + with self._connection_factory.connect() as conn, conn.cursor() as cur: + blob_versions = { + channel: version + for channel, version in dict(new_versions).items() + if channel in blob_values + } + for channel, version in blob_versions.items(): + self._upsert_blob( + cur, + thread_id=thread_id, + checkpoint_ns=db_checkpoint_ns, + channel=str(channel), + version=str(version), + value=blob_values[channel], + ) + + set_json_inputsizes(cur, "checkpoint_json", "metadata_json") + cur.execute( + """ + MERGE INTO tb_cont_workflow_checkpoint dst + USING ( + SELECT + :thread_id thread_id, + :checkpoint_ns checkpoint_ns, + :checkpoint_id checkpoint_id + FROM dual + ) src + ON ( + dst.thread_id = src.thread_id + AND dst.checkpoint_ns = src.checkpoint_ns + AND dst.checkpoint_id = src.checkpoint_id + ) + WHEN MATCHED THEN UPDATE SET + parent_checkpoint_id = :parent_checkpoint_id, + checkpoint_json = :checkpoint_json, + metadata_json = :metadata_json + WHEN NOT MATCHED THEN INSERT ( + thread_id, + checkpoint_ns, + checkpoint_id, + parent_checkpoint_id, + checkpoint_json, + metadata_json + ) VALUES ( + :thread_id, + :checkpoint_ns, + :checkpoint_id, + :parent_checkpoint_id, + :checkpoint_json, + :metadata_json + ) + """, + { + "thread_id": thread_id, + "checkpoint_ns": db_checkpoint_ns, + "checkpoint_id": checkpoint_id, + "parent_checkpoint_id": parent_checkpoint_id, + "checkpoint_json": normalize_json(checkpoint_copy), + "metadata_json": normalize_json( + get_serializable_checkpoint_metadata( + config, # type: ignore[arg-type] + metadata, + ) + ), + }, + ) + except Exception: + logger.error( + "oracle_checkpoint.put.failed thread_id=%s checkpoint_ns=%s " + "checkpoint_id=%s parent_checkpoint_id=%s blob_channels=%s " + "channel_keys=%s", + thread_id, + db_checkpoint_ns, + checkpoint_id, + parent_checkpoint_id, + sorted(str(channel) for channel in blob_values.keys()), + sorted(str(channel) for channel in channel_values.keys()), + exc_info=True, + ) + raise + + return { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint_id, + } + } + + def put_writes( + self, + config: Mapping[str, Any], + writes: Sequence[tuple[str, Any]], + task_id: str, + task_path: str = "", + ) -> None: + configurable = dict(config["configurable"]) + thread_id = str(configurable["thread_id"]) + checkpoint_ns = _to_db_ns(str(configurable.get("checkpoint_ns", ""))) + checkpoint_id = str(configurable["checkpoint_id"]) + use_upsert = all(channel in WRITES_IDX_MAP for channel, _ in writes) + + try: + with self._connection_factory.connect() as conn, conn.cursor() as cur: + set_blob_inputsizes(cur, "blob_payload") + for idx, (channel, value) in enumerate(writes): + write_idx = WRITES_IDX_MAP.get(channel, idx) + type_name, payload = self.serde.dumps_typed(value) + params = { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "checkpoint_id": checkpoint_id, + "task_id": task_id, + "task_path": task_path, + "idx": write_idx, + "channel": channel, + "type_name": type_name, + "blob_payload": _blob_payload_for_storage( + type_name, + payload, + ), + } + if use_upsert: + cur.execute( + """ + MERGE INTO tb_cont_workflow_checkpoint_write dst + USING ( + SELECT + :thread_id thread_id, + :checkpoint_ns checkpoint_ns, + :checkpoint_id checkpoint_id, + :task_id task_id, + :idx idx + FROM dual + ) src + ON ( + dst.thread_id = src.thread_id + AND dst.checkpoint_ns = src.checkpoint_ns + AND dst.checkpoint_id = src.checkpoint_id + AND dst.task_id = src.task_id + AND dst.idx = src.idx + ) + WHEN MATCHED THEN UPDATE SET + task_path = :task_path, + channel = :channel, + type_name = :type_name, + blob_payload = :blob_payload, + value_json = NULL + WHEN NOT MATCHED THEN INSERT ( + thread_id, + checkpoint_ns, + checkpoint_id, + task_id, + task_path, + idx, + channel, + type_name, + blob_payload, + value_json + ) VALUES ( + :thread_id, + :checkpoint_ns, + :checkpoint_id, + :task_id, + :task_path, + :idx, + :channel, + :type_name, + :blob_payload, + NULL + ) + """, + params, + ) + else: + try: + cur.execute( + """ + INSERT INTO tb_cont_workflow_checkpoint_write ( + thread_id, + checkpoint_ns, + checkpoint_id, + task_id, + task_path, + idx, + channel, + type_name, + blob_payload, + value_json + ) VALUES ( + :thread_id, + :checkpoint_ns, + :checkpoint_id, + :task_id, + :task_path, + :idx, + :channel, + :type_name, + :blob_payload, + NULL + ) + """, + params, + ) + except Exception as exc: + if is_unique_constraint_error(exc): + continue + raise + except Exception: + logger.error( + "oracle_checkpoint.put_writes.failed thread_id=%s " + "checkpoint_ns=%s checkpoint_id=%s task_id=%s " + "task_path=%s channels=%s use_upsert=%s", + thread_id, + checkpoint_ns, + checkpoint_id, + task_id, + task_path, + [str(channel) for channel, _ in writes], + use_upsert, + exc_info=True, + ) + raise + + def delete_thread(self, thread_id: str) -> None: + with self._connection_factory.connect() as conn, conn.cursor() as cur: + cur.execute( + "DELETE FROM tb_cont_workflow_checkpoint_write WHERE thread_id = :thread_id", + {"thread_id": thread_id}, + ) + cur.execute( + "DELETE FROM tb_cont_workflow_checkpoint_blob WHERE thread_id = :thread_id", + {"thread_id": thread_id}, + ) + cur.execute( + "DELETE FROM tb_cont_workflow_checkpoint WHERE thread_id = :thread_id", + {"thread_id": thread_id}, + ) + + def close(self) -> None: + return None + + def _load_checkpoint_tuple( + self, + cur: Any, + row: dict[str, Any], + ) -> CheckpointTuple: + checkpoint = read_json_value(row.get("checkpoint_json")) or {} + metadata = read_json_value(row.get("metadata_json")) or {} + thread_id = str(row["thread_id"]) + checkpoint_ns = _db_ns_from_row(row.get("checkpoint_ns")) + checkpoint_id = str(row["checkpoint_id"]) + parent_checkpoint_id = row.get("parent_checkpoint_id") + channel_values = dict(checkpoint.get("channel_values") or {}) + checkpoint["channel_values"] = { + **channel_values, + **self._load_blobs( + cur, + checkpoint=checkpoint, + thread_id=thread_id, + checkpoint_ns=checkpoint_ns, + ), + } + pending_writes = self._load_writes( + cur, + thread_id=thread_id, + checkpoint_ns=checkpoint_ns, + checkpoint_id=checkpoint_id, + ) + return CheckpointTuple( + { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": _from_db_ns(checkpoint_ns), + "checkpoint_id": checkpoint_id, + } + }, + checkpoint, + metadata, + ( + { + "configurable": { + "thread_id": thread_id, + "checkpoint_ns": _from_db_ns(checkpoint_ns), + "checkpoint_id": parent_checkpoint_id, + } + } + if parent_checkpoint_id + else None + ), + pending_writes, + ) + + def _load_blobs( + self, + cur: Any, + *, + checkpoint: dict[str, Any], + thread_id: str, + checkpoint_ns: str, + ) -> dict[str, Any]: + channel_versions = checkpoint.get("channel_versions") or {} + if not isinstance(channel_versions, dict): + return {} + values: dict[str, Any] = {} + for channel, version in channel_versions.items(): + cur.execute( + """ + SELECT channel, type_name, blob_payload + FROM tb_cont_workflow_checkpoint_blob + WHERE thread_id = :thread_id + AND ( + checkpoint_ns = :checkpoint_ns + OR ( + :checkpoint_ns = :default_checkpoint_ns + AND checkpoint_ns IS NULL + ) + ) + AND channel = :channel + AND version = :version + """, + { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "default_checkpoint_ns": _DEFAULT_NS, + "channel": str(channel), + "version": str(version), + }, + ) + blob_row = _fetchone_dict(cur) + if blob_row is None: + continue + type_name = str(blob_row.get("type_name") or "") + if type_name == "empty": + continue + payload = _read_lob_bytes(blob_row.get("blob_payload")) + try: + values[str(blob_row["channel"])] = self.serde.loads_typed( + (type_name, payload) + ) + except Exception: + logger.error( + "oracle_checkpoint.load_blob.failed thread_id=%s " + "checkpoint_ns=%s channel=%s version=%s type_name=%s " + "payload_len=%s", + thread_id, + checkpoint_ns, + channel, + version, + type_name, + len(payload), + exc_info=True, + ) + raise + return values + + def _load_writes( + self, + cur: Any, + *, + thread_id: str, + checkpoint_ns: str, + checkpoint_id: str, + ) -> list[tuple[str, str, Any]]: + cur.execute( + """ + SELECT task_id, channel, type_name, blob_payload + FROM tb_cont_workflow_checkpoint_write + WHERE thread_id = :thread_id + AND ( + checkpoint_ns = :checkpoint_ns + OR ( + :checkpoint_ns = :default_checkpoint_ns + AND checkpoint_ns IS NULL + ) + ) + AND checkpoint_id = :checkpoint_id + ORDER BY task_id, idx + """, + { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "default_checkpoint_ns": _DEFAULT_NS, + "checkpoint_id": checkpoint_id, + }, + ) + rows = [_row_to_dict(cur, row) for row in cur.fetchall()] + writes: list[tuple[str, str, Any]] = [] + for row in rows: + type_name = str(row["type_name"]) + payload = _read_lob_bytes(row.get("blob_payload")) + try: + value = self.serde.loads_typed((type_name, payload)) + except Exception: + logger.error( + "oracle_checkpoint.load_write.failed thread_id=%s " + "checkpoint_ns=%s checkpoint_id=%s task_id=%s " + "channel=%s type_name=%s payload_len=%s", + thread_id, + checkpoint_ns, + checkpoint_id, + row.get("task_id"), + row.get("channel"), + type_name, + len(payload), + exc_info=True, + ) + raise + writes.append( + ( + str(row["task_id"]), + str(row["channel"]), + value, + ) + ) + return writes + + def _upsert_blob( + self, + cur: Any, + *, + thread_id: str, + checkpoint_ns: str, + channel: str, + version: str, + value: Any, + ) -> None: + type_name, payload = self.serde.dumps_typed(value) + set_blob_inputsizes(cur, "blob_payload") + cur.execute( + """ + MERGE INTO tb_cont_workflow_checkpoint_blob dst + USING ( + SELECT + :thread_id thread_id, + :checkpoint_ns checkpoint_ns, + :channel channel, + :version version + FROM dual + ) src + ON ( + dst.thread_id = src.thread_id + AND dst.checkpoint_ns = src.checkpoint_ns + AND dst.channel = src.channel + AND dst.version = src.version + ) + WHEN MATCHED THEN UPDATE SET + type_name = :type_name, + blob_payload = :blob_payload, + json_payload = NULL + WHEN NOT MATCHED THEN INSERT ( + thread_id, + checkpoint_ns, + channel, + version, + type_name, + blob_payload, + json_payload + ) VALUES ( + :thread_id, + :checkpoint_ns, + :channel, + :version, + :type_name, + :blob_payload, + NULL + ) + """, + { + "thread_id": thread_id, + "checkpoint_ns": checkpoint_ns, + "channel": channel, + "version": version, + "type_name": type_name, + "blob_payload": _blob_payload_for_storage(type_name, payload), + }, + ) + + def get_next_version(self, current: Any, channel: None) -> str: + if current is None: + current_v = 0 + elif isinstance(current, int): + current_v = current + else: + current_v = int(str(current).split(".")[0]) + next_v = current_v + 1 + next_h = random() + return f"{next_v:032}.{next_h:016}" + + +def _to_db_ns(checkpoint_ns: str) -> str: + return checkpoint_ns or _DEFAULT_NS + + +def _from_db_ns(checkpoint_ns: str | None) -> str: + return ( + "" + if not checkpoint_ns or checkpoint_ns == _DEFAULT_NS + else checkpoint_ns + ) + + +def _db_ns_from_row(checkpoint_ns: Any) -> str: + if checkpoint_ns is None: + return _DEFAULT_NS + return str(checkpoint_ns) or _DEFAULT_NS + + +def _fetchone_dict(cur: Any) -> dict[str, Any] | None: + row = cur.fetchone() + if row is None: + return None + return _row_to_dict(cur, row) + + +def _row_to_dict(cur: Any, row: Any) -> dict[str, Any]: + names = [str(col[0]).lower() for col in cur.description] + return dict(zip(names, row, strict=False)) + + +def _metadata_matches(metadata: Any, expected: dict[str, Any]) -> bool: + if not isinstance(metadata, dict): + return False + for key, value in expected.items(): + if metadata.get(key) != value: + return False + return True + + +def _read_lob_bytes(value: Any) -> bytes: + if value is None: + return b"" + if isinstance(value, bytes): + return value + if isinstance(value, bytearray): + return bytes(value) + if isinstance(value, memoryview): + return value.tobytes() + if isinstance(value, str): + return value.encode("utf-8") + read = getattr(value, "read", None) + if callable(read): + return _read_lob_bytes(read()) + return bytes(value) + + +def _blob_payload_for_storage(type_name: str, payload: bytes | None) -> bytes: + if payload: + return payload + if type_name in {"empty", "null"}: + # Oracle can normalize zero-length BLOB binds to NULL. LangGraph's + # serde ignores the payload for these tags, so a one-byte sentinel keeps + # legacy payload checks and Oracle's empty-BLOB behavior from breaking + # resume writes that legitimately carry None. + return b"\x00" + return payload if payload is not None else b"" diff --git a/legacy_reference/workflows/oracle_execution_store.py b/legacy_reference/workflows/oracle_execution_store.py new file mode 100644 index 0000000..d166a5d --- /dev/null +++ b/legacy_reference/workflows/oracle_execution_store.py @@ -0,0 +1,342 @@ +"""Oracle implementation of workflow execution state.""" + +from __future__ import annotations + +from datetime import datetime +import logging +from typing import Any + +from agente_contas_tim.repositories.oracle_state_connection import ( + OracleStateConnectionFactory, + is_unique_constraint_error, + normalize_json, + set_json_inputsizes, +) +from agente_contas_tim.workflows.exceptions import ( + WorkflowExecutionNotFoundError, + WorkflowExecutionStateError, +) +from agente_contas_tim.workflows.runtime_types import ( + ExecutionStatus, + WorkflowExecutionRecord, +) + +logger = logging.getLogger(__name__) + + +class OracleExecutionStore: + """Persistencia duravel e lock atomico para workflows em Oracle ADB.""" + + _REQUIRED_TABLES = { + "TB_CONT_AGENT_SESSION", + "TB_CONT_AGENT_MESSAGE", + "TB_CONT_WORKFLOW_EXECUTION", + "TB_CONT_WORKFLOW_MESSAGE_LINK", + } + + def __init__(self, connection_factory: OracleStateConnectionFactory) -> None: + self._connection_factory = connection_factory + self._connection_factory.validate_required_tables(self._REQUIRED_TABLES) + + def create( + self, + execution_id: str, + workflow_name: str, + workflow_version: int, + *, + session_id: str | None = None, + started_by_message_id: str | None = None, + ) -> WorkflowExecutionRecord: + with self._connection_factory.connect() as conn, conn.cursor() as cur: + cur.execute( + """ + INSERT INTO tb_cont_workflow_execution ( + execution_id, + session_id, + started_by_message_id, + last_message_id, + workflow_name, + workflow_version, + status, + current_node, + resume_from, + expected_input_key + ) VALUES ( + :execution_id, + :session_id, + :started_by_message_id, + :last_message_id, + :workflow_name, + :workflow_version, + 'RUNNING', + NULL, + NULL, + NULL + ) + """, + { + "execution_id": execution_id, + "session_id": session_id, + "started_by_message_id": started_by_message_id, + "last_message_id": started_by_message_id, + "workflow_name": workflow_name, + "workflow_version": workflow_version, + }, + ) + if session_id and started_by_message_id: + self._insert_message_link( + cur, + execution_id=execution_id, + session_id=session_id, + message_id=started_by_message_id, + purpose="STARTED", + metadata={"workflow": workflow_name, "version": workflow_version}, + ) + row = self._select_execution(cur, execution_id) + return self._row_to_record(row) + + def get(self, execution_id: str) -> WorkflowExecutionRecord: + with self._connection_factory.connect() as conn, conn.cursor() as cur: + row = self._select_execution(cur, execution_id) + return self._row_to_record(row) + + def mark_status( + self, + execution_id: str, + *, + status: ExecutionStatus, + current_node: str | None, + resume_from: str | None, + expected_input_key: str | None, + ) -> WorkflowExecutionRecord: + with self._connection_factory.connect() as conn, conn.cursor() as cur: + cur.execute( + """ + UPDATE tb_cont_workflow_execution + SET status = :status, + current_node = :current_node, + resume_from = :resume_from, + expected_input_key = :expected_input_key + WHERE execution_id = :execution_id + """, + { + "execution_id": execution_id, + "status": status, + "current_node": current_node, + "resume_from": resume_from, + "expected_input_key": expected_input_key, + }, + ) + if cur.rowcount == 0: + raise WorkflowExecutionNotFoundError( + f"execution_id={execution_id!r} nao encontrado" + ) + row = self._select_execution(cur, execution_id) + if status in {"COMPLETED", "FAILED"}: + purpose = "COMPLETED" if status == "COMPLETED" else "FAILED" + self._link_last_message(cur, row=row, purpose=purpose) + return self._row_to_record(row) + + def claim_resume( + self, + execution_id: str, + workflow_name: str, + workflow_version: int | None, + *, + session_id: str | None = None, + message_id: str | None = None, + ) -> WorkflowExecutionRecord: + with self._connection_factory.connect() as conn, conn.cursor() as cur: + cur.execute( + """ + SELECT + execution_id, + session_id, + started_by_message_id, + last_message_id, + workflow_name, + workflow_version, + status, + current_node, + resume_from, + expected_input_key, + created_at, + updated_at + FROM tb_cont_workflow_execution + WHERE execution_id = :execution_id + FOR UPDATE + """, + {"execution_id": execution_id}, + ) + row = _fetchone_dict(cur) + if row is None: + raise WorkflowExecutionNotFoundError( + f"execution_id={execution_id!r} nao encontrado" + ) + + record = self._row_to_record(row) + if record.workflow_name != workflow_name: + raise WorkflowExecutionStateError( + "workflow_name informado nao corresponde ao execution_id" + ) + if ( + workflow_version is not None + and record.workflow_version != workflow_version + ): + raise WorkflowExecutionStateError( + "version informada nao corresponde a execucao existente" + ) + if record.status != "WAITING_INPUT": + raise WorkflowExecutionStateError( + f"Execucao {execution_id} nao esta aguardando input" + ) + + cur.execute( + """ + UPDATE tb_cont_workflow_execution + SET status = 'RUNNING', + last_message_id = COALESCE(:message_id, last_message_id) + WHERE execution_id = :execution_id + AND status = 'WAITING_INPUT' + """, + {"execution_id": execution_id, "message_id": message_id}, + ) + if cur.rowcount == 0: + raise WorkflowExecutionStateError( + f"Execucao {execution_id} foi retomada por outra requisicao" + ) + row = self._select_execution(cur, execution_id) + effective_session_id = session_id or row.get("session_id") + if effective_session_id and message_id: + self._insert_message_link( + cur, + execution_id=execution_id, + session_id=str(effective_session_id), + message_id=message_id, + purpose="RESUMED", + metadata={"workflow": workflow_name, "version": workflow_version}, + ) + return self._row_to_record(row) + + def close(self) -> None: + return None + + @staticmethod + def _select_execution(cur: Any, execution_id: str) -> dict[str, Any]: + cur.execute( + """ + SELECT + execution_id, + session_id, + started_by_message_id, + last_message_id, + workflow_name, + workflow_version, + status, + current_node, + resume_from, + expected_input_key, + created_at, + updated_at + FROM tb_cont_workflow_execution + WHERE execution_id = :execution_id + """, + {"execution_id": execution_id}, + ) + row = _fetchone_dict(cur) + if row is None: + raise WorkflowExecutionNotFoundError( + f"execution_id={execution_id!r} nao encontrado" + ) + return row + + def _link_last_message( + self, + cur: Any, + *, + row: dict[str, Any], + purpose: str, + ) -> None: + session_id = row.get("session_id") + message_id = row.get("last_message_id") + if not session_id or not message_id: + return + self._insert_message_link( + cur, + execution_id=str(row["execution_id"]), + session_id=str(session_id), + message_id=str(message_id), + purpose=purpose, + metadata={"workflow": row.get("workflow_name")}, + ) + + @staticmethod + def _insert_message_link( + cur: Any, + *, + execution_id: str, + session_id: str, + message_id: str, + purpose: str, + metadata: dict[str, Any], + ) -> None: + try: + set_json_inputsizes(cur, "metadata_json") + cur.execute( + """ + INSERT INTO tb_cont_workflow_message_link ( + execution_id, + session_id, + message_id, + purpose, + metadata_json + ) VALUES ( + :execution_id, + :session_id, + :message_id, + :purpose, + :metadata_json + ) + """, + { + "execution_id": execution_id, + "session_id": session_id, + "message_id": message_id, + "purpose": purpose, + "metadata_json": normalize_json(metadata), + }, + ) + except Exception as exc: + if is_unique_constraint_error(exc): + return + logger.debug("workflow.message_link.insert_failed", exc_info=True) + + @staticmethod + def _row_to_record(row: Any) -> WorkflowExecutionRecord: + if row is None: + raise WorkflowExecutionNotFoundError("Registro de workflow nao encontrado") + return WorkflowExecutionRecord( + execution_id=str(row["execution_id"]), + workflow_name=str(row["workflow_name"]), + workflow_version=int(row["workflow_version"]), + status=str(row["status"]), # type: ignore[arg-type] + current_node=row["current_node"], + resume_from=row["resume_from"], + expected_input_key=row["expected_input_key"], + created_at=_as_datetime(row["created_at"]), + updated_at=_as_datetime(row["updated_at"]), + ) + + +def _fetchone_dict(cur: Any) -> dict[str, Any] | None: + row = cur.fetchone() + if row is None: + return None + names = [str(col[0]).lower() for col in cur.description] + return dict(zip(names, row, strict=False)) + + +def _as_datetime(value: Any) -> datetime: + if isinstance(value, datetime): + return value + return datetime.fromisoformat(str(value)) diff --git a/legacy_reference/workflows/repositories/__init__.py b/legacy_reference/workflows/repositories/__init__.py new file mode 100644 index 0000000..d5a2e21 --- /dev/null +++ b/legacy_reference/workflows/repositories/__init__.py @@ -0,0 +1,9 @@ +from agente_contas_tim.workflows.repositories.db_repo import DbWorkflowRepository +from agente_contas_tim.workflows.repositories.base import WorkflowRepository +from agente_contas_tim.workflows.repositories.file_repo import FileWorkflowRepository + +__all__ = [ + "WorkflowRepository", + "FileWorkflowRepository", + "DbWorkflowRepository", +] diff --git a/legacy_reference/workflows/repositories/base.py b/legacy_reference/workflows/repositories/base.py new file mode 100644 index 0000000..bd7f6f7 --- /dev/null +++ b/legacy_reference/workflows/repositories/base.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing import Protocol + +from agente_contas_tim.workflows.contracts import WorkflowDef + + +class WorkflowRepository(Protocol): + def get_active(self, name: str) -> WorkflowDef: ... + + def get_version(self, name: str, version: int) -> WorkflowDef: ... diff --git a/legacy_reference/workflows/repositories/db_repo.py b/legacy_reference/workflows/repositories/db_repo.py new file mode 100644 index 0000000..fb28e14 --- /dev/null +++ b/legacy_reference/workflows/repositories/db_repo.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import json +from typing import Any, Protocol + +from agente_contas_tim.workflows.contracts import WorkflowDef +from agente_contas_tim.workflows.exceptions import WorkflowNotFoundError + + +class DbConnection(Protocol): + def execute(self, query: str, params: tuple[Any, ...]): ... + + +class DbWorkflowRepository: + """Repositório de workflow em banco. + + Espera schema lógico: + workflow_definitions(name, version, status, definition_json) + workflow_active(name, version) + """ + + def __init__(self, connection: DbConnection) -> None: + self._connection = connection + + def get_active(self, name: str) -> WorkflowDef: + row = self._connection.execute( + ( + "SELECT d.definition_json " + "FROM workflow_active a " + "JOIN workflow_definitions d " + " ON d.name = a.name AND d.version = a.version " + "WHERE a.name = %s" + ), + (name,), + ).fetchone() + if row is None: + raise WorkflowNotFoundError(f"Workflow {name!r} ativo não encontrado") + return WorkflowDef.model_validate(json.loads(row[0])) + + def get_version(self, name: str, version: int) -> WorkflowDef: + row = self._connection.execute( + ( + "SELECT definition_json " + "FROM workflow_definitions " + "WHERE name = %s AND version = %s" + ), + (name, version), + ).fetchone() + if row is None: + raise WorkflowNotFoundError(f"Workflow {name!r} v{version} não encontrado") + return WorkflowDef.model_validate(json.loads(row[0])) diff --git a/legacy_reference/workflows/repositories/file_repo.py b/legacy_reference/workflows/repositories/file_repo.py new file mode 100644 index 0000000..c80a5e4 --- /dev/null +++ b/legacy_reference/workflows/repositories/file_repo.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from agente_contas_tim.workflows.contracts import WorkflowDef +from agente_contas_tim.workflows.exceptions import ( + WorkflowConfigurationError, + WorkflowNotFoundError, +) + + +class FileWorkflowRepository: + """Repositório de workflow baseado em arquivos no disco. + + Convenções suportadas: + - Versão explícita: ``.v.json|yaml|yml`` + - Ativo por ponteiro: ``.active.json|yaml|yml`` + - Pode conter um inteiro: ``{"version": 3}`` + - Ou conter o workflow completo. + - Sem ponteiro ativo: usa maior versão disponível. + """ + + def __init__(self, base_dir: str | Path) -> None: + self._base_dir = Path(base_dir) + + def get_active(self, name: str) -> WorkflowDef: + self._ensure_base_dir() + active = self._find_active_file(name) + if active is not None: + loaded = self._load_dict(active) + if self._is_workflow_dict(loaded): + return WorkflowDef.model_validate(loaded) + version = loaded.get("version") + if not isinstance(version, int): + raise WorkflowConfigurationError( + f"Arquivo ativo inválido: {active}. " + "Esperado {'version': } ou workflow completo." + ) + return self.get_version(name, version) + + versions = self._list_versions(name) + if not versions: + raise WorkflowNotFoundError(f"Workflow {name!r} não encontrado") + return self.get_version(name, max(versions)) + + def get_version(self, name: str, version: int) -> WorkflowDef: + self._ensure_base_dir() + candidate = self._find_version_file(name, version) + if candidate is not None: + return WorkflowDef.model_validate(self._load_dict(candidate)) + + raise WorkflowNotFoundError( + f"Workflow {name!r} v{version} não encontrado em {self._base_dir}" + ) + + def _ensure_base_dir(self) -> None: + if not self._base_dir.exists(): + raise WorkflowConfigurationError( + f"Diretório de workflows não existe: {self._base_dir}" + ) + + def _find_active_file(self, name: str) -> Path | None: + return self._find_unique_file( + [f"{name}.active.json", f"{name}.active.yaml", f"{name}.active.yml"], + not_found_message=None, + ambiguous_message=( + "Múltiplos ponteiros ativos encontrados para " + f"{name!r} em {self._base_dir}. " + "Mantenha apenas um arquivo .active.." + ), + ) + + def _list_versions(self, name: str) -> list[int]: + versions: list[int] = [] + for file in self._base_dir.rglob(f"{name}.v*.*"): + suffixes = file.suffixes + if not suffixes: + continue + ext = suffixes[-1] + if ext not in {".json", ".yaml", ".yml"}: + continue + stem = file.stem + # stem ex.: vas_decision.v1 + if ".v" not in stem: + continue + version_str = stem.rsplit(".v", maxsplit=1)[-1] + if version_str.isdigit(): + versions.append(int(version_str)) + return versions + + def _find_version_file(self, name: str, version: int) -> Path | None: + return self._find_unique_file( + [ + f"{name}.v{version}.json", + f"{name}.v{version}.yaml", + f"{name}.v{version}.yml", + ], + not_found_message=None, + ambiguous_message=( + f"Múltiplos workflows encontrados para {name!r} v{version} " + f"em {self._base_dir}. Mantenha apenas um arquivo por versão." + ), + ) + + def _find_unique_file( + self, + names: list[str], + *, + not_found_message: str | None, + ambiguous_message: str, + ) -> Path | None: + matches: list[Path] = [] + for file_name in names: + matches.extend(self._base_dir.rglob(file_name)) + + if not matches: + if not_found_message is None: + return None + raise WorkflowConfigurationError(not_found_message) + + unique_matches = sorted({path.resolve() for path in matches}) + if len(unique_matches) > 1: + raise WorkflowConfigurationError(ambiguous_message) + return unique_matches[0] + + def _load_dict(self, path: Path) -> dict[str, Any]: + raw = path.read_text(encoding="utf-8") + + try: + loaded = json.loads(raw) + if not isinstance(loaded, dict): + raise WorkflowConfigurationError( + f"Workflow em {path} deve ser um objeto/dict" + ) + return loaded + except json.JSONDecodeError: + pass + + if path.suffix not in {".yaml", ".yml"}: + raise WorkflowConfigurationError(f"Arquivo inválido: {path}") + + try: + import yaml # type: ignore + except ModuleNotFoundError as exc: + raise WorkflowConfigurationError( + f"Arquivo YAML detectado em {path}, mas PyYAML não está instalado. " + "Instale a dependência 'pyyaml' ou use JSON." + ) from exc + + loaded_yaml = yaml.safe_load(raw) + if not isinstance(loaded_yaml, dict): + raise WorkflowConfigurationError( + f"Workflow YAML em {path} deve ser um objeto/dict" + ) + return loaded_yaml + + @staticmethod + def _is_workflow_dict(data: dict[str, Any]) -> bool: + return {"name", "version", "start", "nodes", "edges"} <= set(data.keys()) diff --git a/legacy_reference/workflows/runtime_types.py b/legacy_reference/workflows/runtime_types.py new file mode 100644 index 0000000..67605c7 --- /dev/null +++ b/legacy_reference/workflows/runtime_types.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, Literal + +ExecutionStatus = Literal["RUNNING", "WAITING_INPUT", "COMPLETED", "FAILED"] + + +def utc_now() -> datetime: + return datetime.now(timezone.utc) + + +@dataclass(frozen=True, slots=True) +class ActionResult: + success: bool + output: dict[str, Any] = field(default_factory=dict) + error: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + @classmethod + def ok( + cls, + output: dict[str, Any] | None = None, + **metadata: Any, + ) -> ActionResult: + return cls(success=True, output=output or {}, metadata=metadata) + + @classmethod + def fail(cls, error: str, **metadata: Any) -> ActionResult: + return cls(success=False, error=error, metadata=metadata) + + +@dataclass(frozen=True, slots=True) +class WorkflowExecutionRecord: + execution_id: str + workflow_name: str + workflow_version: int + status: ExecutionStatus + current_node: str | None + resume_from: str | None + expected_input_key: str | None + created_at: datetime + updated_at: datetime + + +@dataclass(frozen=True, slots=True) +class WorkflowRunResponse: + execution_id: str + status: ExecutionStatus + data: Any = None + error: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) diff --git a/legacy_reference/workflows/service.py b/legacy_reference/workflows/service.py new file mode 100644 index 0000000..3ed53dd --- /dev/null +++ b/legacy_reference/workflows/service.py @@ -0,0 +1,528 @@ +from __future__ import annotations + +from collections.abc import Mapping +import logging +import time +from typing import Any +from uuid import uuid4 + +from agente_contas_tim.agent.llm_gateway import LLMCapabilityGateway +from agente_contas_tim.factory import CommandFactory +from agente_contas_tim.observability import get_session_id +from agente_contas_tim.workflows.actions.discovery import ensure_actions_loaded +from agente_contas_tim.workflows.actions.registry import ( + DEFAULT_ACTION_REGISTRY, + ActionRegistry, + WorkflowRuntimeContext, +) +from agente_contas_tim.workflows.compiler import build_initial_state, compile_workflow +from agente_contas_tim.workflows.execution_store import ( + ExecutionStore, + PostgresExecutionStore, +) +from agente_contas_tim.workflows.exceptions import ( + WorkflowConfigurationError, + WorkflowExecutionStateError, + WorkflowInputError, +) +from agente_contas_tim.workflows.repositories.base import WorkflowRepository +from agente_contas_tim.workflows.runtime_types import WorkflowRunResponse + +logger = logging.getLogger(__name__) + + +class WorkflowService: + def __init__( + self, + repository: WorkflowRepository, + factory: CommandFactory, + *, + llm_gateway: LLMCapabilityGateway | None = None, + postgres_dsn: str | None = None, + action_registry: ActionRegistry | None = None, + execution_store: ExecutionStore | None = None, + checkpointer: Any | None = None, + checkpointer_manager: Any | None = None, + ) -> None: + self._repository = repository + self._factory = factory + self._runtime = WorkflowRuntimeContext( + factory=factory, + llm_gateway=llm_gateway, + workflow_runner=lambda workflow_name, input_payload, execution_id=None, version=None: self.run( + workflow_name, + input_payload, + execution_id=execution_id, + version=version, + ), + ) + self._registry = action_registry or DEFAULT_ACTION_REGISTRY + self._compiled_cache: dict[tuple[str, int], Any] = {} + self._checkpointer_manager = checkpointer_manager + + if execution_store is not None: + self._execution_store = execution_store + else: + if not postgres_dsn: + raise ValueError( + "postgres_dsn e obrigatorio quando execution_store nao e informado" + ) + self._execution_store = PostgresExecutionStore(postgres_dsn) + + if checkpointer is not None: + self._checkpointer = checkpointer + else: + if not postgres_dsn: + raise ValueError( + "postgres_dsn e obrigatorio quando checkpointer nao e informado" + ) + ( + self._checkpointer, + self._checkpointer_manager, + ) = self._create_checkpointer(postgres_dsn) + + ensure_actions_loaded("agente_contas_tim.workflows.actions") + + def run( + self, + workflow_name: str, + input_payload: Mapping[str, Any], + *, + execution_id: str | None = None, + version: int | None = None, + ) -> WorkflowRunResponse: + started_at = time.monotonic() + input_keys = list(input_payload.keys()) if isinstance(input_payload, Mapping) else [] + logger.info( + "workflow.run.start name=%s version=%s execution_id=%s input_keys=%s", + workflow_name, + version, + execution_id, + input_keys, + ) + if execution_id is None: + result = self._start_execution( + workflow_name=workflow_name, + input_payload=dict(input_payload), + version=version, + ) + else: + result = self._resume_execution( + execution_id=execution_id, + workflow_name=workflow_name, + input_payload=dict(input_payload), + version=version, + ) + elapsed_ms = round((time.monotonic() - started_at) * 1000, 2) + logger.info( + "workflow.run.end name=%s execution_id=%s status=%s elapsed_ms=%s", + workflow_name, + result.execution_id, + result.status, + elapsed_ms, + ) + return result + + def _start_execution( + self, + *, + workflow_name: str, + input_payload: dict[str, Any], + version: int | None, + ) -> WorkflowRunResponse: + definition = ( + self._repository.get_version(workflow_name, version) + if version is not None + else self._repository.get_active(workflow_name) + ) + graph = self._get_graph(definition) + execution_id = str(uuid4()) + session_id = self._current_session_id() + message_id = self._extract_message_id(input_payload) + self._execution_store.create( + execution_id=execution_id, + workflow_name=definition.name, + workflow_version=definition.version, + session_id=session_id, + started_by_message_id=message_id, + ) + + initial_state = build_initial_state(definition, input_payload) + config = self._config(execution_id) + + try: + graph.invoke(initial_state, config=config) + except Exception as exc: + self._execution_store.mark_status( + execution_id, + status="FAILED", + current_node=None, + resume_from=None, + expected_input_key=None, + ) + logger.exception("Falha inesperada ao iniciar workflow: %s", exc) + return WorkflowRunResponse( + execution_id=execution_id, + status="FAILED", + error="Erro inesperado ao executar workflow", + metadata={ + "workflow": definition.name, + "version": definition.version, + "error_code": "WORKFLOW_INTERNAL_ERROR", + }, + ) + + return self._build_response( + execution_id, definition.name, definition.version, graph + ) + + def _resume_execution( + self, + *, + execution_id: str, + workflow_name: str, + input_payload: dict[str, Any], + version: int | None, + ) -> WorkflowRunResponse: + record = self._execution_store.claim_resume( + execution_id=execution_id, + workflow_name=workflow_name, + workflow_version=version, + session_id=self._current_session_id(), + message_id=self._extract_message_id(input_payload), + ) + definition = self._repository.get_version( + record.workflow_name, + record.workflow_version, + ) + graph = self._get_graph(definition) + config = self._config(execution_id) + checkpoint_summary = self._checkpoint_debug_summary(config) + logger.info( + "workflow.resume.checkpoint execution_id=%s workflow=%s " + "version=%s checkpoint=%s record_status=%s current_node=%s " + "resume_from=%s expected_input_key=%s", + execution_id, + definition.name, + definition.version, + checkpoint_summary, + record.status, + record.current_node, + record.resume_from, + record.expected_input_key, + ) + + try: + from langgraph.types import Command + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + "langgraph nao esta instalado. " + "Adicione a dependencia para usar workflows." + ) from exc + + try: + graph.invoke(Command(resume=input_payload), config=config) + except WorkflowInputError: + self._execution_store.mark_status( + execution_id, + status="WAITING_INPUT", + current_node=record.current_node, + resume_from=record.resume_from, + expected_input_key=record.expected_input_key, + ) + raise + except Exception as exc: + self._execution_store.mark_status( + execution_id, + status="FAILED", + current_node=None, + resume_from=None, + expected_input_key=None, + ) + logger.info( + "workflow.resume.failed.summary execution_id=%s workflow=%s " + "version=%s error_type=%s error=%s checkpoint=%s " + "record_status=%s current_node=%s resume_from=%s " + "expected_input_key=%s", + execution_id, + definition.name, + definition.version, + type(exc).__name__, + str(exc), + checkpoint_summary, + record.status, + record.current_node, + record.resume_from, + record.expected_input_key, + ) + logger.error( + "workflow.resume.failed execution_id=%s workflow=%s version=%s " + "error_type=%s error=%s checkpoint=%s record_status=%s " + "current_node=%s resume_from=%s expected_input_key=%s", + execution_id, + definition.name, + definition.version, + type(exc).__name__, + str(exc), + checkpoint_summary, + record.status, + record.current_node, + record.resume_from, + record.expected_input_key, + exc_info=True, + ) + return WorkflowRunResponse( + execution_id=execution_id, + status="FAILED", + error="Erro inesperado ao executar workflow", + metadata={ + "workflow": definition.name, + "version": definition.version, + "error_code": "WORKFLOW_INTERNAL_ERROR", + }, + ) + + return self._build_response( + execution_id, definition.name, definition.version, graph + ) + + def _checkpoint_debug_summary(self, config: dict[str, Any]) -> dict[str, Any]: + get_tuple = getattr(self._checkpointer, "get_tuple", None) + if not callable(get_tuple): + return {"available": False, "reason": "checkpointer_without_get_tuple"} + try: + checkpoint_tuple = get_tuple(config) + except Exception as exc: + logger.error( + "workflow.resume.checkpoint_read_failed thread_id=%s " + "error_type=%s error=%s", + config.get("configurable", {}).get("thread_id"), + type(exc).__name__, + str(exc), + exc_info=True, + ) + return { + "available": False, + "read_error_type": type(exc).__name__, + "read_error": str(exc), + } + if checkpoint_tuple is None: + return {"available": False, "reason": "checkpoint_not_found"} + + checkpoint = dict(getattr(checkpoint_tuple, "checkpoint", {}) or {}) + channel_values = dict(checkpoint.get("channel_values") or {}) + pending_writes = list(getattr(checkpoint_tuple, "pending_writes", []) or []) + config_values = dict(getattr(checkpoint_tuple, "config", {}) or {}) + checkpoint_config = dict(config_values.get("configurable", {}) or {}) + return { + "available": True, + "checkpoint_id": checkpoint_config.get("checkpoint_id"), + "channel_keys": sorted(str(key) for key in channel_values.keys()), + "pending_writes_count": len(pending_writes), + "pending_write_channels": sorted( + {str(write[1]) for write in pending_writes if len(write) >= 2} + ), + } + + def _build_response( + self, + execution_id: str, + workflow_name: str, + workflow_version: int, + graph: Any, + ) -> WorkflowRunResponse: + snapshot = graph.get_state(self._config(execution_id)) + values = dict(getattr(snapshot, "values", {}) or {}) + status = str(values.get("status") or "") + if not status: + logger.warning( + "workflow.response.status_missing execution_id=%s workflow=%s " + "version=%s snapshot=%s", + execution_id, + workflow_name, + workflow_version, + self._snapshot_debug_summary(values), + ) + # Salvaguarda: alguns checkpointers podem nao propagar a channel + # `status` ate o snapshot final apos FINISH_NODE. Inferir pelo + # par final_data/final_error setado no action_node antes da + # transicao para o no terminal. + if values.get("final_error"): + status = "FAILED" + elif values.get("final_data") is not None: + status = "COMPLETED" + else: + status = "FAILED" + + if status == "WAITING_INPUT": + pending = values.get("pending_interrupt") + if not isinstance(pending, dict): + raise WorkflowConfigurationError( + f"Workflow {workflow_name!r} pausou sem pending_interrupt" + ) + self._execution_store.mark_status( + execution_id, + status="WAITING_INPUT", + current_node=str(pending.get("node_id", "")) or None, + resume_from=str(pending.get("resume_from", "")) or None, + expected_input_key=str(pending.get("expected_input_key", "")) or None, + ) + return WorkflowRunResponse( + execution_id=execution_id, + status="WAITING_INPUT", + data=pending.get("payload"), + metadata={ + "workflow": workflow_name, + "version": workflow_version, + "paused_at": pending.get("node_id"), + "resume_from": pending.get("resume_from"), + "expected_input_key": pending.get("expected_input_key"), + "allowed_values": pending.get("allowed_values", []), + "normalize": pending.get("normalize"), + }, + ) + + if status == "COMPLETED": + self._execution_store.mark_status( + execution_id, + status="COMPLETED", + current_node=None, + resume_from=None, + expected_input_key=None, + ) + metadata = dict(values.get("final_metadata", {}) or {}) + metadata.setdefault("workflow", workflow_name) + metadata.setdefault("version", workflow_version) + return WorkflowRunResponse( + execution_id=execution_id, + status="COMPLETED", + data=values.get("final_data"), + metadata=metadata, + ) + + if status == "FAILED": + logger.error( + "workflow.response.failed execution_id=%s workflow=%s " + "version=%s snapshot=%s", + execution_id, + workflow_name, + workflow_version, + self._snapshot_debug_summary(values), + ) + self._execution_store.mark_status( + execution_id, + status="FAILED", + current_node=None, + resume_from=None, + expected_input_key=None, + ) + metadata = dict(values.get("final_metadata", {}) or {}) + metadata.setdefault("workflow", workflow_name) + metadata.setdefault("version", workflow_version) + return WorkflowRunResponse( + execution_id=execution_id, + status="FAILED", + data=None, + error=str(values.get("final_error") or "Erro na execucao do workflow"), + metadata=metadata, + ) + + raise WorkflowExecutionStateError( + f"Estado final invalido para workflow: {status!r}" + ) + + @staticmethod + def _snapshot_debug_summary(values: dict[str, Any]) -> dict[str, Any]: + trace = values.get("trace") + trace_tail = trace[-3:] if isinstance(trace, list) else [] + final_metadata = values.get("final_metadata") + return { + "status": values.get("status"), + "current_node": values.get("current_node"), + "last_node": values.get("last_node"), + "final_error": values.get("final_error"), + "final_metadata": ( + final_metadata if isinstance(final_metadata, dict) else {} + ), + "final_data_type": type(values.get("final_data")).__name__, + "pending_interrupt_present": isinstance( + values.get("pending_interrupt"), + dict, + ), + "trace_tail": trace_tail, + "channel_keys": sorted(str(key) for key in values.keys()), + } + + def _get_graph(self, definition: Any) -> Any: + key = (definition.name, definition.version) + graph = self._compiled_cache.get(key) + if graph is not None: + return graph + + graph = compile_workflow( + definition, + action_registry=self._registry, + runtime=self._runtime, + checkpointer=self._checkpointer, + ) + self._compiled_cache[key] = graph + logger.info( + "Workflow compilado com LangGraph: name=%s version=%s", + definition.name, + definition.version, + ) + return graph + + @staticmethod + def _config(execution_id: str) -> dict[str, Any]: + return {"configurable": {"thread_id": execution_id}} + + @staticmethod + def _current_session_id() -> str | None: + session_id = str(get_session_id() or "").strip() + return session_id if session_id and session_id != "-" else None + + @staticmethod + def _extract_message_id(input_payload: Mapping[str, Any]) -> str | None: + for key in ("message_id", "messageId"): + value = str(input_payload.get(key, "") or "").strip() + if value: + return value + return None + + @staticmethod + def _create_checkpointer(postgres_dsn: str) -> tuple[Any, Any | None]: + try: + from langgraph.checkpoint.postgres import PostgresSaver + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + "langgraph.checkpoint.postgres nao esta instalado. " + "Adicione as dependencias para usar workflows com PostgreSQL." + ) from exc + + manager = PostgresSaver.from_conn_string(postgres_dsn) + if hasattr(manager, "__enter__") and hasattr(manager, "__exit__"): + saver = manager.__enter__() + setup = getattr(saver, "setup", None) + if callable(setup): + setup() + return saver, manager + + setup = getattr(manager, "setup", None) + if callable(setup): + setup() + return manager, None + + def close(self) -> None: + close_store = getattr(self._execution_store, "close", None) + if callable(close_store): + close_store() + + manager = getattr(self, "_checkpointer_manager", None) + if manager is not None and hasattr(manager, "__exit__"): + manager.__exit__(None, None, None) + return + + close_checkpointer = getattr(self._checkpointer, "close", None) + if callable(close_checkpointer): + close_checkpointer() diff --git a/legacy_reference/workflows/templating.py b/legacy_reference/workflows/templating.py new file mode 100644 index 0000000..8a2fbb6 --- /dev/null +++ b/legacy_reference/workflows/templating.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from typing import Any + +from agente_contas_tim.workflows.conditions import resolve_value + + +def render_template(template: Any, context: dict[str, Any]) -> Any: + """Resolve templates recursivos com paths $.x.y.""" + if isinstance(template, dict): + return {key: render_template(value, context) for key, value in template.items()} + if isinstance(template, list): + return [render_template(item, context) for item in template] + return resolve_value(template, context) diff --git a/legacy_reference/workflows/transactional/buscar_fatura.active.yaml b/legacy_reference/workflows/transactional/buscar_fatura.active.yaml new file mode 100644 index 0000000..b825518 --- /dev/null +++ b/legacy_reference/workflows/transactional/buscar_fatura.active.yaml @@ -0,0 +1 @@ +version: 1 diff --git a/legacy_reference/workflows/transactional/buscar_fatura.v1.yaml b/legacy_reference/workflows/transactional/buscar_fatura.v1.yaml new file mode 100644 index 0000000..911e545 --- /dev/null +++ b/legacy_reference/workflows/transactional/buscar_fatura.v1.yaml @@ -0,0 +1,16 @@ +name: buscar_fatura +version: 1 +start: buscar_fatura + +nodes: + - id: buscar_fatura + action: buscar_fatura + input: + invoice_id: $.input.invoice_id + msisdn: $.input.msisdn + customer_id: $.input.customer_id + output: $.input.output + +edges: + - from: buscar_fatura + to: END diff --git a/legacy_reference/workflows/transactional/buscar_informacao.active.yaml b/legacy_reference/workflows/transactional/buscar_informacao.active.yaml new file mode 100644 index 0000000..22817d2 --- /dev/null +++ b/legacy_reference/workflows/transactional/buscar_informacao.active.yaml @@ -0,0 +1 @@ +version: 2 diff --git a/legacy_reference/workflows/transactional/buscar_informacao.v1.yaml b/legacy_reference/workflows/transactional/buscar_informacao.v1.yaml new file mode 100644 index 0000000..f21fab3 --- /dev/null +++ b/legacy_reference/workflows/transactional/buscar_informacao.v1.yaml @@ -0,0 +1,16 @@ +name: buscar_informacao +version: 1 +start: buscar_informacao + +nodes: + - id: buscar_informacao + action: buscar_informacao_rag + input: + query: $.input.query + queries: $.input.queries + top_k: $.input.top_k + segment: $.input.segment + +edges: + - from: buscar_informacao + to: END diff --git a/legacy_reference/workflows/transactional/buscar_informacao.v2.yaml b/legacy_reference/workflows/transactional/buscar_informacao.v2.yaml new file mode 100644 index 0000000..eb26864 --- /dev/null +++ b/legacy_reference/workflows/transactional/buscar_informacao.v2.yaml @@ -0,0 +1,28 @@ +name: buscar_informacao +version: 2 +start: buscar_informacao + +nodes: + - id: buscar_informacao + action: buscar_informacao_rag + input: + query: $.input.query + queries: $.input.queries + top_k: $.input.top_k + segment: $.input.segment + + - id: reescrever_resposta + action: reescrever_resposta_buscar_informacao + input: + queries: $.vars.buscar_informacao.queries + documents: $.vars.buscar_informacao.documents + answer: $.vars.buscar_informacao.answer + noMatchRag: $.vars.buscar_informacao.noMatchRag + ragRetrievedDocuments: $.vars.buscar_informacao.ragRetrievedDocuments + ragSelectedDocuments: $.vars.buscar_informacao.ragSelectedDocuments + +edges: + - from: buscar_informacao + to: reescrever_resposta + - from: reescrever_resposta + to: END diff --git a/legacy_reference/workflows/transactional/cancelamento_vas_avulso.active.yaml b/legacy_reference/workflows/transactional/cancelamento_vas_avulso.active.yaml new file mode 100644 index 0000000..8cbe128 --- /dev/null +++ b/legacy_reference/workflows/transactional/cancelamento_vas_avulso.active.yaml @@ -0,0 +1 @@ +version: 1 \ No newline at end of file diff --git a/legacy_reference/workflows/transactional/cancelamento_vas_avulso.v1.yaml b/legacy_reference/workflows/transactional/cancelamento_vas_avulso.v1.yaml new file mode 100644 index 0000000..1724c99 --- /dev/null +++ b/legacy_reference/workflows/transactional/cancelamento_vas_avulso.v1.yaml @@ -0,0 +1,20 @@ +name: cancelamento_vas_avulso +version: 1 +start: cancelar_vas_avulso + +nodes: + - id: cancelar_vas_avulso + action: cancelamento_vas_avulso_batch + input: + items: $.input.items + csp_id: $.input.csp_id + channel: $.input.channel + social_sec_no: $.input.social_sec_no + request_status: "Fechado" + status: "CLOSED" + data_credito_proxima_fatura: $.input.data_credito_proxima_fatura + idempotency_key: $.input.idempotency_key + +edges: + - from: cancelar_vas_avulso + to: END diff --git a/legacy_reference/workflows/transactional/finalizar_atendimento.active.yaml b/legacy_reference/workflows/transactional/finalizar_atendimento.active.yaml new file mode 100644 index 0000000..b825518 --- /dev/null +++ b/legacy_reference/workflows/transactional/finalizar_atendimento.active.yaml @@ -0,0 +1 @@ +version: 1 diff --git a/legacy_reference/workflows/transactional/finalizar_atendimento.v1.yaml b/legacy_reference/workflows/transactional/finalizar_atendimento.v1.yaml new file mode 100644 index 0000000..f1f1962 --- /dev/null +++ b/legacy_reference/workflows/transactional/finalizar_atendimento.v1.yaml @@ -0,0 +1,14 @@ +name: finalizar_atendimento +version: 1 +start: finalizar + +nodes: + - id: finalizar + action: finalizar_atendimento_action + input: + status: $.input.status + summary: $.input.summary + +edges: + - from: finalizar + to: END diff --git a/legacy_reference/workflows/transactional/termino_desconto.active.yaml b/legacy_reference/workflows/transactional/termino_desconto.active.yaml new file mode 100644 index 0000000..b825518 --- /dev/null +++ b/legacy_reference/workflows/transactional/termino_desconto.active.yaml @@ -0,0 +1 @@ +version: 1 diff --git a/legacy_reference/workflows/transactional/termino_desconto.v1.yaml b/legacy_reference/workflows/transactional/termino_desconto.v1.yaml new file mode 100644 index 0000000..85fd1e4 --- /dev/null +++ b/legacy_reference/workflows/transactional/termino_desconto.v1.yaml @@ -0,0 +1,15 @@ +name: termino_desconto +version: 1 +start: formatar + +nodes: + - id: formatar + action: formatar_capability_resposta + input: + tipo: termino_desconto + msisdn: $.input.msisdn + nome_plano: $.input.nome_plano + +edges: + - from: formatar + to: END diff --git a/legacy_reference/workflows/transactional/valor_divergente.active.yaml b/legacy_reference/workflows/transactional/valor_divergente.active.yaml new file mode 100644 index 0000000..b825518 --- /dev/null +++ b/legacy_reference/workflows/transactional/valor_divergente.active.yaml @@ -0,0 +1 @@ +version: 1 diff --git a/legacy_reference/workflows/transactional/valor_divergente.v1.yaml b/legacy_reference/workflows/transactional/valor_divergente.v1.yaml new file mode 100644 index 0000000..f82f6fe --- /dev/null +++ b/legacy_reference/workflows/transactional/valor_divergente.v1.yaml @@ -0,0 +1,14 @@ +name: valor_divergente +version: 1 +start: formatar + +nodes: + - id: formatar + action: formatar_capability_resposta + input: + tipo: valor_divergente + msisdn: $.input.msisdn + +edges: + - from: formatar + to: END diff --git a/llm_profiles.yaml b/llm_profiles.yaml new file mode 100644 index 0000000..15a1cd0 --- /dev/null +++ b/llm_profiles.yaml @@ -0,0 +1,88 @@ +# Optional file. If this file is absent, the backend keeps using .env exactly as before. +# If present, each inference point can override provider/model/params. +profiles: + default: + provider: oci_openai + model: openai.gpt-4.1 + temperature: 0.2 + max_tokens: 2048 + + # Workflow/routing + supervisor: + provider: oci_openai + model: openai.gpt-4.1 + temperature: 0 + max_tokens: 700 + + router: + provider: oci_openai + model: openai.gpt-4.1 + temperature: 0 + max_tokens: 500 + + # Safety / evaluation + guardrail: + provider: oci_openai + model: openai.gpt-4.1 + temperature: 0 + max_tokens: 600 + + grl: + provider: oci_openai + model: openai.gpt-4.1 + temperature: 0 + max_tokens: 700 + + judge: + provider: oci_openai + model: openai.gpt-4.1 + temperature: 0 + max_tokens: 800 + + # RAG + rag_rewriter: + provider: oci_openai + model: openai.gpt-4.1 + temperature: 0 + max_tokens: 300 + + rag_compressor: + provider: oci_openai + model: openai.gpt-4.1 + temperature: 0 + max_tokens: 1200 + + rag_generation: + provider: oci_openai + model: xopenai.gpt-4.1 + temperature: 0.1 + max_tokens: 1800 + + # Memory / operations + summary_memory: + provider: oci_openai + model: openai.gpt-4.1 + temperature: 0.1 + max_tokens: 1200 + + noc: + provider: oci_openai + model: openai.gpt-4.1 + temperature: 0 + max_tokens: 700 + + # Agent-specific overrides + billing_agent: + provider: oci_openai + model: openai.gpt-4.1 + temperature: 0.2 + + product_agent: + provider: oci_openai + model: openai.gpt-4.1 + temperature: 0.2 + + backoffice_agent: + provider: oci_openai + model: openai.gpt-4.1 + temperature: 0.2 diff --git a/mcp_servers/.DS_Store b/mcp_servers/.DS_Store new file mode 100644 index 0000000..e21a860 Binary files /dev/null and b/mcp_servers/.DS_Store differ diff --git a/mcp_servers/legacy_tim_mcp/.DS_Store b/mcp_servers/legacy_tim_mcp/.DS_Store new file mode 100644 index 0000000..5b022e5 Binary files /dev/null and b/mcp_servers/legacy_tim_mcp/.DS_Store differ diff --git a/mcp_servers/legacy_tim_mcp/.env.example b/mcp_servers/legacy_tim_mcp/.env.example new file mode 100644 index 0000000..74e2f2a --- /dev/null +++ b/mcp_servers/legacy_tim_mcp/.env.example @@ -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= diff --git a/mcp_servers/legacy_tim_mcp/README.md b/mcp_servers/legacy_tim_mcp/README.md new file mode 100644 index 0000000..dabb621 --- /dev/null +++ b/mcp_servers/legacy_tim_mcp/README.md @@ -0,0 +1,28 @@ +# Legacy TIM MCP Server + +Servidor MCP HTTP simples para adaptar os serviços externos do `agent_contas_first` ao contrato esperado pelo `agent_framework_oci`: + +- `GET /mcp/tools/list` +- `POST /mcp/tools/call` com `{ "tool_name": "...", "arguments": {...} }` + +Por padrão roda com integração real (`TIM_MCP_USE_MOCK=false`). Para teste local sem APIs TIM, configure explicitamente `TIM_MCP_USE_MOCK=true`. + +## Subir local + +```bash +cd 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 direto + +```bash +curl -s http://localhost:8100/health +curl -s http://localhost:8100/mcp/tools/call \ + -H 'Content-Type: application/json' \ + -d '{"tool_name":"consultar_fatura","arguments":{"msisdn":"11999999999","invoice_id":"FAT-2026-06"}}' +``` diff --git a/mcp_servers/legacy_tim_mcp/__pycache__/main.cpython-313.pyc b/mcp_servers/legacy_tim_mcp/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..378f819 Binary files /dev/null and b/mcp_servers/legacy_tim_mcp/__pycache__/main.cpython-313.pyc differ diff --git a/mcp_servers/legacy_tim_mcp/main.py b/mcp_servers/legacy_tim_mcp/main.py new file mode 100644 index 0000000..840ae46 --- /dev/null +++ b/mcp_servers/legacy_tim_mcp/main.py @@ -0,0 +1,657 @@ +from __future__ import annotations + +import os +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Awaitable +from urllib.parse import urlencode, urlsplit, urlunsplit, parse_qsl + +import httpx +from dotenv import dotenv_values +from fastapi import FastAPI +from pydantic import BaseModel, Field + + +def _hydrate_env_from_dotenv_files() -> list[str]: + """Load .env values even when the MCP server is started from another directory. + + The previous package depended on `source .env` or Docker `env_file`. When the + user started uvicorn directly, variables like TIM_COMPLETE_INVOICES_URL were + absent and the MCP returned `Endpoint externo não configurado`. + + This loader reads both the repository root .env and the MCP-local .env, and + fills only variables that are missing or exported as an empty string. + """ + here = Path(__file__).resolve() + candidates = [] + for candidate in [ + Path.cwd() / ".env", + here.parent / ".env", + here.parents[2] / ".env" if len(here.parents) > 2 else None, + ]: + if candidate and candidate.exists() and candidate not in candidates: + candidates.append(candidate) + + loaded: list[str] = [] + for env_file in candidates: + values = dotenv_values(env_file) + for key, value in values.items(): + if key and value is not None and not os.getenv(key): + os.environ[key] = str(value) + loaded.append(str(env_file)) + return loaded + + +LOADED_ENV_FILES = _hydrate_env_from_dotenv_files() + +APP_NAME = os.getenv("APP_NAME", "legacy-tim-mcp-server") +USE_MOCK = os.getenv("TIM_MCP_USE_MOCK", "false").lower() in {"1", "true", "yes", "y"} +DEFAULT_TIMEOUT = float(os.getenv("TIM_MCP_TIMEOUT_SECONDS", os.getenv("TIM_GATEWAY_DEFAULT_TIMEOUT", "30"))) +DEFAULT_CLIENT_ID = os.getenv("TIM_CLIENT_ID", os.getenv("TIM_DEFAULT_CLIENT_ID", "AIAGENTCR")) +DEFAULT_USER_ID = os.getenv("TIM_USER_ID", "AIAGENTCR") +DEFAULT_CHANNEL = os.getenv("TIM_DEFAULT_CHANNEL", "APP") +DEFAULT_CSP_ID = os.getenv("TIM_DEFAULT_CSP_ID", "740") + + +def _first_env(*names: str, default: str = "") -> str: + for name in names: + value = os.getenv(name) + if value is not None and str(value).strip() != "": + return str(value).strip().strip('"').strip("'") + return default + + +@dataclass(frozen=True) +class EndpointConfig: + name: str + url: str + auth: str = "" + timeout: float = DEFAULT_TIMEOUT + client_id: str = DEFAULT_CLIENT_ID + extra_headers: dict[str, str] = field(default_factory=dict) + + +class GatewayError(RuntimeError): + """Erro rico no padrão do legado: status, body, headers, request id e provider.""" + + def __init__( + self, + message: str, + *, + endpoint: str, + method: str, + url: str, + status_code: int | None = None, + error_code: str | None = None, + error_body: Any = None, + error_headers: dict[str, str] | None = None, + request_payload: Any = None, + response_text: str | None = None, + latency_ms: int | None = None, + exception_type: str = "GatewayError", + ) -> None: + super().__init__(message) + self.endpoint = endpoint + self.method = method + self.url = url + self.status_code = status_code + self.error_code = error_code + self.error_body = error_body + self.error_headers = error_headers or {} + self.request_payload = request_payload + self.response_text = response_text + self.latency_ms = latency_ms + self.exception_type = exception_type + + @property + def metadata(self) -> dict[str, Any]: + provider: dict[str, Any] = {} + if isinstance(self.error_body, dict) and isinstance(self.error_body.get("provider"), dict): + provider = self.error_body.get("provider") or {} + return { + "endpoint": self.endpoint, + "method": self.method, + "url": self.url, + "status_code": self.status_code, + "error_code": self.error_code, + "provider_service": provider.get("serviceName"), + "provider_error_code": provider.get("errorCode"), + "provider_error_message": provider.get("errorMessage"), + "message_id": _get_header_value(self.error_headers, "Messageid", "MessageId"), + "kong_request_id": _get_header_value(self.error_headers, "X-Kong-Request-Id"), + "latency_ms": self.latency_ms, + "exception_type": self.exception_type, + "request_payload_preview": _preview_json(self.request_payload), + "response_body_preview": _preview_json(self.error_body if self.error_body is not None else self.response_text), + "headers": _safe_headers(self.error_headers), + "mock": USE_MOCK, + } + + +def _preview_json(value: Any, limit: int = 2000) -> str: + if value is None: + return "" + text = str(value) + return text if len(text) <= limit else text[:limit] + "..." + + +def _get_header_value(headers: dict[str, str] | None, *keys: str) -> str: + normalized = {str(k).lower(): str(v) for k, v in (headers or {}).items()} + for key in keys: + value = normalized.get(key.lower(), "").strip() + if value: + return value + return "" + + +def _safe_headers(headers: dict[str, str] | None) -> dict[str, str]: + safe: dict[str, str] = {} + for k, v in (headers or {}).items(): + lk = str(k).lower() + if lk in {"authorization", "authorizationoam", "authorization-oam", "proxy-authorization"} or "token" in lk or "secret" in lk: + safe[k] = "***" if v else "" + else: + safe[k] = str(v) + return safe + + +def _append_path(url: str, *parts: str) -> str: + base = url.rstrip("/") + suffix = "/".join(str(p).strip("/") for p in parts if str(p or "").strip("/")) + return f"{base}/{suffix}" if suffix else base + + +def _add_query(url: str, params: dict[str, Any]) -> str: + clean = {k: v for k, v in params.items() if v is not None and str(v) != ""} + if not clean: + return url + parts = urlsplit(url) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query.update(clean) + return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)) + + +ENDPOINTS: dict[str, tuple[tuple[str, ...], tuple[str, ...], str, tuple[str, ...]]] = { + "TIM_COMPLETE_INVOICES": (("TIM_COMPLETE_INVOICES_URL",), ("TIM_COMPLETE_INVOICES_AUTH",), "", ("TIM_COMPLETE_INVOICES_TIMEOUT",)), + "TIM_PROFILE_BILL": (("TIM_PROFILE_BILL_URL", "TIM_URL_PERFIL_FATURA"), ("TIM_PROFILE_BILL_AUTH", "TIM_COMPLETE_INVOICES_AUTH"), "", ("TIM_PROFILE_BILL_TIMEOUT",)), + "TIM_CONTRATO": (("TIM_CONTRATO_URL",), ("TIM_CONTRATO_AUTH",), "", ("TIM_CONTRATO_TIMEOUT",)), + "TIM_QUERY_VAS": (("TIM_QUERY_VAS_URL", "TIM_URL_CONSULTA_VAS", "TIM_CONSULTA_URL"), ("TIM_QUERY_VAS_AUTH", "TIM_CONSULTA_AUTH"), "", ("TIM_QUERY_VAS_TIMEOUT", "TIM_CONSULTA_TIMEOUT")), + "TIM_BILL_PDF": (("TIM_BILL_PDF_URL", "TIM_URL_INVOICE_RECOVER"), ("TIM_BILL_PDF_AUTH", "TIM_INVOICE_RECOVER_AUTH"), "", ("TIM_BILL_PDF_TIMEOUT", "TIM_INVOICE_RECOVER_TIMEOUT")), + "TIM_PROTOCOL": (("TIM_PROTOCOL_URL",), ("TIM_PROTOCOL_AUTH",), "", ("TIM_PROTOCOL_TIMEOUT",)), + "TIM_CUSTOMER_CONTESTATION": (("TIM_CUSTOMER_CONTESTATION_URL", "TIM_DIVERGENCIA_URL"), ("TIM_CUSTOMER_CONTESTATION_AUTH", "TIM_DIVERGENCIA_AUTH"), "", ("TIM_CUSTOMER_CONTESTATION_TIMEOUT",)), + "TIM_SERVICE_REQUEST_STATUS": (("TIM_SERVICE_REQUEST_STATUS_URL",), ("TIM_SERVICE_REQUEST_STATUS_AUTH",), "", ("TIM_SERVICE_REQUEST_STATUS_TIMEOUT",)), + "TIM_CANCEL_VAS": (("TIM_CANCEL_VAS_URL", "TIM_CANCELLATION_URL", "TIM_CANCELAMENTO_URL"), ("TIM_CANCEL_VAS_AUTH", "TIM_CANCELLATION_AUTH", "TIM_CANCELAMENTO_AUTH"), "", ("TIM_CANCEL_VAS_TIMEOUT", "TIM_CANCELAMENTO_TIMEOUT")), + "TIM_BLOCK_VAS": (("TIM_BLOCK_VAS_URL", "TIM_URL_BLOQUEIO_VAS", "TIM_BLOQUEIO_URL"), ("TIM_BLOCK_VAS_AUTH", "TIM_BLOQUEIO_AUTH"), "", ("TIM_BLOCK_VAS_TIMEOUT", "TIM_BLOQUEIO_TIMEOUT")), + "TIM_SMS": (("TIM_SMS_URL",), ("TIM_SMS_AUTH", "SMS_BARCODE_AUTH"), "", ("TIM_SMS_TIMEOUT",)), + "TIM_TRACKING_ACTIVITIES": (("TIM_TRACKING_ACTIVITIES_URL",), ("TIM_TRACKING_ACTIVITIES_AUTH",), "", ("TIM_TRACKING_ACTIVITIES_TIMEOUT",)), +} + + +def endpoint_config(name: str) -> EndpointConfig: + url_names, auth_names, default_url, timeout_names = ENDPOINTS[name] + timeout_raw = _first_env(*timeout_names, default=str(DEFAULT_TIMEOUT)) + try: + timeout = float(timeout_raw) + except ValueError: + timeout = DEFAULT_TIMEOUT + return EndpointConfig( + name=name, + url=_first_env(*url_names, default=default_url), + auth=_first_env(*auth_names, "TIM_DEFAULT_AUTH", default=""), + timeout=timeout, + client_id=_first_env(f"{name}_CLIENT_ID", "TIM_CLIENT_ID", "TIM_DEFAULT_CLIENT_ID", default=DEFAULT_CLIENT_ID), + ) + + +class HttpApiGateway: + async def request( + self, + method: str, + config: EndpointConfig, + *, + url: str | None = None, + payload: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + ) -> dict[str, Any]: + if not config.url: + raise GatewayError( + f"Endpoint externo não configurado para {config.name}", + endpoint=config.name, + method=method, + url=url or "", + request_payload=payload, + exception_type="MissingEndpoint", + ) + + target_url = url or config.url + request_headers = {"Accept": "application/json", **(headers or {})} + if config.auth and "Authorization" not in request_headers: + request_headers["Authorization"] = config.auth + if payload is not None: + request_headers.setdefault("Content-Type", "application/json") + + t0 = time.monotonic() + try: + async with httpx.AsyncClient(timeout=config.timeout) as client: + resp = await client.request(method, target_url, json=payload if method.upper() != "GET" else None, headers=request_headers) + latency_ms = int((time.monotonic() - t0) * 1000) + parsed: Any + try: + parsed = resp.json() + except Exception: + parsed = {"raw_text": resp.text} + metadata = { + "endpoint": config.name, + "method": method, + "url": target_url, + "status_code": resp.status_code, + "latency_ms": latency_ms, + "request_headers": _safe_headers(request_headers), + "response_headers": _safe_headers(dict(resp.headers)), + "request_payload_preview": _preview_json(payload), + "response_body_preview": _preview_json(parsed), + "mock": False, + } + if resp.status_code >= 400: + code = None + if isinstance(parsed, dict): + code = parsed.get("error_code") or parsed.get("code") or parsed.get("errorCode") or f"HTTP_{resp.status_code}" + raise GatewayError( + f"{config.name} falhou: status={resp.status_code} body={_preview_json(parsed, 500)}", + endpoint=config.name, + method=method, + url=target_url, + status_code=resp.status_code, + error_code=str(code or f"HTTP_{resp.status_code}"), + error_body=parsed, + error_headers=dict(resp.headers), + request_payload=payload, + latency_ms=latency_ms, + exception_type="HTTPStatusError", + ) + if isinstance(parsed, dict): + parsed.setdefault("metadata", metadata) + return parsed + return {"value": parsed, "metadata": metadata} + except GatewayError: + raise + except httpx.TimeoutException as exc: + latency_ms = int((time.monotonic() - t0) * 1000) + raise GatewayError( + f"{config.name} timeout após {config.timeout}s: {exc}", + endpoint=config.name, + method=method, + url=target_url, + request_payload=payload, + latency_ms=latency_ms, + exception_type="TimeoutException", + ) from exc + except httpx.RequestError as exc: + latency_ms = int((time.monotonic() - t0) * 1000) + raise GatewayError( + f"{config.name} erro de transporte: {exc}", + endpoint=config.name, + method=method, + url=target_url, + request_payload=payload, + latency_ms=latency_ms, + exception_type=exc.__class__.__name__, + ) from exc + + async def get(self, config: EndpointConfig, url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]: + return await self.request("GET", config, url=url, headers=headers) + + async def post(self, config: EndpointConfig, payload: dict[str, Any], url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]: + print("===========================================================================") + print(config, payload) + print("===========================================================================") + return await self.request("POST", config, url=url, payload=payload, headers=headers) + + async def delete(self, config: EndpointConfig, payload: dict[str, Any] | None = None, url: str | None = None, headers: dict[str, str] | None = None) -> dict[str, Any]: + return await self.request("DELETE", config, url=url, payload=payload, headers=headers) + + +def _common_headers(config: EndpointConfig, *, client_id: str | None = None) -> dict[str, str]: + h = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if config.auth: + h["Authorization"] = config.auth + cid = client_id or config.client_id or DEFAULT_CLIENT_ID + if cid: + h["ClientID"] = cid + h["clientId"] = cid + return h + + +def _arg(args: dict[str, Any], *names: str, default: Any = "") -> Any: + for name in names: + value = args.get(name) + if value is not None and str(value).strip() != "": + return value + return default + + +class LegacyTimCommand: + command_name = "LegacyTimCommand" + endpoint_name = "" + + def __init__(self, args: dict[str, Any], gateway: HttpApiGateway) -> None: + self.args = args or {} + self.gateway = gateway + self.config = endpoint_config(self.endpoint_name) + + async def execute(self) -> dict[str, Any]: + raise NotImplementedError + + def envelope(self, raw: dict[str, Any], *, normalized: dict[str, Any] | None = None) -> dict[str, Any]: + meta = raw.get("metadata", {}) if isinstance(raw, dict) else {} + return { + "command": self.command_name, + "endpoint": self.endpoint_name, + "ok": True, + "data": raw, + "normalized": normalized or {}, + "metadata": {**meta, "command": self.command_name, "endpoint": self.endpoint_name, "mock": USE_MOCK}, + } + + +class CompleteInvoicesCommand(LegacyTimCommand): + command_name = "CompleteInvoicesCommand" + endpoint_name = "TIM_COMPLETE_INVOICES" + + async def execute(self) -> dict[str, Any]: + msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) + payload = {"msisdn": msisdn} + invoice_id = _arg(self.args, "invoice_id", "invoiceId", "contract_key") + if invoice_id: + payload["invoiceId"] = str(invoice_id) + raw = await self.gateway.post(self.config, payload, headers=_common_headers(self.config, client_id=_first_env("TIM_COMPLETE_INVOICES_CLIENT_ID", default=DEFAULT_CLIENT_ID))) + items = raw.get("paymentItems") if isinstance(raw, dict) else None + selected = None + if isinstance(items, list) and invoice_id: + for item in items: + if isinstance(item, dict) and str(item.get("id") or item.get("invoiceId") or item.get("number") or "") == str(invoice_id): + selected = item + break + if selected is None and isinstance(items, list) and items: + selected = next((i for i in items if isinstance(i, dict)), None) + normalized = { + "msisdn": msisdn, + "invoice_id": invoice_id or (selected or {}).get("id") or (selected or {}).get("invoiceId"), + "amount": (selected or {}).get("amount") or (selected or {}).get("totalAmount") or raw.get("totalAmount") if isinstance(raw, dict) else None, + "due_date": (selected or {}).get("dueDate") or (selected or {}).get("expirationDate"), + "status": (selected or {}).get("status"), + "payment_items_count": len(items) if isinstance(items, list) else None, + } + return self.envelope(raw, normalized=normalized) + + +class ProfileBillCommand(CompleteInvoicesCommand): + command_name = "ProfileBillCommand" + endpoint_name = "TIM_PROFILE_BILL" + + +class QueryVasCommand(LegacyTimCommand): + command_name = "QueryVasCommand" + endpoint_name = "TIM_QUERY_VAS" + + async def execute(self) -> dict[str, Any]: + msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) + url = _append_path(self.config.url, msisdn) + url = _add_query(url, {"clientid": _first_env("TIM_CONSULTA_CLIENT_ID", "TIM_CLIENT_ID", default=DEFAULT_CLIENT_ID)}) + raw = await self.gateway.get(self.config, url=url, headers=_common_headers(self.config)) + services = raw.get("services") or raw.get("items") or raw.get("products") if isinstance(raw, dict) else None + normalized = {"msisdn": msisdn, "service_count": len(services) if isinstance(services, list) else None} + return self.envelope(raw, normalized=normalized) + + +class ContractInformationCommand(LegacyTimCommand): + command_name = "ContractInformationCommand" + endpoint_name = "TIM_CONTRATO" + + async def execute(self) -> dict[str, Any]: + msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) + url = _append_path(self.config.url, msisdn) + raw = await self.gateway.get(self.config, url=url, headers=_common_headers(self.config)) + return self.envelope(raw, normalized={"msisdn": msisdn, "plan": raw.get("plan") if isinstance(raw, dict) else None}) + + +class InvoiceRecoverCommand(LegacyTimCommand): + command_name = "InvoiceRecoverCommand" + endpoint_name = "TIM_BILL_PDF" + + async def execute(self) -> dict[str, Any]: + msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) + invoice_id = str(_arg(self.args, "invoice_id", "invoiceId", "contract_key")) + url = _add_query(self.config.url, {"invoiceId": invoice_id, "msisdn": msisdn, "customerId": _arg(self.args, "customer_id", "customerId")}) + raw = await self.gateway.get(self.config, url=url, headers=_common_headers(self.config, client_id=_first_env("TIM_INVOICE_RECOVER_CLIENT_ID", "TIM_BILL_PDF_CLIENT_ID", default="TIMX"))) + return self.envelope(raw, normalized={"msisdn": msisdn, "invoice_id": invoice_id, "pdf_available": bool(raw)}) + + +class ProtocolCommand(LegacyTimCommand): + command_name = "ProtocolCommand" + endpoint_name = "TIM_PROTOCOL" + + async def execute(self) -> dict[str, Any]: + msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) + payload = { + "customer": {"msisdn": msisdn}, + "protocolNumber": _arg(self.args, "protocol_number", "protocolNumber"), + "reason": _arg(self.args, "reason", default="Atendimento Agent Contas FIRST"), + "channel": _arg(self.args, "channel", default=DEFAULT_CHANNEL), + "interactionCallId": _arg(self.args, "interaction_call_id", "ura_call_id", "message_id"), + } + raw = await self.gateway.post(self.config, payload, headers=_common_headers(self.config)) + return self.envelope(raw, normalized={"msisdn": msisdn, "protocol_number": raw.get("protocolNumber") or payload["protocolNumber"] if isinstance(raw, dict) else payload["protocolNumber"]}) + + +class CustomerContestationCommand(LegacyTimCommand): + command_name = "CustomerContestationCommand" + endpoint_name = "TIM_CUSTOMER_CONTESTATION" + + async def execute(self) -> dict[str, Any]: + msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) + items = _arg(self.args, "items", default=[]) + if not isinstance(items, list): + items = [items] + payload = { + "customer": {"msisdn": msisdn}, + "invoice": {"number": _arg(self.args, "invoice_id", "invoice_number", "contract_key")}, + "contestation": { + "reason": _arg(self.args, "reason", default="Contestação solicitada pelo atendimento"), + "claimedAmount": _arg(self.args, "amount", "claimed_amount", default=0), + "items": items, + }, + "user": {"login": _first_env("TIM_CUSTOMER_CONTESTATION_USER_ID", default=DEFAULT_USER_ID)}, + "clientId": _first_env("TIM_CUSTOMER_CONTESTATION_CLIENT_ID", default=DEFAULT_CLIENT_ID), + } + raw = await self.gateway.post(self.config, payload, headers=_common_headers(self.config, client_id=payload["clientId"])) + return self.envelope(raw, normalized={"msisdn": msisdn, "sr": raw.get("sr") or raw.get("serviceRequest") if isinstance(raw, dict) else None}) + + +class ServiceRequestStatusCommand(LegacyTimCommand): + command_name = "ServiceRequestStatusCommand" + endpoint_name = "TIM_SERVICE_REQUEST_STATUS" + + async def execute(self) -> dict[str, Any]: + protocol = _arg(self.args, "sr", "protocol_number", "protocolNumber", "service_request") + msisdn = _arg(self.args, "msisdn", "customer_id") + url = _add_query(self.config.url, {"protocolNumber": protocol, "serviceRequest": protocol, "msisdn": msisdn}) + raw = await self.gateway.get(self.config, url=url, headers=_common_headers(self.config)) + return self.envelope(raw, normalized={"protocol_number": protocol, "status": raw.get("status") if isinstance(raw, dict) else None}) + + +class CancelVasCommand(LegacyTimCommand): + command_name = "CancelVasCommand" + endpoint_name = "TIM_CANCEL_VAS" + + async def execute(self) -> dict[str, Any]: + msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) + service_id = _arg(self.args, "service_id", "app_id", "product_id") + payload = {"msisdn": msisdn, "serviceId": service_id, "cspId": _arg(self.args, "csp_id", default=DEFAULT_CSP_ID)} + raw = await self.gateway.delete(self.config, payload, headers=_common_headers(self.config)) + return self.envelope(raw, normalized={"msisdn": msisdn, "service_id": service_id, "cancelled": True}) + + +class BlockVasCommand(LegacyTimCommand): + command_name = "BlockVasCommand" + endpoint_name = "TIM_BLOCK_VAS" + + async def execute(self) -> dict[str, Any]: + msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) + payload = { + "msisdn": msisdn, + "operationType": _first_env("TIM_BLOCK_VAS_OPERATION_TYPE", "TIM_BLOQUEIO_OPERATION_TYPE", default="block"), + "cspId": _arg(self.args, "csp_id", default=DEFAULT_CSP_ID), + "serviceId": _arg(self.args, "service_id", "app_id", "product_id"), + } + headers = _common_headers(self.config) + accept_encoding = _first_env("TIM_BLOCK_VAS_ACCEPT_ENCODING", "TIM_BLOQUEIO_ACCEPT_ENCODING", default="") + if accept_encoding: + headers["Accept-Encoding"] = accept_encoding + raw = await self.gateway.post(self.config, payload, headers=headers) + return self.envelope(raw, normalized={"msisdn": msisdn, "blocked": True}) + + +class SmsCommand(LegacyTimCommand): + command_name = "SmsCommand" + endpoint_name = "TIM_SMS" + + async def execute(self) -> dict[str, Any]: + msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) + payload = { + "msisdn": msisdn, + "message": _arg(self.args, "message", default=""), + "longUrl": _arg(self.args, "long_url", "longUrl", default=""), + "notifyUrl": _arg(self.args, "notify_url", "notifyUrl", default=""), + } + raw = await self.gateway.post(self.config, payload, headers=_common_headers(self.config, client_id=_first_env("TIM_SMS_CLIENT_ID", default=""))) + return self.envelope(raw, normalized={"msisdn": msisdn, "sent": True}) + + +class TrackingActivitiesCommand(LegacyTimCommand): + command_name = "TrackingActivitiesCommand" + endpoint_name = "TIM_TRACKING_ACTIVITIES" + + async def execute(self) -> dict[str, Any]: + msisdn = str(_arg(self.args, "msisdn", "customer_id", "customer_key")) + payload = { + "channel": _first_env("TIM_TRACKING_ACTIVITIES_CHANNEL", default=DEFAULT_CHANNEL), + "customer": {"msisdn": msisdn}, + "protocolNumber": _arg(self.args, "protocol_number", "protocolNumber"), + "invoice": {"number": _arg(self.args, "invoice_id", "invoice_number")}, + "activity": { + "type": _arg(self.args, "activity_type", default="ATENDIMENTO"), + "status": _arg(self.args, "activity_status", default="OPEN"), + "id": _arg(self.args, "activity_id", default=""), + }, + "user": {"login": _first_env("TIM_TRACKING_ACTIVITIES_USER_LOGIN", default=DEFAULT_USER_ID)}, + } + raw = await self.gateway.post(self.config, payload, headers=_common_headers(self.config, client_id=_first_env("TIM_TRACKING_ACTIVITIES_CLIENT_ID", default=DEFAULT_CLIENT_ID))) + return self.envelope(raw, normalized={"msisdn": msisdn, "tracked": True}) + + +COMMANDS: dict[str, type[LegacyTimCommand]] = { + "consultar_fatura": CompleteInvoicesCommand, + "consultar_pagamentos": ProfileBillCommand, + "consultar_plano": ContractInformationCommand, + "listar_servicos": QueryVasCommand, + "recuperar_pdf_fatura": InvoiceRecoverCommand, + "registrar_protocolo": ProtocolCommand, + "abrir_contestacao": CustomerContestationCommand, + "consultar_status_sr": ServiceRequestStatusCommand, + "atualizar_status_sr": ServiceRequestStatusCommand, + "cancelar_vas": CancelVasCommand, + "bloquear_vas": BlockVasCommand, + "enviar_sms": SmsCommand, + "registrar_tracking": TrackingActivitiesCommand, +} + + +MOCKS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = { + "consultar_fatura": lambda a: {"paymentItems": [{"id": _arg(a, "invoice_id", default="3000131180"), "amount": 123.45, "dueDate": "2026-06-20", "status": "em aberto"}]}, + "consultar_pagamentos": lambda a: {"payments": []}, + "consultar_plano": lambda a: {"plan": "TIM Controle Smart"}, + "listar_servicos": lambda a: {"services": []}, +} + + +class ToolCall(BaseModel): + tool_name: str = Field(..., alias="tool_name") + arguments: dict[str, Any] = Field(default_factory=dict) + + +app = FastAPI(title=APP_NAME) + + +@app.get("/health") +async def health() -> dict[str, Any]: + configured = {name: bool(endpoint_config(name).url) for name in ENDPOINTS} + urls = {name: endpoint_config(name).url for name in ENDPOINTS} + return { + "ok": True, + "app": APP_NAME, + "mock": USE_MOCK, + "tool_count": len(COMMANDS), + "loaded_env_files": LOADED_ENV_FILES, + "configured_endpoints": configured, + "endpoint_urls": urls, + } + + +@app.get("/debug/config") +async def debug_config() -> dict[str, Any]: + return { + "loaded_env_files": LOADED_ENV_FILES, + "mock": USE_MOCK, + "endpoints": { + name: { + "url": endpoint_config(name).url, + "has_auth": bool(endpoint_config(name).auth), + "timeout": endpoint_config(name).timeout, + "client_id": endpoint_config(name).client_id, + } + for name in ENDPOINTS + }, + } + + +@app.get("/mcp/tools/list") +async def list_tools() -> dict[str, Any]: + return {"tools": [{"name": name, "description": cls.command_name} for name, cls in COMMANDS.items()]} + + +@app.post("/mcp/tools/call") +async def call_tool(call: ToolCall) -> dict[str, Any]: + base_metadata = {"server": "legacy_tim", "tool": call.tool_name, "mock": USE_MOCK} + command_cls = COMMANDS.get(call.tool_name) + if command_cls is None: + return {"ok": False, "result": None, "error": f"Tool não encontrada: {call.tool_name}", "metadata": {**base_metadata, "exception_type": "ToolNotFound"}} + + try: + if USE_MOCK: + raw = MOCKS.get(call.tool_name, lambda a: {"mock": True})(call.arguments or {}) + result = {"command": command_cls.command_name, "ok": True, "data": raw, "metadata": {**base_metadata, "command": command_cls.command_name, "endpoint": command_cls.endpoint_name}} + else: + result = await command_cls(call.arguments or {}, HttpApiGateway()).execute() + metadata = dict(result.get("metadata") or {}) if isinstance(result, dict) else {} + ok = bool(result.get("ok", True)) if isinstance(result, dict) else True + error = str(result.get("error") or "") if isinstance(result, dict) else "" + return {"ok": ok, "result": result, "error": error or None, "metadata": {**base_metadata, **metadata}} + except GatewayError as exc: + return { + "ok": False, + "result": None, + "error": str(exc) or exc.exception_type, + "metadata": {**base_metadata, **exc.metadata}, + } + except Exception as exc: + return { + "ok": False, + "result": None, + "error": str(exc) or exc.__class__.__name__, + "metadata": {**base_metadata, "exception_type": exc.__class__.__name__}, + } diff --git a/mcp_servers/legacy_tim_mcp/requirements.txt b/mcp_servers/legacy_tim_mcp/requirements.txt new file mode 100644 index 0000000..1e92949 --- /dev/null +++ b/mcp_servers/legacy_tim_mcp/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.111.0 +uvicorn[standard]>=0.30.0 +httpx>=0.27.0 +pydantic>=2.7.0 +python-dotenv>=1.0.1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..71214bd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +pydantic>=2.8.0 +pydantic-settings>=2.4.0 +python-dotenv>=1.0.1 +langgraph>=0.2.60 +langchain-core>=0.3.0 +openai>=1.60.0 +oci>=2.130.0 +oracledb>=2.4.0 +pymongo>=4.8.0 +redis>=5.0.0 +PyYAML>=6.0.2 + +langfuse>=3.0.0 +httpx>=0.27.0 +opentelemetry-api>=1.27.0 +opentelemetry-sdk>=1.27.0 +opentelemetry-exporter-otlp-proto-http>=1.27.0 + +pytest>=8.0.0 +pytest-asyncio>=0.23.0 +google-cloud-pubsub>=2.28.0 diff --git a/scripts/start_backend.sh b/scripts/start_backend.sh new file mode 100644 index 0000000..760c5a1 --- /dev/null +++ b/scripts/start_backend.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +if [ ! -d .venv ]; then + python -m venv .venv +fi +source .venv/bin/activate +pip install -r requirements.txt +if [ ! -f .env ]; then + cp .env.example .env +fi +uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload diff --git a/scripts/start_legacy_tim_mcp.sh b/scripts/start_legacy_tim_mcp.sh new file mode 100644 index 0000000..a973078 --- /dev/null +++ b/scripts/start_legacy_tim_mcp.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/../mcp_servers/legacy_tim_mcp" +if [ ! -d .venv ]; then + python -m venv .venv +fi +source .venv/bin/activate +pip install -r requirements.txt +if [ ! -f .env ]; then + cp .env.example .env +fi +set -a +source .env +set +a +uvicorn main:app --host 0.0.0.0 --port 8100 --reload diff --git a/tests/curl_backend_debug_mcp.sh b/tests/curl_backend_debug_mcp.sh new file mode 100644 index 0000000..c8942af --- /dev/null +++ b/tests/curl_backend_debug_mcp.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +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 diff --git a/tests/curl_backend_health.sh b/tests/curl_backend_health.sh new file mode 100644 index 0000000..18887a8 --- /dev/null +++ b/tests/curl_backend_health.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +curl -s http://localhost:8000/health | python -m json.tool diff --git a/tests/curl_gateway_fatura.sh b/tests/curl_gateway_fatura.sh new file mode 100644 index 0000000..21ecdcc --- /dev/null +++ b/tests/curl_gateway_fatura.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +curl -s http://localhost:8000/gateway/message \ + -H 'Content-Type: application/json' \ + -d '{ + "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" + } + }' | python -m json.tool diff --git a/tests/curl_mcp_consultar_fatura.sh b/tests/curl_mcp_consultar_fatura.sh new file mode 100644 index 0000000..7e8cf6d --- /dev/null +++ b/tests/curl_mcp_consultar_fatura.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +curl -s http://localhost:8100/mcp/tools/call \ + -H 'Content-Type: application/json' \ + -d '{"tool_name":"consultar_fatura","arguments":{"msisdn":"11999999999","invoice_id":"FAT-2026-06","customer_id":"CUST-001"}}' | python -m json.tool diff --git a/tests/curl_mcp_health.sh b/tests/curl_mcp_health.sh new file mode 100644 index 0000000..000badf --- /dev/null +++ b/tests/curl_mcp_health.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env bash +curl -s http://localhost:8100/health | python -m json.tool