mirror of
https://github.com/hoshikawa2/first_contas.git
synced 2026-07-09 10:14:20 +00:00
first commit
This commit is contained in:
308
.env.example
Normal file
308
.env.example
Normal file
@@ -0,0 +1,308 @@
|
||||
APP_NAME=agent-contas-first-migrated
|
||||
APP_ENV=local
|
||||
LOG_LEVEL=DEBUG
|
||||
API_HOST=0.0.0.0
|
||||
API_PORT=8000
|
||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
# =====================================================================
|
||||
# Agent Framework OCI - LLM
|
||||
# Mapeado a partir das variáveis TIM_LLM_* do legado.
|
||||
# =====================================================================
|
||||
LLM_PROVIDER=oci
|
||||
LLM_TEMPERATURE=0.0
|
||||
LLM_MAX_TOKENS=10000
|
||||
LLM_TIMEOUT_SECONDS=0
|
||||
OCI_GENAI_BASE_URL=https://pegruagntaiatenddev.pe.inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com
|
||||
OCI_GENAI_MODEL=ocid1.generativeaiendpoint.oc1.sa-saopaulo-1.amaaaaaaaehl73aa2amkvyvpsv3ts6rdbx6tzya43zgwx7vqljtjade2vjya
|
||||
OCI_GENAI_API_KEY=
|
||||
OCI_CONFIG_FILE=/Users/cristianohoshikawa/Dropbox/ORACLE/TIM/FIRST/config/config
|
||||
OCI_PROFILE=DEFAULT
|
||||
OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaa3prjvf7mkvijwn5ng5h6n5ftanbbwqfg6cu44kmffwamhyy267iq
|
||||
OCI_REGION=sa-saopaulo-1
|
||||
|
||||
# Variáveis OCI legadas mantidas para compatibilidade com adapters/scripts.
|
||||
TIM_LLM_PROVIDER=oci
|
||||
TIM_LLM_MODEL=120b
|
||||
TIM_LLM_API_KEY=
|
||||
TIM_LLM_BASE_URL=
|
||||
TIM_LLM_API_VERSION=
|
||||
TIM_LLM_TEMPERATURE=0.0
|
||||
TIM_LLM_MAX_TOKENS=10000
|
||||
TIM_LLM_REQUEST_TIMEOUT=0
|
||||
TIM_LLM_EXTRA={"auth_type":"API_KEY","provider":"meta"}
|
||||
TIM_LLM_OCI_VARIANT=120b
|
||||
TIM_LLM_OCI_MODEL_ID_20B=ocid1.generativeaiendpoint.oc1.sa-saopaulo-1.amaaaaaaaehl73aarckk4norgg263as5zleyp36tujnynrssoohshf3nwlhq
|
||||
TIM_LLM_OCI_MODEL_ID_120B=ocid1.generativeaiendpoint.oc1.sa-saopaulo-1.amaaaaaaaehl73aa2amkvyvpsv3ts6rdbx6tzya43zgwx7vqljtjade2vjya
|
||||
TIM_LLM_OCI_SERVICE_ENDPOINT=https://pegruagntaiatenddev.pe.inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com
|
||||
TIM_LLM_OCI_MODEL_KWARGS={"temperature":0.1,"top_p":0.1,"reasoning_effort":"LOW","max_tokens":5000}
|
||||
TIM_LLM_OCI_MODEL_KWARGS_20B={"temperature":0.0,"top_p":0.1,"reasoning_effort":"LOW","max_tokens":5000}
|
||||
TIM_LLM_OCI_MODEL_KWARGS_120B={"temperature":0.0,"top_p":0.1,"reasoning_effort":"LOW","max_tokens":5000}
|
||||
TIM_LLM_OCI_COMPARTMENT_ID=ocid1.compartment.oc1..aaaaaaaa3prjvf7mkvijwn5ng5h6n5ftanbbwqfg6cu44kmffwamhyy267iq
|
||||
TIM_OCI_USER=ocid1.user.oc1..aaaaaaaa2m2rrqp2m272oa6kfulx3kouaijutu4iik4xhtttg6l76nrp4rza
|
||||
TIM_OCI_FINGERPRINT=89:b7:68:10:11:ff:60:45:e1:4c:4b:f2:68:3e:e5:3f
|
||||
TIM_OCI_TENANCY=ocid1.tenancy.oc1..aaaaaaaayzepbi32gafno3hj23o3d6tuquqetuw3yelxb5y7t2ft2wtm2i7q
|
||||
TIM_OCI_REGION=sa-saopaulo-1
|
||||
TIM_OCI_KEY_FILE=/Users/cristianohoshikawa/Dropbox/ORACLE/TIM/FIRST/config/sa-agnt-ai-atendimento-contas-dev-2026-03-20T14_02_49.145Z.pem
|
||||
TIM_LLM_OCI_AUTH_FILE_LOCATION=/Users/cristianohoshikawa/Dropbox/ORACLE/TIM/FIRST/config/config
|
||||
|
||||
# =====================================================================
|
||||
# Repositórios / persistência do framework
|
||||
# =====================================================================
|
||||
SESSION_REPOSITORY_PROVIDER=memory
|
||||
MEMORY_REPOSITORY_PROVIDER=memory
|
||||
CHECKPOINT_REPOSITORY_PROVIDER=memory
|
||||
USAGE_REPOSITORY_PROVIDER=memory
|
||||
TIM_STATE_BACKEND=memory
|
||||
|
||||
# Autonomous DB / Oracle Store
|
||||
ADB_USER=USR_ADB_AGNTATEND_W_DEV
|
||||
ADB_PASSWORD="T!M#Esta026!"
|
||||
ADB_DSN="(description=(retry_count=20)(retry_delay=3)(address=(protocol=tcps)(port=1522)(host=10.152.100.72))(connect_data=(service_name=gf9a4a2e79cfeb2_agntatendimentodev_low.adb.oraclecloud.com))(security=(ssl_server_dn_match=no)))"
|
||||
ADB_WALLET_LOCATION=
|
||||
ADB_WALLET_PASSWORD=
|
||||
ADB_TABLE_PREFIX=AGENTFW
|
||||
|
||||
TIM_DB_TNS_NAME=agntatendimentodev_low
|
||||
TIM_DB_CONNECT_STRING="(description= (retry_count=20)(retry_delay=3)(address=(protocol=tcps)(port=1522)(host=cxprbmua.adb.sa-saopaulo-1.oraclecloud.com))(connect_data=(service_name=gf9a4a2e79cfeb2_agntatendimentodev_low.adb.oraclecloud.com))(security=(ssl_server_dn_match=no)))"
|
||||
TIM_DB_USER=
|
||||
TIM_DB_PASSWORD=
|
||||
TIM_DB_WALLET_PASSWORD=
|
||||
TIM_DB_USE_WALLET=true
|
||||
|
||||
# =====================================================================
|
||||
# RAG / Vector Store do framework
|
||||
# =====================================================================
|
||||
VECTOR_STORE_PROVIDER=autonomous
|
||||
GRAPH_STORE_PROVIDER=memory
|
||||
RAG_TOP_K=5
|
||||
EMBEDDING_PROVIDER=oci
|
||||
OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0
|
||||
RAG_FILE_GLOBS=*.md,*.txt,*.yaml,*.yml,*.json,*.pdf
|
||||
TIM_RAG_ENABLED=true
|
||||
TIM_RAG_OCI_SERVICE_ENDPOINT=https://inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com
|
||||
TIM_RAG_DB_USER=USR_ADB_AGNTATEND_W_DEV
|
||||
TIM_RAG_DB_PASSWORD="T!M#Esta026!"
|
||||
TIM_RAG_DB_DSN="(description=(retry_count=20)(retry_delay=3)(address=(protocol=tcps)(port=1522)(host=10.152.100.72))(connect_data=(service_name=gf9a4a2e79cfeb2_agntatendimentodev_low.adb.oraclecloud.com))(security=(ssl_server_dn_match=no)))"
|
||||
|
||||
# =====================================================================
|
||||
# Langfuse / observabilidade
|
||||
# =====================================================================
|
||||
ENABLE_LANGFUSE=true
|
||||
LANGFUSE_PUBLIC_KEY=
|
||||
LANGFUSE_SECRET_KEY=
|
||||
LANGFUSE_HOST=http://localhost:3005
|
||||
ENABLE_OTEL=false
|
||||
TIM_LLM_GATEWAY_LANGFUSE_PUBLIC_KEY="pk-lf-ec665df6-a762-4c10-9412-7cb7035a7a19"
|
||||
TIM_LLM_GATEWAY_LANGFUSE_SECRET_KEY="sk-lf-e90bef95-b166-4ee4-8a23-dfc1ac169a17"
|
||||
TIM_LLM_GATEWAY_LANGFUSE_HOST=http://localhost:3005
|
||||
TIM_LLM_GATEWAY_LANGFUSE_DEFAULT_LABEL=production
|
||||
TIM_LLM_GATEWAY_LANGFUSE_CACHE_TTL_SECONDS=60
|
||||
TIM_LLM_GATEWAY_LANGFUSE_MAX_RETRIES=3
|
||||
TIM_LLM_GATEWAY_LANGFUSE_FETCH_TIMEOUT_SECONDS=5
|
||||
TIM_LLM_GATEWAY_LANGFUSE_MASK_SENSITIVE_DATA=true
|
||||
TIM_AGENT_FRAMEWORK_LOG_EXPORT_ENABLED=false
|
||||
|
||||
ENABLE_ANALYTICS=false
|
||||
ANALYTICS_PROVIDERS=noop
|
||||
ENABLE_OCI_STREAMING=false
|
||||
|
||||
# =====================================================================
|
||||
# Guardrails / Supervisor / Judges do framework
|
||||
# =====================================================================
|
||||
ENABLE_INPUT_GUARDRAILS=true
|
||||
ENABLE_OUTPUT_GUARDRAILS=true
|
||||
ENABLE_JUDGES=true
|
||||
ENABLE_SUPERVISOR=true
|
||||
ENABLE_OUTPUT_SUPERVISOR=true
|
||||
ENABLE_PARALLEL_GUARDRAILS=true
|
||||
GUARDRAILS_FAIL_FAST=true
|
||||
OUTPUT_SUPERVISOR_MAX_RETRIES=3
|
||||
GUARDRAILS_CONFIG_PATH=./config/guardrails.yaml
|
||||
JUDGES_CONFIG_PATH=./config/judges.yaml
|
||||
PROMPT_POLICY_PATH=./config/prompt_policy.yaml
|
||||
USE_MOCK_LLM=false
|
||||
GUARDRAIL_LLM=20b
|
||||
|
||||
# =====================================================================
|
||||
# Channel / Routing / MCP framework
|
||||
# =====================================================================
|
||||
DEFAULT_CHANNEL=web
|
||||
FRAMEWORK_CHANNEL_INPUT_MODE=embedded
|
||||
ENABLE_VOICE_ADAPTER=true
|
||||
ENABLE_WHATSAPP_ADAPTER=true
|
||||
ENABLE_TEXT_ADAPTER=true
|
||||
ROUTING_CONFIG_PATH=./config/routing.yaml
|
||||
ENABLE_LLM_ROUTER=false
|
||||
ROUTING_MODE=router
|
||||
|
||||
ENABLE_MCP_TOOLS=true
|
||||
MCP_SERVERS_CONFIG_PATH=./config/mcp_servers.yaml
|
||||
TOOLS_CONFIG_PATH=./config/tools.yaml
|
||||
MCP_PARAMETER_MAPPING_PATH=./config/mcp_parameter_mapping.yaml
|
||||
MCP_TOOL_TIMEOUT_SECONDS=30
|
||||
IDENTITY_CONFIG_PATH=./config/identity.yaml
|
||||
|
||||
ENABLE_REDIS_CACHE=false
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
ENABLE_CONVERSATION_SUMMARY_MEMORY=true
|
||||
MEMORY_CONTEXT_STRATEGY=summary
|
||||
MEMORY_HISTORY_LIMIT=80
|
||||
MEMORY_RECENT_MESSAGES_LIMIT=8
|
||||
MEMORY_SUMMARY_TRIGGER_MESSAGES=20
|
||||
MEMORY_MAX_SUMMARY_CHARS=6000
|
||||
MEMORY_SUMMARY_USE_LLM=false
|
||||
MEMORY_INJECT_RECENT_MESSAGES=true
|
||||
MEMORY_INJECT_SUMMARY=true
|
||||
|
||||
# =====================================================================
|
||||
# MCP legacy TIM/FIRST - integração real por padrão
|
||||
# =====================================================================
|
||||
TIM_MCP_USE_MOCK=false
|
||||
TIM_MCP_TIMEOUT_SECONDS=30
|
||||
TIM_GATEWAY_RETRY_MAX_RETRIES=3
|
||||
TIM_GATEWAY_RETRY_BACKOFF_FACTOR=0.5
|
||||
TIM_GATEWAY_DEFAULT_TIMEOUT=30
|
||||
TIM_DEFAULT_AUTH="Basic dXJhOnVyYTAwMDAx"
|
||||
TIM_CLIENT_ID=AIAGENTCR
|
||||
TIM_USER_ID=AIAGENTCR
|
||||
TIM_DEFAULT_CLIENT_ID=CHAT
|
||||
TIM_DEFAULT_CSP_ID=740
|
||||
TIM_DEFAULT_CHANNEL=APP
|
||||
TIM_DEFAULT_CHANNEL_LEGACY=APP
|
||||
TIM_AUTHORIZATION_OAM=
|
||||
TIM_CN_FIELD=
|
||||
TIM_TYPE_FIELD=
|
||||
|
||||
# Consulta VAS
|
||||
TIM_QUERY_VAS_URL=http://10.151.3.100:8000/access/v1/productsSingleVas
|
||||
TIM_QUERY_VAS_AUTH="Basic dXJhOnVyYTAwMDAx"
|
||||
TIM_QUERY_VAS_TIMEOUT=30
|
||||
TIM_URL_CONSULTA_VAS=http://10.151.3.100:8000/access/v1/productsSingleVas
|
||||
TIM_CONSULTA_AUTH="Basic dXJhOnVyYTAwMDAx"
|
||||
TIM_CONSULTA_TIMEOUT=30
|
||||
|
||||
# Bloqueio VAS
|
||||
TIM_BLOCK_VAS_URL=http://10.151.3.100:8000/customers/v1/createPartialBlocking
|
||||
TIM_BLOCK_VAS_AUTH="Basic YmZmZGlnaXRhbDpiZmZkaWdpdGFsMDE="
|
||||
TIM_BLOCK_VAS_TIMEOUT=30
|
||||
TIM_BLOCK_VAS_CLIENT_ID=AIAGENTCR
|
||||
TIM_BLOCK_VAS_OPERATION_TYPE=block
|
||||
TIM_BLOCK_VAS_PAYLOAD_MODE=auto
|
||||
TIM_BLOCK_VAS_ACCEPT_ENCODING=gzip,deflate
|
||||
TIM_URL_BLOQUEIO_VAS=http://10.151.3.100:8000/customers/v1/createPartialBlocking
|
||||
TIM_BLOQUEIO_AUTH="Basic YmZmZGlnaXRhbDpiZmZkaWdpdGFsMDE="
|
||||
TIM_BLOQUEIO_TIMEOUT=30
|
||||
TIM_BLOQUEIO_OPERATION_TYPE=block
|
||||
TIM_BLOQUEIO_ACCEPT_ENCODING=gzip,deflate
|
||||
|
||||
# Cancelamento VAS
|
||||
TIM_CANCEL_VAS_URL=http://10.151.3.100:8000/access/v1/singleVas
|
||||
TIM_CANCEL_VAS_AUTH="Basic dXJhOnVyYTAwMDAx"
|
||||
TIM_CANCEL_VAS_TIMEOUT=30
|
||||
TIM_CANCEL_VAS_CLIENT_ID=AIAGENTCR
|
||||
TIM_CANCELAMENTO_URL=http://10.151.3.100:8000/access/v1/singleVas
|
||||
TIM_CANCELAMENTO_AUTH="Basic dXJhOnVyYTAwMDAx"
|
||||
TIM_CANCELAMENTO_TIMEOUT=30
|
||||
|
||||
# Contrato
|
||||
TIM_CONTRATO_URL=http://10.151.3.100:8000/access/v1/contractInformation
|
||||
TIM_CONTRATO_AUTH="Basic dXJhOnVyYTAwMDAx"
|
||||
TIM_CONTRATO_TIMEOUT=30
|
||||
|
||||
# Complete invoices / fatura completa
|
||||
TIM_COMPLETE_INVOICES_URL=http://10.151.3.100:8000/customers/v1/completeInvoices
|
||||
TIM_COMPLETE_INVOICES_AUTH="Basic dXJhOnVyYTAwMDAx"
|
||||
TIM_COMPLETE_INVOICES_TIMEOUT=30
|
||||
TIM_COMPLETE_INVOICES_CLIENT_ID=AIAGENTCR
|
||||
|
||||
# Perfil de fatura
|
||||
TIM_PROFILE_BILL_URL=http://10.151.3.100:8000/customers/v1/completeInvoices
|
||||
TIM_PROFILE_BILL_AUTH="Basic dXJhOnVyYTAwMDAx"
|
||||
TIM_PROFILE_BILL_TIMEOUT=30
|
||||
TIM_URL_PERFIL_FATURA=http://10.151.3.100:8000/customers/v1/completeInvoices
|
||||
TIM_PROFILE_FULL_URL=http://server:port/v1/r-profile-full
|
||||
|
||||
# Divergência / billing analysis
|
||||
TIM_DIVERGENCIA_URL=http://10.151.3.100:8000/customers/v1/billingAnalysis
|
||||
TIM_DIVERGENCIA_AUTH="Basic dXJhOnVyYTAwMDAx"
|
||||
TIM_INVOICE_EXPLANATION_URL=http://10.151.3.100:8000/customers/v1/billingAnalysis
|
||||
TIM_INVOICE_EXPLANATION_AUTH="Basic dXJhOnVyYTAwMDAx"
|
||||
|
||||
# Bill/PDF / invoice recover
|
||||
TIM_BILL_PDF_URL=http://10.151.3.100:8000/invoices/v1/invoiceRecover
|
||||
TIM_BILL_PDF_AUTH="Basic YmZmZGlnaXRhbDpiZmZkaWdpdGFsMDE="
|
||||
TIM_BILL_PDF_TIMEOUT=30
|
||||
TIM_BILL_PDF_CLIENT_ID=TIMX
|
||||
TIM_URL_INVOICE_RECOVER=http://10.151.3.100:8000/invoices/v1/invoiceRecover
|
||||
TIM_INVOICE_RECOVER_AUTH="Basic YmZmZGlnaXRhbDpiZmZkaWdpdGFsMDE="
|
||||
TIM_INVOICE_RECOVER_TIMEOUT=30
|
||||
TIM_INVOICE_RECOVER_CLIENT_ID=TIMX
|
||||
|
||||
# Protocolo / backOfficeSRopening
|
||||
TIM_PROTOCOL_URL=http://10.151.3.100:8000/customers/v1/backOfficeSRopening
|
||||
TIM_PROTOCOL_AUTH="Basic YmZmZGlnaXRhbDpiZmZkaWdpdGFsMDE="
|
||||
TIM_PROTOCOL_TIMEOUT=30
|
||||
|
||||
# Contestação/SR
|
||||
TIM_CUSTOMER_CONTESTATION_URL=http://10.151.3.100:8000/interactions/v1/customerContestation
|
||||
TIM_CUSTOMER_CONTESTATION_AUTH="Basic dXJhOnVyYTAwMDAx"
|
||||
TIM_CUSTOMER_CONTESTATION_TIMEOUT=30
|
||||
TIM_CUSTOMER_CONTESTATION_CLIENT_ID=AIAGENTCR
|
||||
TIM_CUSTOMER_CONTESTATION_USER_ID=aiagentcr
|
||||
|
||||
# Status SR
|
||||
TIM_SERVICE_REQUEST_STATUS_URL=http://10.151.3.100:8000/interactions/v1/statusServiceRequest
|
||||
TIM_SERVICE_REQUEST_STATUS_AUTH="Basic dXJhOnVyYTAwMDAx"
|
||||
TIM_SERVICE_REQUEST_STATUS_TIMEOUT=30
|
||||
|
||||
# SMS
|
||||
TIM_SMS_URL=http://10.151.3.100:8000/customers/v1/smsSend
|
||||
TIM_SMS_AUTH="Bearer eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCIsIng1dCI6IjVubEFuRWxwaFlNZ3R4Nm1JbFRMN1VuX2x2RSIsImtpZCI6Im9yYWtleSJ9.eyJzdWIiOiJlY29tbWVyY2VjYyIsImlzcyI6Im9yYWtleTJfb2FtdmFzcWEiLCJvcmFjbGUub2F1dGguc3ZjX3BfbiI6Ik9BdXRoU2VydmljZVByb2ZpbGUiLCJpYXQiOjE3NzQ2MjU3NjIsIm9yYWNsZS5vYXV0aC5wcm4uaWRfdHlwZSI6IkNsaWVudElEIiwiZXhwIjoxNzc0NjQwMTYyLCJvcmFjbGUub2F1dGgudGtfY29udGV4dCI6InJlc291cmNlX2FjY2Vzc190ayIsInBybiI6ImVjb21tZXJjZWNjIiwianRpIjoiZGJkNmUwOTgtOThmYi00YmFjLTkyOGItOTRkZmIwYTIwYWI3Iiwib3JhY2xlLm9hdXRoLmNsaWVudF9vcmlnaW5faWQiOiJlY29tbWVyY2VjYyIsIm9yYWNsZS5vYXV0aC5zY29wZSI6ImVjb21tZXJjZS5jdXN0b21lciIsInVzZXIudGVuYW50Lm5hbWUiOiJEZWZhdWx0RG9tYWluIiwib3JhY2xlLm9hdXRoLmlkX2RfaWQiOiIxMjM0NTY3OC0xMjM0LTEyMzQtMTIzNC0xMjM0NTY3ODkwMTIifQ.fJ_zeOegG9UcYFz87NqSf3E-pnV2i_GLiJ19BumC6cyiGJunjBNTa0CxpAx0YTJznwV9l7kcBVh2rmmVrm4JH4db3Q0N_sR9l0qaKfwjFgzjUcwI7dVSDzzCCZHuYfqg1rOAUghQOPk02Ht2kxT5jaJtCYxRnK9FSnupPyzPXHI"
|
||||
TIM_SMS_TIMEOUT=30
|
||||
TIM_SMS_CLIENT_ID=Ecommercecc
|
||||
TIM_SMS_SENDER_ADDRESS=TIM
|
||||
TIM_SMS_SENDER_NAME=TIM
|
||||
TIM_SMS_TOKEN_URL=
|
||||
TIM_SMS_TOKEN_CLIENT_ID=
|
||||
TIM_SMS_TOKEN_CLIENT_SECRET=
|
||||
TIM_SMS_TOKEN_SCOPE=
|
||||
TIM_SMS_TOKEN_AUDIENCE=
|
||||
TIM_SMS_TOKEN_TIMEOUT=
|
||||
SMS_BARCODE_AUTH="Bearer eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCIsIng1dCI6IjVubEFuRWxwaFlNZ3R4Nm1JbFRMN1VuX2x2RSIsImtpZCI6Im9yYWtleSJ9.eyJzdWIiOiJlY29tbWVyY2VjYyIsImlzcyI6Im9yYWtleTJfb2FtdmFzcWEiLCJvcmFjbGUub2F1dGguc3ZjX3BfbiI6Ik9BdXRoU2VydmljZVByb2ZpbGUiLCJpYXQiOjE3NzQ2MjU3NjIsIm9yYWNsZS5vYXV0aC5wcm4uaWRfdHlwZSI6IkNsaWVudElEIiwiZXhwIjoxNzc0NjQwMTYyLCJvcmFjbGUub2F1dGgudGtfY29udGV4dCI6InJlc291cmNlX2FjY2Vzc190ayIsInBybiI6ImVjb21tZXJjZWNjIiwianRpIjoiZGJkNmUwOTgtOThmYi00YmFjLTkyOGItOTRkZmIwYTIwYWI3Iiwib3JhY2xlLm9hdXRoLmNsaWVudF9vcmlnaW5faWQiOiJlY29tbWVyY2VjYyIsIm9yYWNsZS5vYXV0aC5zY29wZSI6ImVjb21tZXJjZS5jdXN0b21lciIsInVzZXIudGVuYW50Lm5hbWUiOiJEZWZhdWx0RG9tYWluIiwib3JhY2xlLm9hdXRoLmlkX2RfaWQiOiIxMjM0NTY3OC0xMjM0LTEyMzQtMTIzNC0xMjM0NTY3ODkwMTIifQ.fJ_zeOegG9UcYFz87NqSf3E-pnV2i_GLiJ19BumC6cyiGJunjBNTa0CxpAx0YTJznwV9l7kcBVh2rmmVrm4JH4db3Q0N_sR9l0qaKfwjFgzjUcwI7dVSDzzCCZHuYfqg1rOAUghQOPk02Ht2kxT5jaJtCYxRnK9FSnupPyzPXHI"
|
||||
|
||||
# Tracking activities
|
||||
TIM_TRACKING_ACTIVITIES_URL=http://10.151.3.100:8000/customers/v1/trackingActivities
|
||||
TIM_TRACKING_ACTIVITIES_AUTH="Basic dXJhOnVyYTAwMDAx"
|
||||
TIM_TRACKING_ACTIVITIES_TIMEOUT=30
|
||||
TIM_TRACKING_ACTIVITIES_USER_LOGIN=
|
||||
TIM_TRACKING_ACTIVITIES_CHANNEL=
|
||||
TIM_TRACKING_ACTIVITIES_CLIENT_ID=
|
||||
|
||||
# Legacy app / workflows
|
||||
TIM_WORKFLOWS_DIR=workflows
|
||||
TIM_WORKFLOW_POSTGRES_DSN=
|
||||
TIM_APP_HOST=0.0.0.0
|
||||
TIM_APP_PORT=8000
|
||||
TIM_APP_RELOAD=false
|
||||
TIM_APP_LOG_LEVEL=DEBUG
|
||||
|
||||
# LLM Gateway legado
|
||||
TIM_LLM_GATEWAY_SOURCES=txt
|
||||
TIM_LLM_GATEWAY_LOCAL_DIR=prompts
|
||||
TIM_LLM_GATEWAY_CAPABILITIES_DIR=
|
||||
TIM_LLM_GATEWAY_LANGGRAPH_URL=
|
||||
TIM_LLM_GATEWAY_LANGGRAPH_TIMEOUT=5
|
||||
TIM_LLM_GATEWAY_FALLBACK_PROMPT_ID=default
|
||||
TIM_LLM_GATEWAY_FALLBACK_PROMPT_CONTENT="Você é um agente para operações VAS TIM. Use tools quando precisar executar ações no backend."
|
||||
|
||||
# Pub/Sub / export legado
|
||||
GCP_PROJECT_ID=tim-bigdata-dev-ca1f
|
||||
AGENT_PUBSUB_TOPIC=pbs-ingest-agnt-ai-contas-curadoria
|
||||
TIM_PUBSUB_DIRECT_ENABLED=true
|
||||
GOOGLE_APPLICATION_CREDENTIALS=GCP_ACESS_KEY.json
|
||||
|
||||
# Mock legado explicitamente desligado
|
||||
TIM_MOCK_LATENCY_MS=0
|
||||
TIM_MOCK_FAILURES=
|
||||
TIM_MOCK_FIXTURES_DIR=
|
||||
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Environment-dependent path to Maven home directory
|
||||
/mavenHomeManager.xml
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
9
.idea/first_contas.iml
generated
Normal file
9
.idea/first_contas.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
6
.idea/misc.xml
generated
Normal file
6
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_24" default="true" project-jdk-name="24" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/first_contas.iml" filepath="$PROJECT_DIR$/.idea/first_contas.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/vcs.xml
generated
Normal file
7
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
24
.oca/custom_code_review_guidelines.txt
Normal file
24
.oca/custom_code_review_guidelines.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
# Sample guideline, please follow similar structure for guideline with code samples
|
||||
# 1. Suggest using streams instead of simple loops for better readability.
|
||||
# <example>
|
||||
# *Comment:
|
||||
# Category: Minor
|
||||
# Issue: Use streams instead of a loop for better readability.
|
||||
# Code Block:
|
||||
#
|
||||
# ```java
|
||||
# // Calculate squares of numbers
|
||||
# List<Integer> squares = new ArrayList<>();
|
||||
# for (int number : numbers) {
|
||||
# squares.add(number * number);
|
||||
# }
|
||||
# ```
|
||||
# Recommendation:
|
||||
#
|
||||
# ```java
|
||||
# // Calculate squares of numbers
|
||||
# List<Integer> squares = Arrays.stream(numbers)
|
||||
# .map(n -> n * n) // Map each number to its square
|
||||
# .toList();
|
||||
# ```
|
||||
# </example>
|
||||
6
Dockerfile
Normal file
6
Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
||||
FROM python:3.12-slim
|
||||
WORKDIR /app
|
||||
COPY agent_framework /agent_framework
|
||||
COPY agent_template_backend /app
|
||||
RUN pip install --no-cache-dir -e /agent_framework -r requirements.txt
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
831
README.md
Normal file
831
README.md
Normal file
@@ -0,0 +1,831 @@
|
||||
# Agent Contas FIRST migrado para Agent Framework OCI
|
||||
|
||||
Este projeto é uma versão executável do **Agent Contas FIRST** migrada para o padrão do **Agent Framework OCI**. Ele implementa um agente de atendimento para dúvidas e operações de contas/faturas de telecom, mantendo a lógica específica do domínio de contas em uma camada fina e delegando workflow, roteamento, guardrails, judges, memória, RAG, MCP, cache e observabilidade para o framework corporativo.
|
||||
|
||||
> Observação de segurança: o arquivo `.env.example` do pacote contém exemplos de valores sensíveis. Este README explica os parâmetros, mas não reproduz senhas, tokens, chaves privadas nem secrets. Para uso real, mantenha esses valores fora do Git e injete por vault, secret manager, variável de ambiente segura ou arquivo `.env` local não versionado.
|
||||
|
||||
## O que é este projeto
|
||||
|
||||
O pacote contém quatro partes principais:
|
||||
|
||||
| Parte | Caminho | Função |
|
||||
|---|---|---|
|
||||
| Backend do agente | `app/` | API FastAPI que expõe `/gateway/message`, normaliza canal, resolve identidade, executa o workflow LangGraph e chama o Agent Contas. |
|
||||
| Configurações do framework | `config/` e `llm_profiles.yaml` | YAMLs de agente, roteamento, guardrails, judges, identity, MCP servers, tools e mapeamento de parâmetros. |
|
||||
| MCP Server legado TIM/FIRST | `mcp_servers/legacy_tim_mcp/` | Adaptador HTTP MCP que expõe tools no contrato esperado pelo framework e chama APIs TIM/FIRST ou mock. |
|
||||
| Mock de serviços externos | `backend_mock/` | Servidor FastAPI que simula endpoints TIM/FIRST como fatura, contrato, VAS, PDF, protocolo, contestação, status, SMS e tracking. |
|
||||
|
||||
O projeto também mantém `legacy_reference/` apenas como referência de rastreabilidade do legado. A decisão de migração foi não portar a camada própria de workflow/guardrails/judges/RAG do legado, evitando duplicidade com o Agent Framework OCI.
|
||||
|
||||
## O que é o Agente de Contas
|
||||
|
||||
O **Agente de Contas** é um agente especializado no domínio `telecom_contas`. Ele atende perguntas e fluxos relacionados a fatura, pagamento, segunda via, contestação, plano, serviços adicionais, protocolos e SMS. No código, a lógica específica fica principalmente em:
|
||||
|
||||
- `app/agents/contas_agent.py`: comportamento de domínio, prompt do agente, seleção declarativa de tools por intent e montagem de contexto com RAG/MCP.
|
||||
- `config/routing.yaml`: intents suportadas e tools relacionadas.
|
||||
- `config/tools.yaml`: catálogo das tools MCP disponíveis.
|
||||
- `config/mcp_parameter_mapping.yaml`: mapeamento das chaves canônicas de negócio para argumentos reais das APIs TIM/FIRST.
|
||||
- `config/identity.yaml`: resolução de identidade e aliases, como `msisdn`, `invoice_id`, `asset_id`, `ura_call_id` e `session_id`.
|
||||
|
||||
## Funcionalidades prometidas pelo agente
|
||||
|
||||
### Atendimento de faturas e contas
|
||||
|
||||
- Explicar fatura, valor, vencimento, status e itens cobrados.
|
||||
- Consultar fatura completa via `consultar_fatura`.
|
||||
- Consultar pagamentos e baixa via `consultar_pagamentos`.
|
||||
- Ajudar em dúvidas sobre cobrança proporcional, divergência de valor e histórico de cobrança.
|
||||
|
||||
### Segunda via e PDF
|
||||
|
||||
- Recuperar dados da fatura atual.
|
||||
- Recuperar PDF ou segunda via quando disponível via `recuperar_pdf_fatura`.
|
||||
- Preparar envio de informações de boleto/código por canal, quando a política permitir.
|
||||
|
||||
### Contestação e protocolo
|
||||
|
||||
- Identificar intenção de contestar cobrança.
|
||||
- Consultar evidências antes de orientar o cliente.
|
||||
- Abrir contestação via `abrir_contestacao`, marcada como ação sensível e com confirmação requerida.
|
||||
- Registrar protocolo via `registrar_protocolo`.
|
||||
- Consultar status de protocolo/SR via `consultar_status_sr`.
|
||||
- Atualizar status de SR via `atualizar_status_sr`, também como ação sensível.
|
||||
|
||||
### Plano, serviços e VAS
|
||||
|
||||
- Consultar plano/contrato ativo via `consultar_plano`.
|
||||
- Listar serviços ativos, VAS/SVA, bundles e benefícios via `listar_servicos`.
|
||||
- Orientar cancelamento ou bloqueio de VAS.
|
||||
- Executar `cancelar_vas` e `bloquear_vas` apenas como ações com confirmação explícita.
|
||||
|
||||
### SMS e tracking operacional
|
||||
|
||||
- Enviar SMS via `enviar_sms`, com confirmação.
|
||||
- Registrar tracking/activity via `registrar_tracking`.
|
||||
|
||||
### Segurança, qualidade e observabilidade
|
||||
|
||||
- Guardrails de entrada e saída configuráveis por YAML.
|
||||
- Judges de qualidade, groundedness, sentimento e tom.
|
||||
- Output Supervisor com retry.
|
||||
- Eventos IC/NOC/GRL e telemetria Langfuse/OTEL.
|
||||
- Cache MCP para consultas.
|
||||
- Memória conversacional e summary memory.
|
||||
|
||||
## Como ele funciona com o Agent Framework OCI
|
||||
|
||||
A arquitetura de execução é:
|
||||
|
||||
```text
|
||||
Frontend / curl / canal
|
||||
↓
|
||||
FastAPI backend: POST /gateway/message
|
||||
↓
|
||||
ChannelGateway do Agent Framework OCI
|
||||
↓
|
||||
IdentityResolver + BusinessContext
|
||||
↓
|
||||
SessionRepository + Memory + Summary Memory
|
||||
↓
|
||||
Workflow LangGraph do template
|
||||
↓
|
||||
Input Guardrails
|
||||
↓
|
||||
Enterprise Router / routing.yaml
|
||||
↓
|
||||
ContasAgent / AgentRuntimeMixin
|
||||
↓
|
||||
RAG nativo + MCPToolRouter nativo
|
||||
↓
|
||||
legacy_tim_mcp
|
||||
↓
|
||||
APIs TIM/FIRST reais ou backend_mock
|
||||
↓
|
||||
LLM + Output Supervisor + Output Guardrails + Judges
|
||||
↓
|
||||
Resposta final + telemetria + persistência
|
||||
```
|
||||
|
||||
O backend inicializa os componentes nativos em `app/main.py`:
|
||||
|
||||
- `ChannelGateway` para normalizar mensagens de web/voz/WhatsApp/texto.
|
||||
- `IdentityResolver` para transformar `msisdn`, `invoice_id`, `asset_id`, `ura_call_id` etc. em `BusinessContext` canônico.
|
||||
- `AgentProfileRegistry` para carregar o agente `contas_first`.
|
||||
- `create_llm`, `create_memory`, `create_conversation_summary_memory`, `create_session_repository`, `create_checkpoint_repository`, `create_cache` e `create_usage_repository`.
|
||||
- `create_mcp_tool_router` para carregar `config/mcp_servers.yaml`, `config/tools.yaml` e `config/mcp_parameter_mapping.yaml`.
|
||||
- `AgentWorkflow` para executar o pipeline LangGraph corporativo.
|
||||
|
||||
## Integração com o framework
|
||||
|
||||
O projeto usa o Agent Framework OCI nos seguintes pontos:
|
||||
|
||||
| Recurso do framework | Como é usado no projeto |
|
||||
|---|---|
|
||||
| LangGraph workflow | `app/workflows/agent_graph.py` orquestra o pipeline corporativo. |
|
||||
| AgentRuntimeMixin | `ContasAgent` herda de `AgentRuntimeMixin` para usar RAG, MCP, memória, cache e montagem de prompt. |
|
||||
| Enterprise Router | `config/routing.yaml` define intents, prioridades, keywords, exemplos e tools por intent. |
|
||||
| Guardrails | `config/guardrails.yaml` ativa MSK/VLOOP na entrada e REVPREC/PINJ/DLEX_OUT na saída. |
|
||||
| Judges | `config/judges.yaml` define `response_quality`, `groundedness`, `sentiment` e `tone`. |
|
||||
| Output Supervisor | Controlado por `ENABLE_OUTPUT_SUPERVISOR` e `OUTPUT_SUPERVISOR_MAX_RETRIES`. |
|
||||
| IdentityResolver | `config/identity.yaml` converte aliases de canal/legado em chaves canônicas. |
|
||||
| BusinessContext | Chaves `customer_key`, `contract_key`, `interaction_key`, `account_key`, `resource_key`, `session_key` acompanham todo o fluxo. |
|
||||
| MCPToolRouter | Executa tools declaradas em `config/tools.yaml` no servidor `legacy_tim`. |
|
||||
| MCP Parameter Mapping | `config/mcp_parameter_mapping.yaml` converte chaves canônicas em argumentos como `msisdn`, `invoice_id`, `customer_id`, `asset_id`. |
|
||||
| Cache MCP | Queries têm cache configurado por tool em `config/tools.yaml`. |
|
||||
| RAG | Usa o RAG nativo do framework, com namespace `contas_first` na metadata do agente. |
|
||||
| Memória | Usa histórico e summary memory do framework. |
|
||||
| Observabilidade | Usa Telemetry, Langfuse, IC/NOC/GRL e analytics publisher. |
|
||||
| SSE | `SSEHub` permite eventos de fluxo quando o endpoint SSE estiver em uso. |
|
||||
|
||||
## Customizações específicas do Contas
|
||||
|
||||
As customizações de domínio são intencionais e concentradas:
|
||||
|
||||
1. **Intents de contas** em `config/routing.yaml`:
|
||||
- `billing_invoice_explanation`
|
||||
- `billing_payment_status`
|
||||
- `billing_second_copy`
|
||||
- `billing_contestation`
|
||||
- `service_plan_information`
|
||||
- `vas_services_information`
|
||||
- `generic_billing_rag`
|
||||
- `billing_protocol_status`
|
||||
- `billing_protocol_register`
|
||||
- `vas_cancel_or_block`
|
||||
- `send_billing_sms`
|
||||
- `tracking_activity`
|
||||
|
||||
2. **Tools de telecom** em `config/tools.yaml`:
|
||||
- consultas com cache: `consultar_fatura`, `consultar_pagamentos`, `consultar_plano`, `listar_servicos`, `recuperar_pdf_fatura`, `consultar_status_sr`;
|
||||
- ações sem cache e com confirmação quando sensíveis: `registrar_protocolo`, `abrir_contestacao`, `atualizar_status_sr`, `cancelar_vas`, `bloquear_vas`, `enviar_sms`, `registrar_tracking`.
|
||||
|
||||
3. **MCP Server legado** em `mcp_servers/legacy_tim_mcp/main.py`, que adapta endpoints TIM/FIRST ao contrato MCP:
|
||||
|
||||
```http
|
||||
GET /mcp/tools/list
|
||||
POST /mcp/tools/call
|
||||
```
|
||||
|
||||
4. **Mock de backend externo** em `backend_mock/app.py`, útil para testar o MCP sem depender das APIs reais.
|
||||
|
||||
5. **Prompt específico do agente**, em `ContasAgent.run()`, instruindo o modelo a não inventar dados de fatura, pagamento, plano, VAS, protocolo ou contestação e a usar resultados MCP/RAG como evidência.
|
||||
|
||||
## Tecnologias utilizadas
|
||||
|
||||
- Python 3.11/3.12 recomendado.
|
||||
- FastAPI e Uvicorn.
|
||||
- Pydantic e pydantic-settings.
|
||||
- LangGraph e LangChain Core.
|
||||
- Agent Framework OCI.
|
||||
- OCI Generative AI / OCI SDK.
|
||||
- Oracle Autonomous Database / `oracledb`, quando usado para persistência/RAG.
|
||||
- Redis, opcional para cache distribuído.
|
||||
- Langfuse, opcional para traces.
|
||||
- OpenTelemetry, opcional para exportação OTEL.
|
||||
- HTTPX para chamadas HTTP do MCP legado.
|
||||
- YAML para configuração declarativa.
|
||||
- Docker Compose opcional.
|
||||
|
||||
## Requisitos para subir a aplicação
|
||||
|
||||
### Obrigatórios
|
||||
|
||||
- Python 3.11 ou 3.12.
|
||||
- `pip` e `venv`.
|
||||
- Agent Framework OCI instalado no mesmo ambiente Python do backend.
|
||||
- Portas livres:
|
||||
- `8000`: backend do agente;
|
||||
- `5173`: frontend;
|
||||
- `8100`: MCP Server legado;
|
||||
- `8001`: mock das APIs externas.
|
||||
|
||||
### Para execução real com OCI/ADB
|
||||
|
||||
- Acesso ao OCI Generative AI ou endpoint OCI configurado.
|
||||
- `OCI_CONFIG_FILE`, `OCI_PROFILE`, `OCI_REGION`, `OCI_COMPARTMENT_ID` e modelo/endpoint válidos.
|
||||
- Acesso ao Autonomous Database se `VECTOR_STORE_PROVIDER=autonomous` ou repositórios forem configurados para ADB.
|
||||
- Wallet/DSN/senha conforme o modo de conexão.
|
||||
|
||||
### Para observabilidade
|
||||
|
||||
- Langfuse local ou remoto se `ENABLE_LANGFUSE=true`.
|
||||
- Redis se `ENABLE_REDIS_CACHE=true`.
|
||||
- OTEL collector se `ENABLE_OTEL=true`.
|
||||
|
||||
## Instalação inicial
|
||||
|
||||
No diretório raiz do projeto:
|
||||
|
||||
```bash
|
||||
cd first_contas
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Instale o framework no mesmo ambiente. Exemplo em modo editable:
|
||||
|
||||
```bash
|
||||
pip install -e "../agent_framework"
|
||||
```
|
||||
|
||||
Ajuste o caminho para o local real do seu `agent_framework_oci`.
|
||||
|
||||
## Configuração recomendada para teste local com mock externo
|
||||
|
||||
Para testar o fluxo completo com **backend + MCP + mock server**, deixe o MCP chamando o mock HTTP local. No `.env` do backend e no `.env` do MCP, ajuste endpoints para `localhost:8001`:
|
||||
|
||||
```env
|
||||
TIM_MCP_USE_MOCK=false
|
||||
TIM_COMPLETE_INVOICES_URL=http://localhost:8001/customers/v1/completeInvoices
|
||||
TIM_PROFILE_BILL_URL=http://localhost:8001/customers/v1/completeInvoices/profile
|
||||
TIM_CONTRATO_URL=http://localhost:8001/access/v1/contractInformation
|
||||
TIM_QUERY_VAS_URL=http://localhost:8001/access/v1/productsSingleVas
|
||||
TIM_CANCEL_VAS_URL=http://localhost:8001/access/v1/singleVas
|
||||
TIM_BLOCK_VAS_URL=http://localhost:8001/customers/v1/createPartialBlocking
|
||||
TIM_BILL_PDF_URL=http://localhost:8001/invoices/v1/invoiceRecover
|
||||
TIM_PROTOCOL_URL=http://localhost:8001/customers/v1/backOfficeSRopening
|
||||
TIM_CUSTOMER_CONTESTATION_URL=http://localhost:8001/interactions/v1/customerContestation
|
||||
TIM_SERVICE_REQUEST_STATUS_URL=http://localhost:8001/interactions/v1/statusServiceRequest
|
||||
TIM_SMS_URL=http://localhost:8001/customers/v1/smsSend
|
||||
TIM_TRACKING_ACTIVITIES_URL=http://localhost:8001/customers/v1/trackingActivities
|
||||
```
|
||||
|
||||
Também é possível usar `TIM_MCP_USE_MOCK=true`; nesse modo o MCP usa mock interno e não precisa do `backend_mock`. Como você pediu a subida dos quatro terminais, o modo recomendado acima mantém o `backend_mock` participando do fluxo.
|
||||
|
||||
## Como abrir os 4 terminais para subir a aplicação toda
|
||||
|
||||
### Terminal 1 — MCP Server legado TIM/FIRST
|
||||
|
||||
```bash
|
||||
cd first_contas
|
||||
./scripts/start_legacy_tim_mcp.sh
|
||||
```
|
||||
|
||||
Alternativa manual:
|
||||
|
||||
```bash
|
||||
cd first_contas/mcp_servers/legacy_tim_mcp
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
uvicorn main:app --host 0.0.0.0 --port 8100 --reload
|
||||
```
|
||||
|
||||
Teste:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8100/health | python -m json.tool
|
||||
curl -s http://localhost:8100/mcp/tools/list | python -m json.tool
|
||||
```
|
||||
|
||||
### Terminal 2 — Mock server das APIs externas
|
||||
|
||||
```bash
|
||||
cd first_contas/backend_mock
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install fastapi 'uvicorn[standard]'
|
||||
uvicorn app:app --host 0.0.0.0 --port 8001 --reload
|
||||
```
|
||||
|
||||
Teste:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8001/health | python -m json.tool
|
||||
```
|
||||
|
||||
### Terminal 3 — Backend do agente
|
||||
|
||||
```bash
|
||||
cd first_contas
|
||||
./scripts/start_backend.sh
|
||||
```
|
||||
|
||||
Alternativa manual:
|
||||
|
||||
```bash
|
||||
cd first_contas
|
||||
source .venv/bin/activate
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
Teste:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8000/health | python -m json.tool
|
||||
curl -s http://localhost:8000/debug/mcp/call/consultar_fatura -H 'Content-Type: application/json' -d '{"business_context":{"customer_key":"11999999999","contract_key":"FAT-2026-06","account_key":"CUST-001","interaction_key":"URA-001"}}' | python -m json.tool
|
||||
```
|
||||
|
||||
### Terminal 4 — Frontend
|
||||
|
||||
Este pacote não traz o código do frontend. Use o frontend do `agent_framework_oci` ou o projeto de frontend que você já usa com o backend do framework. Exemplo:
|
||||
|
||||
```bash
|
||||
cd "/agent_framework_oci/agent_frontend"
|
||||
npm install
|
||||
npm run dev -- --host 0.0.0.0 --port 5173
|
||||
```
|
||||
|
||||
No frontend, configure a URL do backend como:
|
||||
|
||||
```text
|
||||
http://localhost:8000
|
||||
```
|
||||
|
||||
E use `agent_id`:
|
||||
|
||||
```text
|
||||
contas_first
|
||||
```
|
||||
|
||||
Payload mínimo esperado pelo backend:
|
||||
|
||||
```json
|
||||
{
|
||||
"channel": "web",
|
||||
"tenant_id": "default",
|
||||
"agent_id": "contas_first",
|
||||
"payload": {
|
||||
"text": "Quero entender por que minha fatura veio mais alta",
|
||||
"session_id": "sess-contas-001",
|
||||
"user_id": "11999999999",
|
||||
"customer_key": "11999999999",
|
||||
"contract_key": "FAT-2026-06",
|
||||
"account_key": "CUST-001",
|
||||
"interaction_key": "URA-001",
|
||||
"channel": "web"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testes por curl incluídos no pacote
|
||||
|
||||
Depois de subir os serviços:
|
||||
|
||||
```bash
|
||||
cd first_contas
|
||||
./tests/curl_mcp_health.sh
|
||||
./tests/curl_mcp_consultar_fatura.sh
|
||||
./tests/curl_backend_health.sh
|
||||
./tests/curl_backend_debug_mcp.sh
|
||||
./tests/curl_gateway_fatura.sh
|
||||
```
|
||||
|
||||
## Endpoints principais
|
||||
|
||||
| Endpoint | Serviço | Descrição |
|
||||
|---|---|---|
|
||||
| `GET /health` | Backend | Health check do backend do agente. |
|
||||
| `POST /gateway/message` | Backend | Entrada principal de mensagens normalizadas ou payloads de canal. |
|
||||
| `POST /debug/mcp/call/{tool}` | Backend | Diagnóstico direto de chamada MCP com `business_context`. |
|
||||
| `GET /health` | MCP | Health check do MCP legado. |
|
||||
| `GET /mcp/tools/list` | MCP | Lista tools disponíveis no servidor MCP. |
|
||||
| `POST /mcp/tools/call` | MCP | Executa uma tool MCP. |
|
||||
| `GET /health` | Mock | Health check do backend mock. |
|
||||
|
||||
## BusinessContext e mapeamento MCP
|
||||
|
||||
O framework trabalha com chaves canônicas:
|
||||
|
||||
```json
|
||||
{
|
||||
"customer_key": "11999999999",
|
||||
"contract_key": "FAT-2026-06",
|
||||
"account_key": "CUST-001",
|
||||
"interaction_key": "URA-001",
|
||||
"resource_key": "ASSET-001",
|
||||
"session_key": "sess-contas-001"
|
||||
}
|
||||
```
|
||||
|
||||
O `MCPToolRouter` converte essas chaves para os parâmetros reais de cada tool. Exemplos:
|
||||
|
||||
| Tool | Mapeamento principal |
|
||||
|---|---|
|
||||
| `consultar_fatura` | `customer_key → msisdn`, `contract_key → invoice_id`, `account_key → customer_id`, `interaction_key → ura_call_id`, `session_key → session_id` |
|
||||
| `consultar_pagamentos` | `customer_key → msisdn`, `account_key → customer_id`, `interaction_key → ura_call_id` |
|
||||
| `consultar_plano` | `customer_key → msisdn`, `resource_key → asset_id`, `account_key → customer_id` |
|
||||
| `listar_servicos` | `customer_key → msisdn`, `resource_key → asset_id` |
|
||||
| `abrir_contestacao` | `customer_key → msisdn`, `contract_key → invoice_id`, `account_key → customer_id` |
|
||||
| `cancelar_vas`/`bloquear_vas` | `customer_key → msisdn`, `resource_key → app_id` |
|
||||
|
||||
## Arquivos mais importantes
|
||||
|
||||
| Arquivo | Descrição |
|
||||
|---|---|
|
||||
| `README.md` | README original do pacote, com visão geral da migração. |
|
||||
| `README_MIGRACAO_CONTAS_FIRST.md` | Decisões de migração e rastreabilidade. |
|
||||
| `.env.example` | Exemplo de configuração completa. |
|
||||
| `app/main.py` | Bootstrap FastAPI e inicialização dos componentes do framework. |
|
||||
| `app/agents/contas_agent.py` | Agente específico de Contas. |
|
||||
| `app/agents/runtime.py` | Mixin/apoio runtime do agente. |
|
||||
| `app/workflows/agent_graph.py` | Workflow LangGraph corporativo. |
|
||||
| `config/agents.yaml` | Registro do agente `contas_first`. |
|
||||
| `config/routing.yaml` | Intents, priorities, keywords e tools por intent. |
|
||||
| `config/tools.yaml` | Catálogo declarativo de tools MCP. |
|
||||
| `config/mcp_servers.yaml` | Servidor MCP `legacy_tim` em `http://localhost:8100/mcp`. |
|
||||
| `config/mcp_parameter_mapping.yaml` | Mapeamento BusinessContext -> args das tools. |
|
||||
| `config/identity.yaml` | Resolução de aliases de identidade. |
|
||||
| `config/guardrails.yaml` | Guardrails globais. |
|
||||
| `config/judges.yaml` | Judges globais. |
|
||||
| `mcp_servers/legacy_tim_mcp/main.py` | Servidor MCP legado adaptador TIM/FIRST. |
|
||||
| `backend_mock/app.py` | Mock de endpoints externos TIM/FIRST. |
|
||||
| `scripts/start_backend.sh` | Script para subir backend. |
|
||||
| `scripts/start_legacy_tim_mcp.sh` | Script para subir MCP legado. |
|
||||
| `tests/*.sh` | Curls de validação. |
|
||||
|
||||
## Docker Compose opcional
|
||||
|
||||
O pacote inclui `docker-compose.yml` com dois serviços:
|
||||
|
||||
- `legacy-tim-mcp`, porta `8100`;
|
||||
- `agent-contas`, porta `8000`.
|
||||
|
||||
Execução:
|
||||
|
||||
```bash
|
||||
cd first_contas
|
||||
cp .env.example .env
|
||||
cp mcp_servers/legacy_tim_mcp/.env.example mcp_servers/legacy_tim_mcp/.env
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
O compose não inclui o frontend nem o `backend_mock`; para testar com mock externo no Docker, adicione um serviço adicional ou rode o mock localmente e ajuste as URLs conforme a rede do container.
|
||||
|
||||
## Lista de parâmetros `.env`
|
||||
|
||||
Abaixo estão os parâmetros encontrados em `.env.example`, explicados por finalidade. Os valores concretos devem ser preenchidos no `.env` local ou por secrets. Não copie tokens/senhas reais para documentação ou repositório.
|
||||
|
||||
### Geral
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `APP_NAME` | Nome lógico da aplicação usado em logs, telemetria e identificação operacional. |
|
||||
| `APP_ENV` | Ambiente de execução, por exemplo local, dev, hml ou prod. |
|
||||
| `LOG_LEVEL` | Nível de log da aplicação Python/FastAPI. |
|
||||
| `API_HOST` | Host/IP usado pelo Uvicorn para publicar o backend. |
|
||||
| `API_PORT` | Porta HTTP do backend do agente. |
|
||||
| `CORS_ORIGINS` | Origens permitidas para chamadas do frontend/browser. |
|
||||
|
||||
### Mapeado a partir das variáveis TIM_LLM_* do legado.
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `LLM_PROVIDER` | Provider principal de LLM usado pelo Agent Framework OCI. |
|
||||
| `LLM_TEMPERATURE` | Temperatura padrão do modelo para controlar criatividade/variação. |
|
||||
| `LLM_MAX_TOKENS` | Limite máximo padrão de tokens de resposta. |
|
||||
| `LLM_TIMEOUT_SECONDS` | Timeout padrão para chamadas ao LLM; zero normalmente significa sem timeout específico. |
|
||||
| `OCI_GENAI_BASE_URL` | Endpoint/base URL do serviço OCI Generative AI ou endpoint dedicado. |
|
||||
| `OCI_GENAI_MODEL` | Modelo ou endpoint OCID usado pelo OCI Generative AI. |
|
||||
| `OCI_GENAI_API_KEY` | Chave de API quando o provider usa modo compatível com API key. |
|
||||
| `OCI_CONFIG_FILE` | Caminho do arquivo de configuração OCI local. |
|
||||
| `OCI_PROFILE` | Profile OCI dentro do arquivo config. |
|
||||
| `OCI_COMPARTMENT_ID` | OCID do compartment onde ficam os recursos de GenAI/embedding. |
|
||||
| `OCI_REGION` | Região OCI usada pelo SDK/serviços. |
|
||||
|
||||
### Variáveis OCI legadas mantidas para compatibilidade com adapters/scripts.
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_LLM_PROVIDER` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
|
||||
| `TIM_LLM_MODEL` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
|
||||
| `TIM_LLM_API_KEY` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
|
||||
| `TIM_LLM_BASE_URL` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
|
||||
| `TIM_LLM_API_VERSION` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
|
||||
| `TIM_LLM_TEMPERATURE` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
|
||||
| `TIM_LLM_MAX_TOKENS` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
|
||||
| `TIM_LLM_REQUEST_TIMEOUT` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
|
||||
| `TIM_LLM_EXTRA` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
|
||||
| `TIM_LLM_OCI_VARIANT` | Configuração legada OCI usada por adapters/scripts do Agent Contas FIRST. |
|
||||
| `TIM_LLM_OCI_MODEL_ID_20B` | OCID/model id legado para variante específica do LLM OCI. |
|
||||
| `TIM_LLM_OCI_MODEL_ID_120B` | OCID/model id legado para variante específica do LLM OCI. |
|
||||
| `TIM_LLM_OCI_SERVICE_ENDPOINT` | Configuração legada OCI usada por adapters/scripts do Agent Contas FIRST. |
|
||||
| `TIM_LLM_OCI_MODEL_KWARGS` | JSON com parâmetros do modelo OCI legado, como temperature, top_p, max_tokens e reasoning_effort. |
|
||||
| `TIM_LLM_OCI_MODEL_KWARGS_20B` | JSON com parâmetros do modelo OCI legado, como temperature, top_p, max_tokens e reasoning_effort. |
|
||||
| `TIM_LLM_OCI_MODEL_KWARGS_120B` | JSON com parâmetros do modelo OCI legado, como temperature, top_p, max_tokens e reasoning_effort. |
|
||||
| `TIM_LLM_OCI_COMPARTMENT_ID` | Configuração legada OCI usada por adapters/scripts do Agent Contas FIRST. |
|
||||
| `TIM_OCI_USER` | Credencial/configuração OCI legada usada pelos adapters do legado. |
|
||||
| `TIM_OCI_FINGERPRINT` | Credencial/configuração OCI legada usada pelos adapters do legado. |
|
||||
| `TIM_OCI_TENANCY` | Credencial/configuração OCI legada usada pelos adapters do legado. |
|
||||
| `TIM_OCI_REGION` | Credencial/configuração OCI legada usada pelos adapters do legado. |
|
||||
| `TIM_OCI_KEY_FILE` | Credencial/configuração OCI legada usada pelos adapters do legado. |
|
||||
| `TIM_LLM_OCI_AUTH_FILE_LOCATION` | Configuração legada OCI usada por adapters/scripts do Agent Contas FIRST. |
|
||||
|
||||
### Repositórios / persistência do framework
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `SESSION_REPOSITORY_PROVIDER` | Provider do repositório de sessões do framework, como memory ou autonomous. |
|
||||
| `MEMORY_REPOSITORY_PROVIDER` | Provider da memória conversacional/histórico. |
|
||||
| `CHECKPOINT_REPOSITORY_PROVIDER` | Provider de checkpoints LangGraph. |
|
||||
| `USAGE_REPOSITORY_PROVIDER` | Provider para gravação de uso/tokens/custo. |
|
||||
| `TIM_STATE_BACKEND` | Backend legado de estado mantido por compatibilidade. |
|
||||
|
||||
### Autonomous DB / Oracle Store
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `ADB_USER` | Usuário do Autonomous Database para persistência/RAG. |
|
||||
| `ADB_PASSWORD` | Senha do Autonomous Database. Valor sensível; não commitar. |
|
||||
| `ADB_DSN` | DSN/connect string do Autonomous Database. |
|
||||
| `ADB_WALLET_LOCATION` | Diretório do wallet ADB, quando aplicável. |
|
||||
| `ADB_WALLET_PASSWORD` | Senha do wallet ADB. Valor sensível; não commitar. |
|
||||
| `ADB_TABLE_PREFIX` | Prefixo das tabelas criadas/usadas pelo framework. |
|
||||
| `TIM_DB_TNS_NAME` | Configuração de banco Oracle/ADB legada TIM mantida para compatibilidade. |
|
||||
| `TIM_DB_CONNECT_STRING` | Configuração de banco Oracle/ADB legada TIM mantida para compatibilidade. |
|
||||
| `TIM_DB_USER` | Configuração de banco Oracle/ADB legada TIM mantida para compatibilidade. |
|
||||
| `TIM_DB_PASSWORD` | Configuração de banco Oracle/ADB legada TIM mantida para compatibilidade. |
|
||||
| `TIM_DB_WALLET_PASSWORD` | Configuração de banco Oracle/ADB legada TIM mantida para compatibilidade. |
|
||||
| `TIM_DB_USE_WALLET` | Configuração de banco Oracle/ADB legada TIM mantida para compatibilidade. |
|
||||
|
||||
### RAG / Vector Store do framework
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `VECTOR_STORE_PROVIDER` | Provider do vector store/RAG, como memory ou autonomous. |
|
||||
| `GRAPH_STORE_PROVIDER` | Provider do graph store, quando habilitado. |
|
||||
| `RAG_TOP_K` | Quantidade máxima de documentos/chunks recuperados por consulta RAG. |
|
||||
| `EMBEDDING_PROVIDER` | Provider de embeddings, como mock ou oci. |
|
||||
| `OCI_EMBEDDING_MODEL` | Modelo OCI de embedding usado para indexação/consulta. |
|
||||
| `RAG_FILE_GLOBS` | Extensões/padrões de arquivos aceitos na ingestão RAG. |
|
||||
| `TIM_RAG_ENABLED` | Liga/desliga compatibilidade RAG do legado TIM. |
|
||||
| `TIM_RAG_OCI_SERVICE_ENDPOINT` | Configuração RAG legada TIM mantida para compatibilidade com adapters/scripts. |
|
||||
| `TIM_RAG_DB_USER` | Configuração RAG legada TIM mantida para compatibilidade com adapters/scripts. |
|
||||
| `TIM_RAG_DB_PASSWORD` | Configuração RAG legada TIM mantida para compatibilidade com adapters/scripts. |
|
||||
| `TIM_RAG_DB_DSN` | Configuração RAG legada TIM mantida para compatibilidade com adapters/scripts. |
|
||||
|
||||
### Langfuse / observabilidade
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `ENABLE_LANGFUSE` | Liga/desliga envio de traces para Langfuse. |
|
||||
| `LANGFUSE_PUBLIC_KEY` | Chave pública Langfuse. Valor sensível operacional. |
|
||||
| `LANGFUSE_SECRET_KEY` | Chave secreta Langfuse. Valor sensível; não commitar. |
|
||||
| `LANGFUSE_HOST` | URL do servidor Langfuse. |
|
||||
| `ENABLE_OTEL` | Liga/desliga exportação OpenTelemetry. |
|
||||
| `TIM_LLM_GATEWAY_LANGFUSE_PUBLIC_KEY` | Configuração legada Langfuse do LLM Gateway (public_key). |
|
||||
| `TIM_LLM_GATEWAY_LANGFUSE_SECRET_KEY` | Configuração legada Langfuse do LLM Gateway (secret_key). |
|
||||
| `TIM_LLM_GATEWAY_LANGFUSE_HOST` | Configuração legada Langfuse do LLM Gateway (host). |
|
||||
| `TIM_LLM_GATEWAY_LANGFUSE_DEFAULT_LABEL` | Configuração legada Langfuse do LLM Gateway (default_label). |
|
||||
| `TIM_LLM_GATEWAY_LANGFUSE_CACHE_TTL_SECONDS` | Configuração legada Langfuse do LLM Gateway (cache_ttl_seconds). |
|
||||
| `TIM_LLM_GATEWAY_LANGFUSE_MAX_RETRIES` | Configuração legada Langfuse do LLM Gateway (max_retries). |
|
||||
| `TIM_LLM_GATEWAY_LANGFUSE_FETCH_TIMEOUT_SECONDS` | Configuração legada Langfuse do LLM Gateway (fetch_timeout_seconds). |
|
||||
| `TIM_LLM_GATEWAY_LANGFUSE_MASK_SENSITIVE_DATA` | Configuração legada Langfuse do LLM Gateway (mask_sensitive_data). |
|
||||
| `TIM_AGENT_FRAMEWORK_LOG_EXPORT_ENABLED` | Parâmetro de compatibilidade do projeto; revisar uso específico no código/configuração antes de remover. |
|
||||
| `ENABLE_ANALYTICS` | Liga/desliga publisher analítico do framework. |
|
||||
| `ANALYTICS_PROVIDERS` | Lista/provider de analytics, por exemplo noop ou oci_streaming. |
|
||||
| `ENABLE_OCI_STREAMING` | Liga/desliga integração com OCI Streaming. |
|
||||
|
||||
### Guardrails / Supervisor / Judges do framework
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `ENABLE_INPUT_GUARDRAILS` | Ativa guardrails de entrada antes do roteamento/agente. |
|
||||
| `ENABLE_OUTPUT_GUARDRAILS` | Ativa guardrails de saída depois da resposta do agente. |
|
||||
| `ENABLE_JUDGES` | Ativa judges pós-resposta. |
|
||||
| `ENABLE_SUPERVISOR` | Ativa supervisor/validação de fluxo do framework. |
|
||||
| `ENABLE_OUTPUT_SUPERVISOR` | Ativa revisão/retry da resposta antes da entrega. |
|
||||
| `ENABLE_PARALLEL_GUARDRAILS` | Executa guardrails em paralelo quando suportado. |
|
||||
| `GUARDRAILS_FAIL_FAST` | Interrompe no primeiro guardrail bloqueante. |
|
||||
| `OUTPUT_SUPERVISOR_MAX_RETRIES` | Número máximo de tentativas de correção do Output Supervisor. |
|
||||
| `GUARDRAILS_CONFIG_PATH` | Caminho do YAML de guardrails. |
|
||||
| `JUDGES_CONFIG_PATH` | Caminho do YAML de judges. |
|
||||
| `PROMPT_POLICY_PATH` | Caminho da política de prompt. |
|
||||
| `USE_MOCK_LLM` | Força LLM mock para teste local sem provider real. |
|
||||
| `GUARDRAIL_LLM` | Perfil/modelo usado pelos guardrails LLM. |
|
||||
|
||||
### Channel / Routing / MCP framework
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `DEFAULT_CHANNEL` | Canal padrão quando não informado na request. |
|
||||
| `FRAMEWORK_CHANNEL_INPUT_MODE` | Modo do Channel Gateway: embedded ou external. |
|
||||
| `ENABLE_VOICE_ADAPTER` | Habilita adapter de voz. |
|
||||
| `ENABLE_WHATSAPP_ADAPTER` | Habilita adapter WhatsApp. |
|
||||
| `ENABLE_TEXT_ADAPTER` | Habilita adapter texto/web. |
|
||||
| `ROUTING_CONFIG_PATH` | Caminho do YAML de roteamento/intents. |
|
||||
| `ENABLE_LLM_ROUTER` | Liga router por LLM além das regras/YAML. |
|
||||
| `ROUTING_MODE` | Modo de roteamento do framework. |
|
||||
| `ENABLE_MCP_TOOLS` | Liga/desliga execução de tools MCP. |
|
||||
| `MCP_SERVERS_CONFIG_PATH` | Caminho do YAML com servidores MCP. |
|
||||
| `TOOLS_CONFIG_PATH` | Caminho do catálogo de tools MCP. |
|
||||
| `MCP_PARAMETER_MAPPING_PATH` | Caminho do mapeamento BusinessContext -> argumentos das tools. |
|
||||
| `MCP_TOOL_TIMEOUT_SECONDS` | Timeout padrão das chamadas MCP. |
|
||||
| `IDENTITY_CONFIG_PATH` | Caminho do YAML do IdentityResolver. |
|
||||
| `ENABLE_REDIS_CACHE` | Liga Redis para cache distribuído. |
|
||||
| `REDIS_URL` | URL de conexão Redis. |
|
||||
| `ENABLE_CONVERSATION_SUMMARY_MEMORY` | Liga memória sumarizada de conversa. |
|
||||
| `MEMORY_CONTEXT_STRATEGY` | Estratégia de contexto de memória, por exemplo summary. |
|
||||
| `MEMORY_HISTORY_LIMIT` | Limite de mensagens mantidas no histórico. |
|
||||
| `MEMORY_RECENT_MESSAGES_LIMIT` | Quantidade de mensagens recentes injetadas no prompt. |
|
||||
| `MEMORY_SUMMARY_TRIGGER_MESSAGES` | Número de mensagens que dispara sumarização. |
|
||||
| `MEMORY_MAX_SUMMARY_CHARS` | Tamanho máximo do resumo de memória. |
|
||||
| `MEMORY_SUMMARY_USE_LLM` | Usa LLM para gerar resumo de memória. |
|
||||
| `MEMORY_INJECT_RECENT_MESSAGES` | Injeta mensagens recentes no contexto do agente. |
|
||||
| `MEMORY_INJECT_SUMMARY` | Injeta resumo da conversa no contexto do agente. |
|
||||
|
||||
### MCP legacy TIM/FIRST - integração real por padrão
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_MCP_USE_MOCK` | Liga mock interno do MCP. Se false, MCP chama endpoints externos configurados. |
|
||||
| `TIM_MCP_TIMEOUT_SECONDS` | Timeout padrão do MCP legado. |
|
||||
| `TIM_GATEWAY_RETRY_MAX_RETRIES` | Número máximo de retries nas chamadas externas TIM. |
|
||||
| `TIM_GATEWAY_RETRY_BACKOFF_FACTOR` | Fator de backoff entre retries. |
|
||||
| `TIM_GATEWAY_DEFAULT_TIMEOUT` | Timeout padrão do gateway HTTP externo. |
|
||||
| `TIM_DEFAULT_AUTH` | Authorization default para endpoints TIM quando não há auth específico. |
|
||||
| `TIM_CLIENT_ID` | Client ID padrão enviado às APIs TIM/FIRST. |
|
||||
| `TIM_USER_ID` | Usuário técnico/default enviado em payloads/headers. |
|
||||
| `TIM_DEFAULT_CLIENT_ID` | Client ID fallback para chamadas externas. |
|
||||
| `TIM_DEFAULT_CSP_ID` | CSP ID padrão para VAS/serviços. |
|
||||
| `TIM_DEFAULT_CHANNEL` | Canal default moderno para APIs TIM. |
|
||||
| `TIM_DEFAULT_CHANNEL_LEGACY` | Canal default compatível com legado. |
|
||||
| `TIM_AUTHORIZATION_OAM` | Token/header OAM quando a API exigir. |
|
||||
| `TIM_CN_FIELD` | Campo CN legado opcional para headers/payloads. |
|
||||
| `TIM_TYPE_FIELD` | Campo type legado opcional para headers/payloads. |
|
||||
|
||||
### Consulta VAS
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_QUERY_VAS_URL` | URL do endpoint externo para query vas. |
|
||||
| `TIM_QUERY_VAS_AUTH` | Header Authorization específico para query vas. Valor sensível. |
|
||||
| `TIM_QUERY_VAS_TIMEOUT` | Timeout em segundos para query vas. |
|
||||
| `TIM_URL_CONSULTA_VAS` | Alias legado de URL para endpoint TIM; mantido para compatibilidade com nomes antigos. |
|
||||
| `TIM_CONSULTA_AUTH` | Header Authorization específico para consulta. Valor sensível. |
|
||||
| `TIM_CONSULTA_TIMEOUT` | Timeout em segundos para consulta. |
|
||||
|
||||
### Bloqueio VAS
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_BLOCK_VAS_URL` | URL do endpoint externo para block vas. |
|
||||
| `TIM_BLOCK_VAS_AUTH` | Header Authorization específico para block vas. Valor sensível. |
|
||||
| `TIM_BLOCK_VAS_TIMEOUT` | Timeout em segundos para block vas. |
|
||||
| `TIM_BLOCK_VAS_CLIENT_ID` | Client ID específico usado em block vas. |
|
||||
| `TIM_BLOCK_VAS_OPERATION_TYPE` | Tipo de operação enviado para block vas. |
|
||||
| `TIM_BLOCK_VAS_PAYLOAD_MODE` | Modo de montagem do payload para block vas. |
|
||||
| `TIM_BLOCK_VAS_ACCEPT_ENCODING` | Header Accept-Encoding usado em block vas. |
|
||||
| `TIM_URL_BLOQUEIO_VAS` | Alias legado de URL para endpoint TIM; mantido para compatibilidade com nomes antigos. |
|
||||
| `TIM_BLOQUEIO_AUTH` | Header Authorization específico para bloqueio. Valor sensível. |
|
||||
| `TIM_BLOQUEIO_TIMEOUT` | Timeout em segundos para bloqueio. |
|
||||
| `TIM_BLOQUEIO_OPERATION_TYPE` | Tipo de operação enviado para bloqueio. |
|
||||
| `TIM_BLOQUEIO_ACCEPT_ENCODING` | Header Accept-Encoding usado em bloqueio. |
|
||||
|
||||
### Cancelamento VAS
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_CANCEL_VAS_URL` | URL do endpoint externo para cancel vas. |
|
||||
| `TIM_CANCEL_VAS_AUTH` | Header Authorization específico para cancel vas. Valor sensível. |
|
||||
| `TIM_CANCEL_VAS_TIMEOUT` | Timeout em segundos para cancel vas. |
|
||||
| `TIM_CANCEL_VAS_CLIENT_ID` | Client ID específico usado em cancel vas. |
|
||||
| `TIM_CANCELAMENTO_URL` | URL do endpoint externo para cancelamento. |
|
||||
| `TIM_CANCELAMENTO_AUTH` | Header Authorization específico para cancelamento. Valor sensível. |
|
||||
| `TIM_CANCELAMENTO_TIMEOUT` | Timeout em segundos para cancelamento. |
|
||||
|
||||
### Contrato
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_CONTRATO_URL` | URL do endpoint externo para contrato. |
|
||||
| `TIM_CONTRATO_AUTH` | Header Authorization específico para contrato. Valor sensível. |
|
||||
| `TIM_CONTRATO_TIMEOUT` | Timeout em segundos para contrato. |
|
||||
|
||||
### Complete invoices / fatura completa
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_COMPLETE_INVOICES_URL` | URL do endpoint externo para complete invoices. |
|
||||
| `TIM_COMPLETE_INVOICES_AUTH` | Header Authorization específico para complete invoices. Valor sensível. |
|
||||
| `TIM_COMPLETE_INVOICES_TIMEOUT` | Timeout em segundos para complete invoices. |
|
||||
| `TIM_COMPLETE_INVOICES_CLIENT_ID` | Client ID específico usado em complete invoices. |
|
||||
|
||||
### Perfil de fatura
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_PROFILE_BILL_URL` | URL do endpoint externo para profile bill. |
|
||||
| `TIM_PROFILE_BILL_AUTH` | Header Authorization específico para profile bill. Valor sensível. |
|
||||
| `TIM_PROFILE_BILL_TIMEOUT` | Timeout em segundos para profile bill. |
|
||||
| `TIM_URL_PERFIL_FATURA` | Alias legado de URL para endpoint TIM; mantido para compatibilidade com nomes antigos. |
|
||||
| `TIM_PROFILE_FULL_URL` | URL do endpoint externo para profile full. |
|
||||
|
||||
### Divergência / billing analysis
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_DIVERGENCIA_URL` | URL do endpoint externo para divergencia. |
|
||||
| `TIM_DIVERGENCIA_AUTH` | Header Authorization específico para divergencia. Valor sensível. |
|
||||
| `TIM_INVOICE_EXPLANATION_URL` | URL do endpoint externo para invoice explanation. |
|
||||
| `TIM_INVOICE_EXPLANATION_AUTH` | Header Authorization específico para invoice explanation. Valor sensível. |
|
||||
|
||||
### Bill/PDF / invoice recover
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_BILL_PDF_URL` | URL do endpoint externo para bill pdf. |
|
||||
| `TIM_BILL_PDF_AUTH` | Header Authorization específico para bill pdf. Valor sensível. |
|
||||
| `TIM_BILL_PDF_TIMEOUT` | Timeout em segundos para bill pdf. |
|
||||
| `TIM_BILL_PDF_CLIENT_ID` | Client ID específico usado em bill pdf. |
|
||||
| `TIM_URL_INVOICE_RECOVER` | Alias legado de URL para endpoint TIM; mantido para compatibilidade com nomes antigos. |
|
||||
| `TIM_INVOICE_RECOVER_AUTH` | Header Authorization específico para invoice recover. Valor sensível. |
|
||||
| `TIM_INVOICE_RECOVER_TIMEOUT` | Timeout em segundos para invoice recover. |
|
||||
| `TIM_INVOICE_RECOVER_CLIENT_ID` | Client ID específico usado em invoice recover. |
|
||||
|
||||
### Protocolo / backOfficeSRopening
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_PROTOCOL_URL` | URL do endpoint externo para protocol. |
|
||||
| `TIM_PROTOCOL_AUTH` | Header Authorization específico para protocol. Valor sensível. |
|
||||
| `TIM_PROTOCOL_TIMEOUT` | Timeout em segundos para protocol. |
|
||||
|
||||
### Contestação/SR
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_CUSTOMER_CONTESTATION_URL` | URL do endpoint externo para customer contestation. |
|
||||
| `TIM_CUSTOMER_CONTESTATION_AUTH` | Header Authorization específico para customer contestation. Valor sensível. |
|
||||
| `TIM_CUSTOMER_CONTESTATION_TIMEOUT` | Timeout em segundos para customer contestation. |
|
||||
| `TIM_CUSTOMER_CONTESTATION_CLIENT_ID` | Client ID específico usado em customer contestation. |
|
||||
| `TIM_CUSTOMER_CONTESTATION_USER_ID` | Usuário técnico específico usado em customer contestation. |
|
||||
|
||||
### Status SR
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_SERVICE_REQUEST_STATUS_URL` | URL do endpoint externo para service request status. |
|
||||
| `TIM_SERVICE_REQUEST_STATUS_AUTH` | Header Authorization específico para service request status. Valor sensível. |
|
||||
| `TIM_SERVICE_REQUEST_STATUS_TIMEOUT` | Timeout em segundos para service request status. |
|
||||
|
||||
### SMS
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_SMS_URL` | URL do endpoint externo para sms. |
|
||||
| `TIM_SMS_AUTH` | Header Authorization específico para sms. Valor sensível. |
|
||||
| `TIM_SMS_TIMEOUT` | Timeout em segundos para sms. |
|
||||
| `TIM_SMS_CLIENT_ID` | Client ID específico usado em sms. |
|
||||
| `TIM_SMS_SENDER_ADDRESS` | Endereço/remetente usado no envio de SMS. |
|
||||
| `TIM_SMS_SENDER_NAME` | Nome do remetente usado no envio de SMS. |
|
||||
| `TIM_SMS_TOKEN_URL` | Configuração para obtenção de token OAuth/serviço do envio de SMS. |
|
||||
| `TIM_SMS_TOKEN_CLIENT_ID` | Configuração para obtenção de token OAuth/serviço do envio de SMS. |
|
||||
| `TIM_SMS_TOKEN_CLIENT_SECRET` | Configuração para obtenção de token OAuth/serviço do envio de SMS. |
|
||||
| `TIM_SMS_TOKEN_SCOPE` | Configuração para obtenção de token OAuth/serviço do envio de SMS. |
|
||||
| `TIM_SMS_TOKEN_AUDIENCE` | Configuração para obtenção de token OAuth/serviço do envio de SMS. |
|
||||
| `TIM_SMS_TOKEN_TIMEOUT` | Configuração para obtenção de token OAuth/serviço do envio de SMS. |
|
||||
| `SMS_BARCODE_AUTH` | Authorization alternativo para envio de SMS com código de barras. |
|
||||
|
||||
### Tracking activities
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_TRACKING_ACTIVITIES_URL` | URL do endpoint externo para tracking activities. |
|
||||
| `TIM_TRACKING_ACTIVITIES_AUTH` | Header Authorization específico para tracking activities. Valor sensível. |
|
||||
| `TIM_TRACKING_ACTIVITIES_TIMEOUT` | Timeout em segundos para tracking activities. |
|
||||
| `TIM_TRACKING_ACTIVITIES_USER_LOGIN` | Login técnico usado em tracking activities. |
|
||||
| `TIM_TRACKING_ACTIVITIES_CHANNEL` | Canal enviado nas chamadas de tracking activities. |
|
||||
| `TIM_TRACKING_ACTIVITIES_CLIENT_ID` | Client ID específico usado em tracking activities. |
|
||||
|
||||
### Legacy app / workflows
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_WORKFLOWS_DIR` | Diretório de workflows legados mantido como referência. |
|
||||
| `TIM_WORKFLOW_POSTGRES_DSN` | DSN Postgres legado, se algum workflow antigo exigir. |
|
||||
| `TIM_APP_HOST` | Host legado da aplicação TIM. |
|
||||
| `TIM_APP_PORT` | Porta legada da aplicação TIM. |
|
||||
| `TIM_APP_RELOAD` | Reload legado do app TIM. |
|
||||
| `TIM_APP_LOG_LEVEL` | Log level legado do app TIM. |
|
||||
|
||||
### LLM Gateway legado
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_LLM_GATEWAY_SOURCES` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
|
||||
| `TIM_LLM_GATEWAY_LOCAL_DIR` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
|
||||
| `TIM_LLM_GATEWAY_CAPABILITIES_DIR` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
|
||||
| `TIM_LLM_GATEWAY_LANGGRAPH_URL` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
|
||||
| `TIM_LLM_GATEWAY_LANGGRAPH_TIMEOUT` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
|
||||
| `TIM_LLM_GATEWAY_FALLBACK_PROMPT_ID` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
|
||||
| `TIM_LLM_GATEWAY_FALLBACK_PROMPT_CONTENT` | Configuração legada do LLM Gateway TIM mantida para compatibilidade. |
|
||||
|
||||
### Pub/Sub / export legado
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `GCP_PROJECT_ID` | Projeto GCP legado usado em Pub/Sub/exportações. |
|
||||
| `AGENT_PUBSUB_TOPIC` | Tópico Pub/Sub legado para curadoria/ingestão. |
|
||||
| `TIM_PUBSUB_DIRECT_ENABLED` | Liga publicação direta no Pub/Sub legado. |
|
||||
| `GOOGLE_APPLICATION_CREDENTIALS` | Caminho do arquivo de credenciais GCP. |
|
||||
|
||||
### Mock legado explicitamente desligado
|
||||
| Parâmetro | Para que serve |
|
||||
|---|---|
|
||||
| `TIM_MOCK_LATENCY_MS` | Latência artificial do mock legado. |
|
||||
| `TIM_MOCK_FAILURES` | Lista/configuração de falhas simuladas no mock legado. |
|
||||
| `TIM_MOCK_FIXTURES_DIR` | Diretório de fixtures do mock legado. |
|
||||
|
||||
|
||||
## Recomendações de operação
|
||||
|
||||
1. Para teste local completo, prefira `TIM_MCP_USE_MOCK=false` com URLs apontando para `http://localhost:8001`, pois assim você valida backend, MCP e mock externo no mesmo fluxo.
|
||||
2. Para smoke test rápido apenas do MCP, use `TIM_MCP_USE_MOCK=true` no MCP e dispense o mock server.
|
||||
3. Para produção/homologação, substitua URLs do mock pelas APIs reais TIM/FIRST e revise todos os parâmetros `*_AUTH`, `*_CLIENT_ID`, timeouts e retry.
|
||||
4. Não deixe `USE_MOCK_LLM=true` em ambiente que precise validar chamada real ao LLM.
|
||||
5. Se `VECTOR_STORE_PROVIDER=autonomous`, valide `ADB_*`, `EMBEDDING_PROVIDER=oci` e `OCI_EMBEDDING_MODEL` antes de testar RAG.
|
||||
6. Se `ENABLE_LANGFUSE=true`, confirme `LANGFUSE_HOST`, `LANGFUSE_PUBLIC_KEY` e `LANGFUSE_SECRET_KEY` antes de esperar traces.
|
||||
7. Reinicie MCP e backend sempre que alterar `.env`, `config/tools.yaml`, `config/mcp_servers.yaml` ou `config/mcp_parameter_mapping.yaml`.
|
||||
|
||||
## Troubleshooting rápido
|
||||
|
||||
| Sintoma | Causa provável | Ação |
|
||||
|---|---|---|
|
||||
| `Endpoint externo não configurado para TIM_COMPLETE_INVOICES` | URL da API externa não preenchida no `.env` do MCP. | Configure `TIM_COMPLETE_INVOICES_URL` ou use `TIM_MCP_USE_MOCK=true`. |
|
||||
| Backend não chama MCP | `ENABLE_MCP_TOOLS=false`, `MCP_SERVERS_CONFIG_PATH` errado ou MCP fora do ar. | Verifique `/debug/mcp/call/consultar_fatura` e `http://localhost:8100/health`. |
|
||||
| Frontend recebe erro CORS | Origem não está em `CORS_ORIGINS`. | Inclua `http://localhost:5173` no `.env`. |
|
||||
| Não aparecem traces no Langfuse | Langfuse desabilitado ou chaves/host incorretos. | Verifique `ENABLE_LANGFUSE`, `LANGFUSE_HOST`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_SECRET_KEY`. |
|
||||
| RAG não retorna documentos | Vector store/embedding não configurados ou base não ingerida. | Teste `VECTOR_STORE_PROVIDER=memory`/`EMBEDDING_PROVIDER=mock` ou configure ADB/OCI. |
|
||||
| Tool retorna `ok=false` sem erro | MCP antigo/rota errada ou backend não reiniciado. | Reinicie MCP e backend; confira `config/mcp_servers.yaml`. |
|
||||
|
||||
## Resumo executivo
|
||||
|
||||
Este projeto coloca o Agent Contas FIRST sobre o Agent Framework OCI com uma arquitetura mais limpa: o framework cuida das capacidades transversais corporativas e o código do Contas fica restrito ao domínio de negócio. A integração operacional passa por MCP, com mapeamento canônico de identidade, cache declarativo, RAG nativo, guardrails, judges e observabilidade. Para subir localmente o fluxo completo, use quatro terminais: MCP na porta `8100`, mock externo na porta `8001`, backend na porta `8000` e frontend na porta `5173`.
|
||||
59
README_MIGRACAO_CONTAS_FIRST.md
Normal file
59
README_MIGRACAO_CONTAS_FIRST.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Agent Contas FIRST migrado para agent_framework_oci
|
||||
|
||||
Este backend foi gerado a partir do `agent_template_backend` e remodelado para executar o `agent_contas_first` com máximo uso das capacidades nativas do `agent_framework_oci`.
|
||||
|
||||
## Decisões principais
|
||||
|
||||
- **Não foi portada a camada própria de workflow/guardrails/judges/RAG do legado** para evitar duplicação e regras paralelas.
|
||||
- O agente específico ficou concentrado em `app/agents/contas_agent.py`.
|
||||
- O fluxo LangGraph fica em `app/workflows/agent_graph.py` e usa componentes do framework para input guardrails, router, agente, output supervisor, output guardrails, judges, supervisor review e persistência.
|
||||
- O MCP é chamado somente via `agent_framework.mcp.tool_router` através do `AgentRuntimeMixin.execute_tools_for_intent()`.
|
||||
- Identity e BusinessContext são resolvidos em `app/main.py` com `IdentityResolver` e `BusinessContext` do framework.
|
||||
- RAG é consumido via `RagService` do framework usando namespace `contas_first`.
|
||||
- Eventos IC/NOC/GRL são emitidos no workflow e no agente, sem criar uma camada de observabilidade paralela.
|
||||
|
||||
## Arquivos principais
|
||||
|
||||
- `app/agents/contas_agent.py`: comportamento de domínio, seleção declarativa de intents/tools, uso de memória, RAG, MCP e LLM cache do framework.
|
||||
- `app/workflows/agent_graph.py`: pipeline LangGraph corporativo.
|
||||
- `config/agents.yaml`: registra somente `contas_first`.
|
||||
- `config/routing.yaml`: intents de fatura, pagamento, segunda via, contestação, plano e VAS.
|
||||
- `config/agents/contas_first/guardrails.yaml`: ativa MSK, VLOOP, PINJ, DLEX_IN, REVPREC, DLEX_OUT e RAGSEC no framework.
|
||||
- `config/tools.yaml`: catálogo MCP telecom com cache por `args_schema`.
|
||||
- `config/mcp_parameter_mapping.yaml`: mapeamento canônico BusinessContext -> argumentos MCP.
|
||||
- `config/identity.yaml`: aliases de msisdn, invoice_id, asset_id, ura_call_id e session.
|
||||
- `legacy_reference/`: workflows originais guardados apenas como referência de rastreabilidade.
|
||||
|
||||
## Como rodar
|
||||
|
||||
```bash
|
||||
cd agent_contas_migrated
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
Exemplo:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/gateway/message \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"channel":"web",
|
||||
"agent_id":"contas_first",
|
||||
"tenant_id":"default",
|
||||
"payload":{
|
||||
"text":"quero entender minha fatura",
|
||||
"session_id":"sess-contas-001",
|
||||
"user_id":"11999999999",
|
||||
"msisdn":"11999999999",
|
||||
"invoice_id":"INV-EXEMPLO-001",
|
||||
"ura_call_id":"URA-001"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## Observações
|
||||
|
||||
O pacote não altera o `agent_framework_oci`; ele consome suas APIs nativas. Caso o framework esteja instalado em modo editable, rode com `PYTHONPATH` apontando para o `src` do framework ou instale o pacote via `pip install -e ../agent_framework_oci`.
|
||||
BIN
app/.DS_Store
vendored
Normal file
BIN
app/.DS_Store
vendored
Normal file
Binary file not shown.
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
BIN
app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/main.cpython-313.pyc
Normal file
BIN
app/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/state.cpython-313.pyc
Normal file
BIN
app/__pycache__/state.cpython-313.pyc
Normal file
Binary file not shown.
3
app/agents/__init__.py
Normal file
3
app/agents/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .contas_agent import ContasAgent
|
||||
|
||||
__all__ = ["ContasAgent"]
|
||||
BIN
app/agents/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
app/agents/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/agents/__pycache__/contas_agent.cpython-313.pyc
Normal file
BIN
app/agents/__pycache__/contas_agent.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/agents/__pycache__/runtime.cpython-313.pyc
Normal file
BIN
app/agents/__pycache__/runtime.cpython-313.pyc
Normal file
Binary file not shown.
218
app/agents/contas_agent.py
Normal file
218
app/agents/contas_agent.py
Normal file
@@ -0,0 +1,218 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.agents.runtime import AgentRuntimeMixin
|
||||
|
||||
|
||||
_CONTAS_TOOLS_BY_INTENT: dict[str, list[str]] = {
|
||||
"billing_invoice_explanation": ["consultar_fatura", "consultar_pagamentos"],
|
||||
"billing_payment_status": ["consultar_pagamentos", "consultar_fatura"],
|
||||
"billing_second_copy": ["consultar_fatura", "recuperar_pdf_fatura"],
|
||||
"billing_contestation": ["consultar_fatura", "consultar_pagamentos", "consultar_status_sr"],
|
||||
"service_plan_information": ["consultar_plano", "listar_servicos"],
|
||||
"vas_services_information": ["listar_servicos", "consultar_plano", "consultar_status_sr"],
|
||||
"billing_protocol_status": ["consultar_status_sr"],
|
||||
"billing_protocol_register": ["registrar_protocolo"],
|
||||
"vas_cancel_or_block": ["listar_servicos", "cancelar_vas", "bloquear_vas", "registrar_protocolo"],
|
||||
"send_billing_sms": ["enviar_sms"],
|
||||
"tracking_activity": ["registrar_tracking"],
|
||||
"generic_billing_rag": [],
|
||||
}
|
||||
|
||||
|
||||
class ContasAgent(AgentRuntimeMixin):
|
||||
"""Agent Contas FIRST migrado para o agent_framework_oci.
|
||||
|
||||
Esta classe mantém a lógica específica de negócio apenas como prompt e
|
||||
seleção declarativa de intents/tools. Guardrails, judges, RAG, memória,
|
||||
cache MCP, Identity/BusinessContext, telemetria IC/NOC/GRL e chamada MCP são
|
||||
executados pelos componentes nativos do framework.
|
||||
"""
|
||||
|
||||
name = "contas_agent"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm,
|
||||
telemetry=None,
|
||||
tool_router=None,
|
||||
rag_service=None,
|
||||
cache=None,
|
||||
settings=None,
|
||||
observer=None,
|
||||
memory=None,
|
||||
summary_memory=None,
|
||||
):
|
||||
self.llm = llm
|
||||
self.telemetry = telemetry
|
||||
self.tool_router = tool_router
|
||||
self.rag_service = rag_service
|
||||
self.cache = cache
|
||||
self.settings = settings
|
||||
self.observer = observer
|
||||
self.memory = memory
|
||||
self.summary_memory = summary_memory
|
||||
|
||||
async def run(self, state: dict[str, Any]) -> dict[str, Any]:
|
||||
state = self.normalize_tools_by_intent(
|
||||
state,
|
||||
default_tools_by_intent=_CONTAS_TOOLS_BY_INTENT,
|
||||
default_intent="billing_invoice_explanation",
|
||||
route=self.name,
|
||||
)
|
||||
|
||||
await self._emit_ic(
|
||||
"IC.CONTAS_AGENT_STARTED",
|
||||
state,
|
||||
{
|
||||
"framework_native": True,
|
||||
"uses_native_guardrails": True,
|
||||
"uses_native_rag": True,
|
||||
"uses_native_mcp_router": True,
|
||||
"legacy_business_layer": False,
|
||||
},
|
||||
component="agent.contas.start",
|
||||
)
|
||||
|
||||
runtime = self.get_runtime_context(state)
|
||||
missing = []
|
||||
if not runtime.pick("customer_key", "msisdn", "customer_id"):
|
||||
missing.append("customer_key/msisdn")
|
||||
if missing:
|
||||
await self._emit_noc(
|
||||
"NOC.CONTAS_IDENTITY_INCOMPLETE",
|
||||
state,
|
||||
{"missing": missing, "business_context": runtime.business_context},
|
||||
component="agent.contas.identity",
|
||||
)
|
||||
|
||||
await self.prepare_memory_context(state)
|
||||
|
||||
rag_context, rag_metadata = await self._retrieve_rag_context(state)
|
||||
await self._emit_ic(
|
||||
"IC.CONTAS_RAG_CONTEXT_RETRIEVED",
|
||||
state,
|
||||
rag_metadata,
|
||||
component="agent.contas.rag",
|
||||
)
|
||||
|
||||
mcp_results = await self.execute_tools_for_intent(
|
||||
state,
|
||||
aliases={
|
||||
"customer_key": ["msisdn", "customer_id", "ani", "from"],
|
||||
"contract_key": ["invoice_id", "current_invoice_number", "asset_id"],
|
||||
"interaction_key": ["ura_call_id", "message_id", "call_id"],
|
||||
"resource_key": ["asset_id", "product_id"],
|
||||
"account_key": ["billing_account_id", "account_id"],
|
||||
},
|
||||
)
|
||||
mcp_results = self._normalize_mcp_diagnostics(mcp_results)
|
||||
state["mcp_results"] = mcp_results
|
||||
|
||||
await self._emit_ic(
|
||||
"IC.CONTAS_MCP_CONTEXT_COLLECTED",
|
||||
state,
|
||||
{
|
||||
"tool_count": len(state.get("mcp_tools") or []),
|
||||
"result_count": len(mcp_results),
|
||||
"ok_count": sum(1 for r in mcp_results if r.get("ok")),
|
||||
},
|
||||
component="agent.contas.mcp",
|
||||
)
|
||||
|
||||
system_prompt = """
|
||||
Você é o Agent Contas FIRST migrado para o agent_framework_oci.
|
||||
|
||||
Regras de arquitetura:
|
||||
- Use os resultados MCP recebidos do framework como fonte operacional.
|
||||
- Use o contexto RAG nativo como fonte de política/conhecimento.
|
||||
- Não invente dados de fatura, pagamento, plano, VAS, protocolo ou contestação.
|
||||
- Não crie regras de negócio paralelas; quando faltar evidência, diga exatamente o que falta.
|
||||
- Mantenha a resposta curta, auditável e adequada para atendimento de telecom.
|
||||
- Para ações sensíveis como contestação/cancelamento/bloqueio, explique o próximo passo e solicite confirmação quando a tool/política exigir.
|
||||
|
||||
Coberturas vindas do agent_contas_first reaproveitadas como comportamento, não como camada legada:
|
||||
- explicação de fatura e divergência de valor;
|
||||
- consulta de pagamentos e segunda via;
|
||||
- plano/serviços/VAS;
|
||||
- contestação e fluxos com confirmação;
|
||||
- uso de protocolo apenas quando existir ferramenta/evidência.
|
||||
""".strip()
|
||||
|
||||
messages = self.build_messages(
|
||||
state,
|
||||
system_prompt=system_prompt,
|
||||
mcp_results=mcp_results,
|
||||
rag_context=rag_context,
|
||||
rag_metadata=rag_metadata,
|
||||
extra_sections={
|
||||
"Diretriz de migração": "Máximo uso do framework; legado apenas como referência funcional.",
|
||||
"Intents suportadas": sorted(_CONTAS_TOOLS_BY_INTENT.keys()),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
answer = await self._invoke_llm_cached(state, self.name, messages)
|
||||
except Exception as exc:
|
||||
await self._emit_noc(
|
||||
"NOC.CONTAS_LLM_FAILED",
|
||||
state,
|
||||
{"error": str(exc)},
|
||||
component="agent.contas.llm",
|
||||
)
|
||||
answer = self.build_llm_fallback_answer(state, mcp_results, agent_label="ContasAgent")
|
||||
|
||||
await self._emit_ic(
|
||||
"IC.CONTAS_AGENT_COMPLETED",
|
||||
state,
|
||||
{
|
||||
"answer_chars": len(str(answer or "")),
|
||||
"mcp_result_count": len(mcp_results),
|
||||
"rag_document_count": rag_metadata.get("document_count"),
|
||||
},
|
||||
component="agent.contas.completed",
|
||||
)
|
||||
|
||||
return {
|
||||
"answer": str(answer or ""),
|
||||
"mcp_results": mcp_results,
|
||||
"rag_metadata": rag_metadata,
|
||||
"active_agent": self.name,
|
||||
"route": self.name,
|
||||
"next_state": self._next_state_for_intent(state),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_mcp_diagnostics(self, results: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Preserva evidência MCP mesmo quando versões antigas do router retornam erro vazio.
|
||||
|
||||
A integração correta agora retorna metadata rica do MCP server legado.
|
||||
Esta proteção evita que o prompt/telemetria recebam `ok=False` sem
|
||||
diagnóstico quando o ToolRouter instalado for anterior ou quando o
|
||||
servidor MCP antigo ainda estiver rodando em outra janela.
|
||||
"""
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for item in results or []:
|
||||
if not isinstance(item, dict):
|
||||
normalized.append({"ok": False, "error": f"Resultado MCP inválido: {type(item).__name__}", "metadata": {"exception_type": "InvalidMCPResult"}})
|
||||
continue
|
||||
current = dict(item)
|
||||
metadata = current.get("metadata")
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
if current.get("ok") is False and not str(current.get("error") or "").strip():
|
||||
current["error"] = "MCP retornou ok=False sem mensagem de erro; confirme se o legacy_tim_mcp v7 está em execução e reinicie o backend."
|
||||
metadata.setdefault("exception_type", "EmptyMCPError")
|
||||
metadata.setdefault("diagnostic_hint", "O servidor MCP antigo ou o ToolRouter instalado pode estar descartando error/metadata.")
|
||||
current["metadata"] = metadata
|
||||
normalized.append(current)
|
||||
return normalized
|
||||
|
||||
def _next_state_for_intent(self, state: dict[str, Any]) -> str:
|
||||
intent = state.get("intent") or ""
|
||||
if intent in {"billing_contestation", "billing_second_copy"}:
|
||||
return "WAITING_BILLING_CONFIRMATION"
|
||||
if intent in {"service_plan_information", "vas_services_information"}:
|
||||
return "WAITING_SERVICE_CONFIRMATION"
|
||||
return "CONTAS_ACTIVE"
|
||||
15
app/agents/prompting.py
Normal file
15
app/agents/prompting.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def apply_agent_profile_prompt(state: dict, default_prompt: str) -> str:
|
||||
"""Adiciona o prefixo de prompt configurado para o agent_template selecionado.
|
||||
|
||||
Cada agent_id pode definir metadata.system_prefix em config/agents.yaml. Isso
|
||||
mantém prompts isolados sem duplicar o código dos agentes especializados.
|
||||
"""
|
||||
profile = state.get("agent_profile") or (state.get("context") or {}).get("agent_profile") or {}
|
||||
metadata = profile.get("metadata") or {}
|
||||
prefix = (metadata.get("system_prefix") or "").strip()
|
||||
if not prefix:
|
||||
return default_prompt
|
||||
return f"{prefix}\n\n{default_prompt}"
|
||||
7
app/agents/runtime.py
Normal file
7
app/agents/runtime.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
# Compatibilidade local do template/backend.
|
||||
# A implementação oficial agora fica no framework para evitar duplicação entre agentes.
|
||||
from agent_framework.runtime import AgentRuntimeMixin, MessageBuilder, RuntimeContext
|
||||
|
||||
__all__ = ["AgentRuntimeMixin", "MessageBuilder", "RuntimeContext"]
|
||||
1
app/examples/__init__.py
Normal file
1
app/examples/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Exemplos de uso do template backend enterprise."""
|
||||
37
app/examples/grl_examples.py
Normal file
37
app/examples/grl_examples.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Exemplos de GRL.
|
||||
|
||||
GRL representa eventos de guardrails. Em regra, GRL.001..GRL.009 são emitidos
|
||||
pelo pipeline de guardrails e pelo OutputSupervisor do framework. Use emissão
|
||||
manual apenas para validações customizadas do agente.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def exemplo_guardrail_observado(observer: Any, state: dict[str, Any], rail_code: str, reason: str) -> None:
|
||||
await observer.emit_grl(
|
||||
"OBSERVE",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"rail_code": rail_code,
|
||||
"reason": reason,
|
||||
},
|
||||
component="examples.grl",
|
||||
)
|
||||
|
||||
|
||||
async def exemplo_guardrail_block(observer: Any, state: dict[str, Any], rail_code: str, reason: str) -> None:
|
||||
await observer.emit_grl(
|
||||
"004",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"rail_code": rail_code,
|
||||
"reason": reason,
|
||||
"action": "block",
|
||||
},
|
||||
component="examples.grl",
|
||||
)
|
||||
34
app/examples/ic_examples.py
Normal file
34
app/examples/ic_examples.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Exemplos de IC - Item de Controle.
|
||||
|
||||
ICs representam eventos de negócio. Eles alimentam Informacional, Curadoria,
|
||||
analytics, BigQuery ou qualquer publisher configurado no framework.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def exemplo_fatura_consultada(observer: Any, state: dict[str, Any], invoice_id: str) -> None:
|
||||
await observer.emit_ic(
|
||||
"IC.FATURA_CONSULTADA",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"invoice_id": invoice_id,
|
||||
},
|
||||
component="examples.ic",
|
||||
)
|
||||
|
||||
|
||||
async def exemplo_acao_concluida(observer: Any, state: dict[str, Any], action_name: str, ok: bool) -> None:
|
||||
await observer.emit_ic(
|
||||
"IC.ACAO_CONCLUIDA",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"action_name": action_name,
|
||||
"ok": ok,
|
||||
},
|
||||
component="examples.ic",
|
||||
)
|
||||
43
app/examples/mcp_examples.py
Normal file
43
app/examples/mcp_examples.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Exemplos de MCP + IC.
|
||||
|
||||
O AgentRuntimeMixin já possui _collect_mcp_context(), mas este arquivo mostra o
|
||||
padrão para chamadas explícitas ao tool_router quando necessário.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def exemplo_chamada_mcp(tool_router: Any, observer: Any, state: dict[str, Any], tool_name: str, payload: dict[str, Any]) -> Any:
|
||||
session_id = state.get("conversation_key") or state.get("session_id")
|
||||
|
||||
await observer.emit_ic(
|
||||
"IC.MCP_TOOL_CALLED",
|
||||
{
|
||||
"session_id": session_id,
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"tool_name": tool_name,
|
||||
},
|
||||
component="examples.mcp",
|
||||
)
|
||||
|
||||
result = await tool_router.call(
|
||||
tool_name,
|
||||
payload,
|
||||
business_context=(state.get("context") or {}).get("business_context") or {},
|
||||
original_context=state.get("context") or {},
|
||||
)
|
||||
|
||||
await observer.emit_ic(
|
||||
"IC.TOOL_CALLED",
|
||||
{
|
||||
"session_id": session_id,
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"tool_name": tool_name,
|
||||
"ok": getattr(result, "ok", None),
|
||||
},
|
||||
component="examples.mcp",
|
||||
)
|
||||
|
||||
return result
|
||||
37
app/examples/noc_examples.py
Normal file
37
app/examples/noc_examples.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Exemplos de NOC.
|
||||
|
||||
NOC representa telemetria operacional. O workflow do template já emite NOC.001,
|
||||
NOC.005 e NOC.006. Estes exemplos mostram eventos adicionais que a squad pode
|
||||
emitir em pontos críticos.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def exemplo_api_invalida(observer: Any, state: dict[str, Any], api_url: str, status_code: int, latency_ms: int) -> None:
|
||||
await observer.emit_noc(
|
||||
"002",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"apiUrl": api_url,
|
||||
"statusCode": status_code,
|
||||
"latencyMs": latency_ms,
|
||||
},
|
||||
component="examples.noc",
|
||||
)
|
||||
|
||||
|
||||
async def exemplo_latencia_banco(observer: Any, state: dict[str, Any], resource_name: str, latency_ms: int) -> None:
|
||||
await observer.emit_noc(
|
||||
"003",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"resourceName": resource_name,
|
||||
"latencyMs": latency_ms,
|
||||
},
|
||||
component="examples.noc",
|
||||
)
|
||||
28
app/examples/observer_examples.py
Normal file
28
app/examples/observer_examples.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Resumo prático do Observer corporativo.
|
||||
|
||||
Use este arquivo como cola rápida para IC, NOC e GRL.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def emitir_eventos_basicos(observer: Any, state: dict[str, Any]) -> None:
|
||||
session_id = state.get("conversation_key") or state.get("session_id")
|
||||
|
||||
await observer.emit_ic(
|
||||
"IC.EXEMPLO_NEGOCIO",
|
||||
{"session_id": session_id, "agent_id": state.get("agent_id")},
|
||||
component="examples.observer",
|
||||
)
|
||||
|
||||
await observer.emit_noc(
|
||||
"EXEMPLO_OPERACIONAL",
|
||||
{"session_id": session_id, "agent_id": state.get("agent_id")},
|
||||
component="examples.observer",
|
||||
)
|
||||
|
||||
await observer.emit_grl(
|
||||
"OBSERVE",
|
||||
{"session_id": session_id, "agent_id": state.get("agent_id"), "rail_code": "CUSTOM"},
|
||||
component="examples.observer",
|
||||
)
|
||||
476
app/main.py
Normal file
476
app/main.py
Normal file
@@ -0,0 +1,476 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
import time
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework.channels.base import ChannelResponse
|
||||
from agent_framework.channels.gateway import ChannelGateway
|
||||
from agent_framework.config.agent_registry import AgentProfileRegistry
|
||||
from agent_framework.config.settings import settings
|
||||
from agent_framework.analytics.factory import create_analytics_publisher
|
||||
from agent_framework.observer import configure as configure_global_observer
|
||||
from agent_framework.llm.providers import create_llm
|
||||
from agent_framework.memory.message_history import create_memory
|
||||
from agent_framework.memory.summary_memory import create_conversation_summary_memory
|
||||
from agent_framework.mcp.tool_router import create_mcp_tool_router
|
||||
from agent_framework.models.identity import AgentIdentity
|
||||
from agent_framework.identity import IdentityResolver, BusinessContext
|
||||
from agent_framework.models.session import ChatMessage, SessionContext
|
||||
from agent_framework.observability.telemetry import Telemetry
|
||||
from agent_framework.observability.context import set_observability_context, clear_observability_context
|
||||
from agent_framework.repositories.session_repository import create_session_repository
|
||||
from agent_framework.checkpoints.checkpoint_repository import create_checkpoint_repository
|
||||
from agent_framework.cache.cache import create_cache
|
||||
from agent_framework.billing.usage_repository import create_usage_repository
|
||||
from agent_framework.sse.events import SSEHub
|
||||
from app.workflows.agent_graph import AgentWorkflow
|
||||
from app.observability.telemetry_observer import TelemetryBackedAgentObserver
|
||||
|
||||
logging.basicConfig(level=settings.LOG_LEVEL)
|
||||
logger = logging.getLogger("agent_contas_first_migrated")
|
||||
|
||||
app = FastAPI(title="Agent Contas FIRST Migrado")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",")],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
telemetry = Telemetry(settings)
|
||||
usage_repository = create_usage_repository(settings)
|
||||
llm = create_llm(settings, telemetry=telemetry, usage_repository=usage_repository)
|
||||
memory = create_memory(settings)
|
||||
summary_memory = create_conversation_summary_memory(settings, message_history=memory, llm=llm, telemetry=telemetry)
|
||||
sessions = create_session_repository(settings)
|
||||
checkpoints = create_checkpoint_repository(settings)
|
||||
cache = create_cache(settings, telemetry=telemetry)
|
||||
gateway = ChannelGateway(input_mode=settings.FRAMEWORK_CHANNEL_INPUT_MODE)
|
||||
analytics = create_analytics_publisher(settings)
|
||||
observer = TelemetryBackedAgentObserver(telemetry=telemetry)
|
||||
configure_global_observer({
|
||||
"enabled": getattr(settings, "ENABLE_ANALYTICS", False),
|
||||
"providers": getattr(settings, "ANALYTICS_PROVIDERS", "oci_streaming"),
|
||||
"topic_path": getattr(settings, "GCP_PUBSUB_TOPIC_PATH", None) or getattr(settings, "AGENT_PUBSUB_TOPIC", None),
|
||||
})
|
||||
tool_router = create_mcp_tool_router(settings, telemetry=telemetry)
|
||||
identity_resolver = IdentityResolver.from_yaml(settings.IDENTITY_CONFIG_PATH)
|
||||
agent_profiles = AgentProfileRegistry(settings)
|
||||
sse_hub = SSEHub(settings, telemetry=telemetry)
|
||||
workflow = AgentWorkflow(llm, memory, telemetry, analytics, settings, observer=observer, tool_router=tool_router, summary_memory=summary_memory)
|
||||
|
||||
logger.info("LLM provider carregado: %s", llm.__class__.__name__)
|
||||
logger.info("Langfuse habilitado: %s host=%s", telemetry.is_enabled(), settings.LANGFUSE_HOST)
|
||||
logger.info("Analytics habilitado: %s providers=%s", getattr(settings, "ENABLE_ANALYTICS", False), getattr(settings, "ANALYTICS_PROVIDERS", ""))
|
||||
logger.info("Agentes disponíveis: %s", [p.agent_id for p in agent_profiles.list_profiles()])
|
||||
logger.info("Framework channel input mode: %s", gateway.input_mode)
|
||||
|
||||
@app.middleware("http")
|
||||
async def observability_context_middleware(request: Request, call_next):
|
||||
request_id = request.headers.get("x-request-id") or str(uuid4())
|
||||
set_observability_context(
|
||||
request_id=request_id,
|
||||
channel=request.headers.get("x-channel") or "http",
|
||||
ura_call_id=request.headers.get("x-ura-call-id"),
|
||||
)
|
||||
started = time.time()
|
||||
try:
|
||||
response = await call_next(request)
|
||||
response.headers["x-request-id"] = request_id
|
||||
await telemetry.event("http.request.completed", {
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"status_code": response.status_code,
|
||||
"duration_ms": int((time.time() - started) * 1000),
|
||||
}, kind="http")
|
||||
return response
|
||||
except Exception as exc:
|
||||
await telemetry.event("http.request.failed", {
|
||||
"method": request.method,
|
||||
"path": request.url.path,
|
||||
"error": str(exc),
|
||||
"duration_ms": int((time.time() - started) * 1000),
|
||||
}, kind="http")
|
||||
raise
|
||||
|
||||
|
||||
class GatewayRequest(BaseModel):
|
||||
channel: str = "web"
|
||||
payload: dict
|
||||
agent_id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
|
||||
|
||||
def _resolve_identity(req: GatewayRequest, msg) -> tuple[AgentIdentity, dict, BusinessContext, list[str]]:
|
||||
payload = req.payload or {}
|
||||
context = dict(msg.context or {})
|
||||
tenant_id = req.tenant_id or payload.get("tenant_id") or context.get("tenant_id") or "default"
|
||||
agent_id = req.agent_id or payload.get("agent_id") or context.get("agent_id") or agent_profiles.default_agent_id
|
||||
profile = agent_profiles.get(agent_id)
|
||||
|
||||
# 1) Identidade técnica do framework: isola tenant/agente/sessão.
|
||||
context.update({"tenant_id": tenant_id, "agent_id": profile.agent_id, "agent_profile": profile.__dict__})
|
||||
identity = AgentIdentity.from_context(context, session_id=msg.session_id)
|
||||
|
||||
# 2) Identidade de negócio: chaves canônicas vindas do front/canal.
|
||||
# Estas chaves são estáveis na sessão e seguem até agentes e MCP Router.
|
||||
previous_business_context = context.get("business_context") or context.get("identity") or {}
|
||||
business_context = identity_resolver.resolve(
|
||||
{**payload, **context},
|
||||
session_id=identity.conversation_key(),
|
||||
previous=previous_business_context,
|
||||
)
|
||||
missing_identity_keys = identity_resolver.validate(business_context)
|
||||
context.update({
|
||||
"business_context": business_context.model_dump(),
|
||||
"business_keys": business_context.to_context_dict(),
|
||||
"identity_missing": missing_identity_keys,
|
||||
"conversation_key": identity.conversation_key(),
|
||||
"original_session_id": msg.session_id,
|
||||
})
|
||||
return identity, context, business_context, missing_identity_keys
|
||||
|
||||
|
||||
async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False) -> dict:
|
||||
try:
|
||||
msg = await gateway.normalize(req.channel, req.payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
identity, normalized_context, business_context, missing_identity_keys = _resolve_identity(req, msg)
|
||||
agent_session_id = identity.conversation_key()
|
||||
message_id = (req.payload or {}).get("message_id") or str(uuid4())
|
||||
set_observability_context(
|
||||
session_id=agent_session_id,
|
||||
user_id=msg.user_id,
|
||||
tenant_id=identity.tenant_id,
|
||||
agent_id=identity.agent_id,
|
||||
channel=msg.channel,
|
||||
message_id=message_id,
|
||||
ura_call_id=(req.payload or {}).get("ura_call_id") or normalized_context.get("ura_call_id") or business_context.interaction_key,
|
||||
)
|
||||
|
||||
stream = sse_hub.stream_for(agent_session_id)
|
||||
async with stream.lock:
|
||||
await sse_hub.emit(agent_session_id, "flow.start", {"session_id": agent_session_id, "message_id": message_id, "agent_id": identity.agent_id}) if emit_sse else None
|
||||
|
||||
session = await sessions.get(agent_session_id)
|
||||
if not session:
|
||||
context_fields = {
|
||||
k: v
|
||||
for k, v in normalized_context.items()
|
||||
if k in SessionContext.model_fields
|
||||
and k not in {"tenant_id", "agent_id", "session_id", "user_id", "channel", "channel_id"}
|
||||
}
|
||||
session = SessionContext(
|
||||
tenant_id=identity.tenant_id,
|
||||
agent_id=identity.agent_id,
|
||||
session_id=agent_session_id,
|
||||
user_id=msg.user_id,
|
||||
channel=msg.channel,
|
||||
channel_id=msg.channel_id,
|
||||
**context_fields,
|
||||
)
|
||||
|
||||
session.tenant_id = identity.tenant_id
|
||||
session.agent_id = identity.agent_id
|
||||
session.channel = msg.channel
|
||||
session.channel_id = msg.channel_id or session.channel_id
|
||||
await sessions.upsert(session)
|
||||
session.metadata = {
|
||||
**(session.metadata or {}),
|
||||
"business_context": business_context.model_dump(),
|
||||
"identity_missing": missing_identity_keys,
|
||||
"original_context": normalized_context,
|
||||
}
|
||||
await sse_hub.emit(agent_session_id, "session.upserted", {"session_id": agent_session_id, "business_context": business_context.model_dump()}) if emit_sse else None
|
||||
|
||||
await memory.append(
|
||||
agent_session_id,
|
||||
ChatMessage(
|
||||
role="user",
|
||||
content=msg.text,
|
||||
metadata={
|
||||
**normalized_context,
|
||||
"agent_id": identity.agent_id,
|
||||
"tenant_id": identity.tenant_id,
|
||||
"message_id": message_id,
|
||||
"business_context": business_context.model_dump(),
|
||||
"identity_missing": missing_identity_keys,
|
||||
},
|
||||
),
|
||||
)
|
||||
await sse_hub.emit(agent_session_id, "message.received", {"session_id": agent_session_id, "role": "user"}) if emit_sse else None
|
||||
history = [m.model_dump(mode="json") for m in await memory.list(agent_session_id)]
|
||||
|
||||
trace_input = {
|
||||
"text": msg.text,
|
||||
"channel": msg.channel,
|
||||
"channel_id": msg.channel_id,
|
||||
"tenant_id": identity.tenant_id,
|
||||
"agent_id": identity.agent_id,
|
||||
"conversation_key": agent_session_id,
|
||||
"message_id": message_id,
|
||||
"business_context": business_context.model_dump(),
|
||||
"identity_missing": missing_identity_keys,
|
||||
}
|
||||
|
||||
async with telemetry.span(
|
||||
"agent.gateway_message",
|
||||
session_id=agent_session_id,
|
||||
user_id=session.user_id,
|
||||
channel=msg.channel,
|
||||
input=trace_input,
|
||||
tags=["agent-template", msg.channel, f"agent:{identity.agent_id}", f"tenant:{identity.tenant_id}"],
|
||||
):
|
||||
await telemetry.event("gateway.message.received", trace_input)
|
||||
await sse_hub.emit(agent_session_id, "workflow.started", trace_input) if emit_sse else None
|
||||
result = await workflow.ainvoke(
|
||||
{
|
||||
"tenant_id": identity.tenant_id,
|
||||
"agent_id": identity.agent_id,
|
||||
"session_id": agent_session_id,
|
||||
"conversation_key": agent_session_id,
|
||||
"agent_profile": normalized_context["agent_profile"],
|
||||
"user_text": msg.text,
|
||||
"history": history,
|
||||
"context": {
|
||||
**normalized_context,
|
||||
"session": session.model_dump(mode="json"),
|
||||
"original_session_id": msg.session_id,
|
||||
"session_id": agent_session_id,
|
||||
"conversation_key": agent_session_id,
|
||||
"user_id": session.user_id,
|
||||
"channel": msg.channel,
|
||||
"message_id": message_id,
|
||||
"business_context": business_context.model_dump(),
|
||||
"business_keys": business_context.to_context_dict(),
|
||||
"identity_missing": missing_identity_keys,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await checkpoints.put(agent_session_id, {"state": result, "message_id": message_id})
|
||||
await sse_hub.emit(agent_session_id, "workflow.completed", {"session_id": agent_session_id, "route": result.get("route"), "intent": result.get("intent")}) if emit_sse else None
|
||||
|
||||
answer = result.get("final_answer") or result.get("answer") or ""
|
||||
await memory.append(
|
||||
agent_session_id,
|
||||
ChatMessage(
|
||||
role="assistant",
|
||||
content=answer,
|
||||
metadata={
|
||||
"tenant_id": identity.tenant_id,
|
||||
"agent_id": identity.agent_id,
|
||||
"message_id": f"assistant-{message_id}",
|
||||
"route": result.get("route"),
|
||||
"intent": result.get("intent"),
|
||||
"route_decision": result.get("route_decision"),
|
||||
"judges": result.get("judge_results"),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
await telemetry.event(
|
||||
"gateway.message.responded",
|
||||
{
|
||||
"session_id": agent_session_id,
|
||||
"tenant_id": identity.tenant_id,
|
||||
"agent_id": identity.agent_id,
|
||||
"route": result.get("route"),
|
||||
"intent": result.get("intent"),
|
||||
"answer_chars": len(answer),
|
||||
},
|
||||
)
|
||||
|
||||
response = ChannelResponse(
|
||||
channel=msg.channel,
|
||||
session_id=agent_session_id,
|
||||
text=answer,
|
||||
metadata={
|
||||
"channel_id": msg.channel_id,
|
||||
"tenant_id": identity.tenant_id,
|
||||
"agent_id": identity.agent_id,
|
||||
"original_session_id": msg.session_id,
|
||||
"conversation_key": agent_session_id,
|
||||
"message_id": message_id,
|
||||
"route": result.get("route"),
|
||||
"intent": result.get("intent"),
|
||||
"route_decision": result.get("route_decision"),
|
||||
"domain": result.get("domain"),
|
||||
"mcp_tools": result.get("mcp_tools"),
|
||||
"mcp_results": result.get("mcp_results"),
|
||||
"business_context": business_context.model_dump(),
|
||||
"identity_missing": missing_identity_keys,
|
||||
"judges": result.get("judge_results"),
|
||||
"guardrails": result.get("guardrail_decisions"),
|
||||
},
|
||||
)
|
||||
rendered = await gateway.render(response)
|
||||
await sse_hub.emit(agent_session_id, "message.responded", rendered) if emit_sse else None
|
||||
await sse_hub.emit(agent_session_id, "flow.end", {"session_id": agent_session_id, "message_id": message_id}) if emit_sse else None
|
||||
return rendered
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {
|
||||
"status": "ok",
|
||||
"llm_provider": settings.LLM_PROVIDER,
|
||||
"llm_class": llm.__class__.__name__,
|
||||
"langfuse_enabled": telemetry.is_enabled(),
|
||||
"agents": [p.agent_id for p in agent_profiles.list_profiles()],
|
||||
"default_agent_id": agent_profiles.default_agent_id,
|
||||
"routing_mode": settings.ROUTING_MODE,
|
||||
"sse_enabled": settings.ENABLE_SSE,
|
||||
"session_repository": settings.SESSION_REPOSITORY_PROVIDER,
|
||||
"memory_repository": settings.MEMORY_REPOSITORY_PROVIDER,
|
||||
"checkpoint_repository": settings.CHECKPOINT_REPOSITORY_PROVIDER,
|
||||
"usage_repository": settings.USAGE_REPOSITORY_PROVIDER,
|
||||
"identity_config_path": settings.IDENTITY_CONFIG_PATH,
|
||||
"mcp_parameter_mapping_path": settings.MCP_PARAMETER_MAPPING_PATH,
|
||||
"framework_channel_input_mode": settings.FRAMEWORK_CHANNEL_INPUT_MODE,
|
||||
"legacy_channel_gateway_mode": settings.CHANNEL_GATEWAY_MODE,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/agents")
|
||||
async def list_agents():
|
||||
return {"default_agent_id": agent_profiles.default_agent_id, "agents": [p.__dict__ for p in agent_profiles.list_profiles()]}
|
||||
|
||||
|
||||
@app.get("/debug/env")
|
||||
async def debug_env():
|
||||
return {
|
||||
"APP_ENV": settings.APP_ENV,
|
||||
"LLM_PROVIDER": settings.LLM_PROVIDER,
|
||||
"ENABLE_LANGFUSE": settings.ENABLE_LANGFUSE,
|
||||
"LANGFUSE_HOST": settings.LANGFUSE_HOST,
|
||||
"TELEMETRY_ENABLED": telemetry.is_enabled(),
|
||||
"SQLITE_DB_PATH": settings.SQLITE_DB_PATH,
|
||||
"SESSION_REPOSITORY_PROVIDER": settings.SESSION_REPOSITORY_PROVIDER,
|
||||
"MEMORY_REPOSITORY_PROVIDER": settings.MEMORY_REPOSITORY_PROVIDER,
|
||||
"CHECKPOINT_REPOSITORY_PROVIDER": settings.CHECKPOINT_REPOSITORY_PROVIDER,
|
||||
"AGENTS_CONFIG_PATH": settings.AGENTS_CONFIG_PATH,
|
||||
"ROUTING_CONFIG_PATH": settings.ROUTING_CONFIG_PATH,
|
||||
"ROUTING_MODE": settings.ROUTING_MODE,
|
||||
"FRAMEWORK_CHANNEL_INPUT_MODE": settings.FRAMEWORK_CHANNEL_INPUT_MODE,
|
||||
"CHANNEL_GATEWAY_MODE": settings.CHANNEL_GATEWAY_MODE,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/test-llm")
|
||||
async def test_llm():
|
||||
async with telemetry.span("debug.test_llm", input={"message": "Diga apenas OK"}):
|
||||
answer = await llm.ainvoke([
|
||||
{"role": "system", "content": "Responda de forma curta."},
|
||||
{"role": "user", "content": "Diga apenas OK"},
|
||||
])
|
||||
telemetry.flush()
|
||||
return {"provider": llm.__class__.__name__, "answer": answer}
|
||||
|
||||
|
||||
@app.post("/debug/route")
|
||||
async def debug_route(req: GatewayRequest):
|
||||
msg = await gateway.normalize(req.channel, req.payload)
|
||||
identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg)
|
||||
state = {
|
||||
"tenant_id": identity.tenant_id,
|
||||
"agent_id": identity.agent_id,
|
||||
"session_id": msg.session_id or "debug-session",
|
||||
"conversation_key": identity.conversation_key(),
|
||||
"agent_profile": context["agent_profile"],
|
||||
"user_text": msg.text,
|
||||
"sanitized_input": msg.text,
|
||||
"history": [],
|
||||
"context": {**context, "session": context.get("session", {}), "channel": msg.channel, "business_context": business_context.model_dump()},
|
||||
}
|
||||
if settings.ROUTING_MODE == "supervisor":
|
||||
plan = await workflow.supervisor.route_plan(state)
|
||||
return {"mode": "supervisor", "route": "supervisor_agent", "agents": plan.agents, "intent": plan.intent, "confidence": plan.confidence, "reason": plan.reason, "metadata": plan.metadata}
|
||||
decision = await workflow.router.route(state)
|
||||
data = decision.model_dump(mode="json")
|
||||
data["mode"] = "router"
|
||||
return data
|
||||
|
||||
|
||||
|
||||
|
||||
@app.post("/debug/identity")
|
||||
async def debug_identity(req: GatewayRequest):
|
||||
msg = await gateway.normalize(req.channel, req.payload)
|
||||
identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg)
|
||||
return {
|
||||
"technical_identity": {
|
||||
"tenant_id": identity.tenant_id,
|
||||
"agent_id": identity.agent_id,
|
||||
"conversation_key": identity.conversation_key(),
|
||||
"original_session_id": msg.session_id,
|
||||
},
|
||||
"business_context": business_context.model_dump(),
|
||||
"identity_missing": missing_identity_keys,
|
||||
"context_keys": sorted(context.keys()),
|
||||
}
|
||||
|
||||
@app.get("/debug/usage")
|
||||
async def debug_usage(tenant_id: str | None = None, session_id: str | None = None):
|
||||
return await usage_repository.summarize(tenant_id=tenant_id, session_id=session_id)
|
||||
|
||||
|
||||
@app.get("/debug/mcp/tools")
|
||||
async def debug_mcp_tools():
|
||||
return {"enabled": tool_router.enabled, "tools": tool_router.describe_tools()}
|
||||
|
||||
|
||||
@app.post("/debug/mcp/call/{tool_name}")
|
||||
async def debug_mcp_call(tool_name: str, arguments: dict | None = None):
|
||||
arguments = arguments or {}
|
||||
ctx = arguments.get("business_context") or arguments.get("identity") or {}
|
||||
result = await tool_router.call(
|
||||
tool_name,
|
||||
arguments,
|
||||
business_context=ctx,
|
||||
original_context=arguments,
|
||||
)
|
||||
return result.model_dump(mode="json")
|
||||
|
||||
|
||||
@app.post("/gateway/message")
|
||||
async def gateway_message(req: GatewayRequest):
|
||||
return await _process_gateway_message(req, emit_sse=False)
|
||||
|
||||
|
||||
@app.post("/gateway/message/sse")
|
||||
async def gateway_message_sse(req: GatewayRequest):
|
||||
return await _process_gateway_message(req, emit_sse=True)
|
||||
|
||||
|
||||
@app.get("/gateway/events/{session_id}")
|
||||
async def gateway_events(session_id: str, request: Request):
|
||||
last = request.headers.get("last-event-id") or request.query_params.get("last_event_id") or "0"
|
||||
return StreamingResponse(
|
||||
sse_hub.subscribe(session_id, int(last)),
|
||||
media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/sessions/{session_id}/messages")
|
||||
async def get_session_messages(session_id: str, limit: int = 50):
|
||||
return {"session_id": session_id, "messages": [m.model_dump(mode="json") for m in await memory.list(session_id, limit)]}
|
||||
|
||||
|
||||
@app.get("/sessions/{session_id}/checkpoint")
|
||||
async def get_session_checkpoint(session_id: str):
|
||||
return {"session_id": session_id, "checkpoint": await checkpoints.get_latest(session_id)}
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown():
|
||||
telemetry.shutdown()
|
||||
0
app/observability/__init__.py
Normal file
0
app/observability/__init__.py
Normal file
BIN
app/observability/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
app/observability/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/observability/__pycache__/telemetry_observer.cpython-313.pyc
Normal file
BIN
app/observability/__pycache__/telemetry_observer.cpython-313.pyc
Normal file
Binary file not shown.
84
app/observability/telemetry_observer.py
Normal file
84
app/observability/telemetry_observer.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Observer adapter that emits IC/NOC/GRL through framework Telemetry only.
|
||||
|
||||
This avoids a second Langfuse root trace created by AgentObserver ->
|
||||
AnalyticsPublisher while preserving the events inside the active request span.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _normalize_ic_code(code: str) -> str:
|
||||
code = str(code or "UNKNOWN").strip()
|
||||
return code if code.startswith(("IC.", "AGA.", "NOC.", "GRL.")) else f"IC.{code}"
|
||||
|
||||
|
||||
def _normalize_noc_code(code: str) -> str:
|
||||
code = str(code or "UNKNOWN").strip()
|
||||
return code if code.startswith("NOC.") else f"NOC.{code}"
|
||||
|
||||
|
||||
def _normalize_grl_code(code: str) -> str:
|
||||
code = str(code or "UNKNOWN").strip()
|
||||
return code if code.startswith("GRL.") else f"GRL.{code}"
|
||||
|
||||
|
||||
def _kind_for(event_type: str) -> str:
|
||||
if event_type.startswith(("IC.", "AGA.")):
|
||||
return "ic"
|
||||
if event_type.startswith("NOC."):
|
||||
return "noc"
|
||||
if event_type.startswith("GRL."):
|
||||
return "grl"
|
||||
return "event"
|
||||
|
||||
|
||||
class TelemetryBackedAgentObserver:
|
||||
"""Drop-in subset of AgentObserver backed by Telemetry.event.
|
||||
|
||||
Do not publish through AnalyticsPublisher here. Analytics publishing may be
|
||||
configured with a Langfuse provider, and that path creates an extra root
|
||||
trace for business events such as IC.AGENT_COMPLETED/NOC.006. Telemetry.event
|
||||
uses the active span/trace context, so these events appear inside the single
|
||||
request trace.
|
||||
"""
|
||||
|
||||
def __init__(self, telemetry: Any, *, source: str = "agent_framework") -> None:
|
||||
self.telemetry = telemetry
|
||||
self.source = source
|
||||
|
||||
async def emit(
|
||||
self,
|
||||
event_type: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
*,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
source: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
body = dict(payload or {})
|
||||
meta = dict(metadata or {})
|
||||
body.setdefault("tag", event_type)
|
||||
event = {
|
||||
"eventType": event_type,
|
||||
"source": source or self.source,
|
||||
"eventDate": datetime.now(timezone.utc).isoformat(),
|
||||
"body": body,
|
||||
"metadata": meta,
|
||||
}
|
||||
try:
|
||||
await self.telemetry.event(event_type, event, kind=_kind_for(event_type))
|
||||
except TypeError:
|
||||
# Compatibility with older Telemetry.event signatures.
|
||||
await self.telemetry.event(event_type, event)
|
||||
return event
|
||||
|
||||
async def emit_ic(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
|
||||
return await self.emit(_normalize_ic_code(code), payload, metadata={**metadata, "ic": True})
|
||||
|
||||
async def emit_noc(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
|
||||
return await self.emit(_normalize_noc_code(code), payload, metadata={**metadata, "noc": True})
|
||||
|
||||
async def emit_grl(self, code: str, payload: dict[str, Any] | None = None, **metadata: Any) -> dict[str, Any]:
|
||||
return await self.emit(_normalize_grl_code(code), payload, metadata={**metadata, "grl": True})
|
||||
34
app/state.py
Normal file
34
app/state.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from typing import Any, TypedDict
|
||||
|
||||
|
||||
class AgentState(TypedDict, total=False):
|
||||
tenant_id: str
|
||||
agent_id: str
|
||||
session_id: str
|
||||
conversation_key: str
|
||||
agent_profile: dict[str, Any]
|
||||
user_text: str
|
||||
sanitized_input: str
|
||||
route: str
|
||||
intent: str
|
||||
route_decision: dict[str, Any]
|
||||
answer: str
|
||||
final_answer: str
|
||||
history: list[dict[str, Any]]
|
||||
context: dict[str, Any]
|
||||
guardrail_decisions: list[dict[str, Any]]
|
||||
judge_results: list[dict[str, Any]]
|
||||
next_state: str
|
||||
domain: str
|
||||
mcp_tools: list[str]
|
||||
mcp_results: list[dict[str, Any]]
|
||||
supervisor_plan: dict[str, Any]
|
||||
supervisor_results: list[dict[str, Any]]
|
||||
active_agent: str
|
||||
blocked: bool
|
||||
supervisor_action: str
|
||||
supervisor_guidance: str
|
||||
supervisor_attempt: int
|
||||
supervisor_handover_reason: str
|
||||
output_supervisor_results: list[dict[str, Any]]
|
||||
output_guardrails_already_applied: bool
|
||||
BIN
app/workflows/.DS_Store
vendored
Normal file
BIN
app/workflows/.DS_Store
vendored
Normal file
Binary file not shown.
BIN
app/workflows/__pycache__/agent_graph.cpython-313.pyc
Normal file
BIN
app/workflows/__pycache__/agent_graph.cpython-313.pyc
Normal file
Binary file not shown.
192
app/workflows/agent_graph.py
Normal file
192
app/workflows/agent_graph.py
Normal file
@@ -0,0 +1,192 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
|
||||
from agent_framework.cache.cache import create_cache
|
||||
from agent_framework.checkpoints.langgraph_saver import create_langgraph_checkpointer
|
||||
from agent_framework.guardrails.pipeline import GuardrailPipeline
|
||||
from agent_framework.guardrails.output_supervisor import OutputSupervisor
|
||||
from agent_framework.guardrails.rail_action import RailAction
|
||||
from agent_framework.judges.judge import JudgePipeline
|
||||
from agent_framework.observability.guardrail_events import GuardrailTelemetry
|
||||
from agent_framework.observability.judge_events import JudgeTelemetry
|
||||
from agent_framework.observability.langgraph_telemetry import LangGraphDeepTelemetry
|
||||
from agent_framework.observability.workflow_events import WorkflowTelemetry
|
||||
from agent_framework.rag.embedding_provider import create_embedding_provider
|
||||
from agent_framework.rag.rag_service import RagService
|
||||
from agent_framework.routing.enterprise_router import EnterpriseRouter
|
||||
from agent_framework.supervisor.supervisor import Supervisor
|
||||
from agent_framework.observer import AgentObserver
|
||||
|
||||
from app.agents.contas_agent import ContasAgent
|
||||
from app.state import AgentState
|
||||
|
||||
|
||||
class AgentWorkflow:
|
||||
"""Workflow LangGraph do agent_contas_first migrado.
|
||||
|
||||
O desenho é propositalmente fino: a orquestração corporativa fica no
|
||||
framework. O agente executa apenas o domínio Contas; guardrails, output
|
||||
supervisor, judges, RAG, MCP, checkpoints e observabilidade são nativos.
|
||||
"""
|
||||
|
||||
def __init__(self, llm, memory, telemetry, analytics, settings, observer: AgentObserver | None = None, tool_router=None, summary_memory=None):
|
||||
self.llm = llm
|
||||
self.memory = memory
|
||||
self.telemetry = telemetry
|
||||
self.analytics = analytics
|
||||
self.observer = observer or AgentObserver(analytics=analytics)
|
||||
self.settings = settings
|
||||
self.tool_router = tool_router
|
||||
self.summary_memory = summary_memory
|
||||
self.guardrails = GuardrailPipeline(
|
||||
observer=self.observer,
|
||||
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
|
||||
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
|
||||
)
|
||||
self.output_supervisor_engine = OutputSupervisor(
|
||||
observer=self.observer,
|
||||
max_retries=int(getattr(settings, "OUTPUT_SUPERVISOR_MAX_RETRIES", 3)),
|
||||
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
|
||||
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
|
||||
)
|
||||
self.judges = JudgePipeline(llm=llm, settings=settings)
|
||||
self.supervisor = Supervisor()
|
||||
self.router = EnterpriseRouter(settings, llm=llm, telemetry=telemetry)
|
||||
self.workflow_telemetry = WorkflowTelemetry(telemetry)
|
||||
self.guardrail_telemetry = GuardrailTelemetry(telemetry)
|
||||
self.judge_telemetry = JudgeTelemetry(telemetry)
|
||||
self.langgraph_telemetry = LangGraphDeepTelemetry(telemetry)
|
||||
self.cache = create_cache(settings)
|
||||
self.embedding_provider = create_embedding_provider(settings)
|
||||
self.rag_service = RagService(settings, embedding_provider=self.embedding_provider, telemetry=telemetry, llm=llm)
|
||||
agent_kwargs = {
|
||||
"telemetry": telemetry,
|
||||
"tool_router": tool_router,
|
||||
"rag_service": self.rag_service,
|
||||
"cache": self.cache,
|
||||
"settings": settings,
|
||||
"observer": self.observer,
|
||||
"memory": memory,
|
||||
"summary_memory": summary_memory,
|
||||
}
|
||||
self.contas = ContasAgent(llm, **agent_kwargs)
|
||||
self.graph = self._build_graph()
|
||||
|
||||
def _node(self, name, fn):
|
||||
async def _wrapped(state):
|
||||
async with self.langgraph_telemetry.node(name, state):
|
||||
return await fn(state)
|
||||
return _wrapped
|
||||
|
||||
def _build_graph(self):
|
||||
builder = StateGraph(AgentState)
|
||||
builder.add_node("input_guardrails", self._node("input_guardrails", self.input_guardrails))
|
||||
builder.add_node("routing_decision", self._node("routing_decision", self.routing_decision))
|
||||
builder.add_node("contas_agent", self._node("contas_agent", self.contas_agent))
|
||||
builder.add_node("output_supervisor", self._node("output_supervisor", self.output_supervisor))
|
||||
builder.add_node("output_guardrails", self._node("output_guardrails", self.output_guardrails))
|
||||
builder.add_node("judge", self._node("judge", self.judge))
|
||||
builder.add_node("supervisor_review", self._node("supervisor_review", self.supervisor_review))
|
||||
builder.add_node("persist", self._node("persist", self.persist))
|
||||
|
||||
builder.add_edge(START, "input_guardrails")
|
||||
builder.add_conditional_edges("input_guardrails", lambda s: "blocked" if s.get("blocked") else "continue", {"blocked": "persist", "continue": "routing_decision"})
|
||||
builder.add_edge("routing_decision", "contas_agent")
|
||||
builder.add_edge("contas_agent", "output_supervisor")
|
||||
builder.add_edge("output_supervisor", "output_guardrails")
|
||||
builder.add_edge("output_guardrails", "judge")
|
||||
builder.add_edge("judge", "supervisor_review")
|
||||
builder.add_edge("supervisor_review", "persist")
|
||||
builder.add_edge("persist", END)
|
||||
return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings))
|
||||
|
||||
async def input_guardrails(self, state: AgentState) -> dict[str, Any]:
|
||||
session_id = state.get("conversation_key") or state.get("session_id")
|
||||
async with self.telemetry.span("workflow.input_guardrails", session_id=session_id, input=state.get("user_text")):
|
||||
history_texts = [m.get("content", "") for m in state.get("history", [])]
|
||||
await self.observer.emit_grl("001", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "phase": "input"}, component="workflow.input_guardrails.start")
|
||||
sanitized, decisions = await self.guardrails.run_input(
|
||||
state["user_text"],
|
||||
{**(state.get("context") or {}), "history_texts": history_texts, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "agent_profile": state.get("agent_profile") or {}},
|
||||
)
|
||||
for decision in decisions:
|
||||
await self.guardrail_telemetry.evaluated("input", decision)
|
||||
await self.observer.emit_grl("002" if decision.allowed else "004", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "phase": "input", "rail_code": getattr(decision, "code", None), "allowed": bool(decision.allowed), "reason": getattr(decision, "reason", None)}, component="workflow.input_guardrails.decision")
|
||||
if not decision.allowed:
|
||||
await self.guardrail_telemetry.blocked("input", decision)
|
||||
blocked = any(not d.allowed for d in decisions)
|
||||
await self.observer.emit_grl("009", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "phase": "input", "blocked": blocked, "decision_count": len(decisions)}, component="workflow.input_guardrails.final")
|
||||
if blocked:
|
||||
return {"sanitized_input": sanitized, "answer": "Não consegui seguir com essa mensagem por regra de segurança.", "final_answer": "Não consegui seguir com essa mensagem por regra de segurança.", "guardrail_decisions": [d.model_dump() for d in decisions], "route": "blocked", "blocked": True}
|
||||
return {"sanitized_input": sanitized, "guardrail_decisions": [d.model_dump() for d in decisions], "blocked": False}
|
||||
|
||||
async def routing_decision(self, state: AgentState) -> dict[str, Any]:
|
||||
session_id = state.get("conversation_key") or state.get("session_id")
|
||||
async with self.telemetry.span("workflow.routing_decision", session_id=session_id, input={"text": state.get("sanitized_input") or state.get("user_text")}):
|
||||
decision = await self.router.route({**state, "route": "contas_agent"})
|
||||
await self.langgraph_telemetry.edge("routing_decision", "contas_agent", state, {"method": getattr(decision, "method", None), "intent": decision.intent, "confidence": decision.confidence})
|
||||
await self.observer.emit_ic("IC.CONTAS_ROUTE_SELECTED", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "route": "contas_agent", "intent": decision.intent, "confidence": decision.confidence, "method": getattr(decision, "method", None), "mcp_tools": decision.mcp_tools}, component="workflow.routing_decision")
|
||||
return {"route": "contas_agent", "intent": decision.intent, "route_decision": decision.model_dump(mode="json"), "domain": decision.domain, "mcp_tools": decision.mcp_tools, "next_state": decision.next_state}
|
||||
|
||||
async def contas_agent(self, state: AgentState) -> dict[str, Any]:
|
||||
async with self.telemetry.span("workflow.agent.contas", session_id=state.get("conversation_key") or state.get("session_id"), input={"intent": state.get("intent")}):
|
||||
return await self.contas.run(state)
|
||||
|
||||
async def output_supervisor(self, state: AgentState) -> dict[str, Any]:
|
||||
if not bool(getattr(self.settings, "ENABLE_OUTPUT_SUPERVISOR", True)):
|
||||
return {"output_guardrails_already_applied": False, "supervisor_action": "disabled", "supervisor_attempt": int(state.get("supervisor_attempt", 0))}
|
||||
session_id = state.get("conversation_key") or state.get("session_id")
|
||||
candidate = state.get("answer") or ""
|
||||
context = {**(state.get("context") or {}), "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "session_id": session_id, "route": state.get("route"), "intent": state.get("intent")}
|
||||
async with self.telemetry.span("workflow.output_supervisor", session_id=session_id, input=candidate):
|
||||
decision = await self.output_supervisor_engine.evaluate(candidate, context)
|
||||
action = decision.action.value
|
||||
await self.observer.emit_ic("IC.OUTPUT_SUPERVISOR_COMPLETED", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "route": state.get("route"), "intent": state.get("intent"), "action": action, "approved": decision.approved, "result_count": len(decision.results)}, component="workflow.output_supervisor")
|
||||
if decision.action in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}:
|
||||
final_answer = decision.candidate
|
||||
elif decision.action == RailAction.HANDOVER:
|
||||
final_answer = "Vou encaminhar seu atendimento para continuidade com um especialista."
|
||||
else:
|
||||
final_answer = decision.fallback_message
|
||||
return {"answer": final_answer, "final_answer": final_answer, "supervisor_action": action, "supervisor_guidance": decision.guidance, "supervisor_attempt": int(state.get("supervisor_attempt", 0)) + (1 if decision.action == RailAction.RETRY else 0), "supervisor_handover_reason": decision.handover_reason, "output_supervisor_results": [{"code": r.code, "action": r.action.value, "reason": r.reason, "guidance": r.guidance, "metadata": r.metadata} for r in decision.results], "output_guardrails_already_applied": True}
|
||||
|
||||
async def output_guardrails(self, state: AgentState) -> dict[str, Any]:
|
||||
if state.get("output_guardrails_already_applied"):
|
||||
return {"final_answer": state.get("final_answer") or state.get("answer") or ""}
|
||||
session_id = state.get("conversation_key") or state.get("session_id")
|
||||
async with self.telemetry.span("workflow.output_guardrails", session_id=session_id, input=state.get("answer")):
|
||||
await self.observer.emit_grl("001", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "phase": "output", "route": state.get("route"), "intent": state.get("intent")}, component="workflow.output_guardrails.start")
|
||||
final, decisions = await self.guardrails.run_output(state["answer"], state.get("context", {}))
|
||||
for decision in decisions:
|
||||
await self.guardrail_telemetry.evaluated("output", decision)
|
||||
await self.observer.emit_grl("002" if decision.allowed else "004", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "phase": "output", "rail_code": getattr(decision, "code", None), "allowed": bool(decision.allowed), "reason": getattr(decision, "reason", None)}, component="workflow.output_guardrails.decision")
|
||||
if not decision.allowed:
|
||||
await self.guardrail_telemetry.blocked("output", decision)
|
||||
await self.observer.emit_grl("009", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "phase": "output", "blocked": any(not d.allowed for d in decisions), "decision_count": len(decisions)}, component="workflow.output_guardrails.final")
|
||||
return {"final_answer": final, "guardrail_decisions": state.get("guardrail_decisions", []) + [d.model_dump() for d in decisions]}
|
||||
|
||||
async def judge(self, state: AgentState) -> dict[str, Any]:
|
||||
async with self.telemetry.span("workflow.judge", session_id=state.get("conversation_key") or state.get("session_id"), input={"question": state.get("user_text"), "answer": state.get("final_answer")}):
|
||||
results = await self.judges.evaluate_all(state["user_text"], state["final_answer"], {**(state.get("context") or {}), "mcp_results": state.get("mcp_results", []), "rag_context": state.get("rag_context"), "route": state.get("route"), "intent": state.get("intent")})
|
||||
for result in results:
|
||||
await self.judge_telemetry.evaluated(result)
|
||||
return {"judge_results": [r.model_dump() for r in results]}
|
||||
|
||||
async def supervisor_review(self, state: AgentState) -> dict[str, Any]:
|
||||
async with self.telemetry.span("workflow.supervisor_review", session_id=state.get("conversation_key") or state.get("session_id"), input=state.get("final_answer")):
|
||||
ok, answer = await self.supervisor.review(state["final_answer"], state.get("context", {}))
|
||||
return {"final_answer": answer if ok else answer}
|
||||
|
||||
async def persist(self, state: AgentState) -> dict[str, Any]:
|
||||
session_id = state.get("conversation_key") or state["session_id"]
|
||||
async with self.telemetry.span("workflow.persist", session_id=session_id, input={"route": state.get("route"), "intent": state.get("intent")}):
|
||||
await self.observer.emit_ic("IC.CONTAS_AGENT_PERSISTED", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "route": state.get("route"), "intent": state.get("intent"), "route_decision": state.get("route_decision"), "judges": state.get("judge_results", []), "mcp_tools": state.get("mcp_tools", []), "mcp_results": state.get("mcp_results", [])}, component="workflow.persist")
|
||||
await self.observer.emit_noc("006", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "route": state.get("route"), "intent": state.get("intent"), "answer_chars": len(state.get("final_answer") or "")}, component="workflow.persist")
|
||||
await self.telemetry.event("agent.completed", {"session_id": session_id, "tenant_id": state.get("tenant_id"), "agent_id": state.get("agent_id"), "route": state.get("route"), "intent": state.get("intent"), "answer_chars": len(state.get("final_answer") or "")})
|
||||
return {"final_answer": state.get("final_answer") or state.get("answer") or ""}
|
||||
|
||||
async def ainvoke(self, state: AgentState, config: dict[str, Any] | None = None):
|
||||
return await self.graph.ainvoke(state, config=config or {"configurable": {"thread_id": state.get("conversation_key") or state.get("session_id")}})
|
||||
7
backend_mock/README.md
Normal file
7
backend_mock/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
python -m venv .venv
|
||||
|
||||
source .venv/bin/activate
|
||||
|
||||
pip install -r requirements.txt
|
||||
|
||||
uvicorn app:app --host 0.0.0.0 --port 8001 --reload
|
||||
BIN
backend_mock/__pycache__/app.cpython-313.pyc
Normal file
BIN
backend_mock/__pycache__/app.cpython-313.pyc
Normal file
Binary file not shown.
178
backend_mock/app.py
Normal file
178
backend_mock/app.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from fastapi import FastAPI, Header
|
||||
from typing import Optional
|
||||
|
||||
app = FastAPI(title="TIM Mock Backend")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# COMPLETE INVOICES
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.post("/customers/v1/completeInvoices")
|
||||
async def complete_invoices(
|
||||
payload: dict,
|
||||
authorization: Optional[str] = Header(None),
|
||||
):
|
||||
return {
|
||||
"ok": True,
|
||||
"msisdn": payload.get("msisdn"),
|
||||
"invoices": [
|
||||
{
|
||||
"invoiceId": "FAT123456",
|
||||
"amount": 189.90,
|
||||
"dueDate": "2026-06-25",
|
||||
"status": "OPEN"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PERFIL FATURA
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.post("/customers/v1/completeInvoices/profile")
|
||||
async def profile_bill(payload: dict):
|
||||
return {
|
||||
"ok": True,
|
||||
"customer": {
|
||||
"name": "Cliente Teste",
|
||||
"msisdn": payload.get("msisdn")
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTRATO
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.post("/access/v1/contractInformation")
|
||||
async def contract_information(payload: dict):
|
||||
return {
|
||||
"ok": True,
|
||||
"contractId": "CTR001",
|
||||
"plan": "TIM Black Família"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONSULTA VAS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.post("/access/v1/productsSingleVas")
|
||||
async def query_vas(payload: dict):
|
||||
return {
|
||||
"ok": True,
|
||||
"products": [
|
||||
{
|
||||
"id": "VAS001",
|
||||
"name": "TIM Banca",
|
||||
"status": "ACTIVE"
|
||||
},
|
||||
{
|
||||
"id": "VAS002",
|
||||
"name": "Babbel",
|
||||
"status": "ACTIVE"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CANCELAMENTO VAS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.delete("/access/v1/singleVas")
|
||||
async def cancel_vas(payload: dict):
|
||||
return {
|
||||
"ok": True,
|
||||
"message": "VAS cancelado com sucesso"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BLOQUEIO VAS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.post("/customers/v1/createPartialBlocking")
|
||||
async def block_vas(payload: dict):
|
||||
return {
|
||||
"ok": True,
|
||||
"operation": "block",
|
||||
"status": "SUCCESS"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# RECUPERAR PDF FATURA
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.post("/invoices/v1/invoiceRecover")
|
||||
async def invoice_recover(payload: dict):
|
||||
return {
|
||||
"ok": True,
|
||||
"url": "https://mock.tim.com/faturas/FAT123456.pdf"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PROTOCOLO / SR
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.post("/customers/v1/backOfficeSRopening")
|
||||
async def protocol(payload: dict):
|
||||
return {
|
||||
"ok": True,
|
||||
"protocol": "202606150001"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CONTESTAÇÃO
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.post("/interactions/v1/customerContestation")
|
||||
async def contestation(payload: dict):
|
||||
return {
|
||||
"ok": True,
|
||||
"protocol": "CNT987654",
|
||||
"status": "OPEN"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# STATUS SR
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.post("/interactions/v1/statusServiceRequest")
|
||||
async def sr_status(payload: dict):
|
||||
return {
|
||||
"ok": True,
|
||||
"protocol": payload.get("protocol"),
|
||||
"status": "IN_PROGRESS"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SMS
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.post("/customers/v1/smsSend")
|
||||
async def sms_send(payload: dict):
|
||||
return {
|
||||
"ok": True,
|
||||
"messageId": "SMS001"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# TRACKING
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.post("/customers/v1/trackingActivities")
|
||||
async def tracking(payload: dict):
|
||||
return {
|
||||
"ok": True,
|
||||
"activities": [
|
||||
{
|
||||
"date": "2026-06-15",
|
||||
"description": "Solicitação registrada"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# HEALTH
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "UP"}
|
||||
18
config/agents.yaml
Normal file
18
config/agents.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
default_agent_id: contas_first
|
||||
agents:
|
||||
- agent_id: contas_first
|
||||
name: Agent Contas FIRST Migrado
|
||||
description: Agente de contas/faturas FIRST reimplementado para usar ao máximo o agent_framework_oci.
|
||||
prompt_policy_path: ./config/agents/contas_first/prompt_policy.yaml
|
||||
routing_config_path: ./config/routing.yaml
|
||||
guardrails_config_path: ./config/agents/contas_first/guardrails.yaml
|
||||
judges_config_path: ./config/agents/contas_first/judges.yaml
|
||||
mcp_servers_config_path: ./config/mcp_servers.yaml
|
||||
tools_config_path: ./config/tools.yaml
|
||||
metadata:
|
||||
domain: telecom_contas
|
||||
rag_namespace: contas_first
|
||||
system_prefix: |
|
||||
Você está executando o Agent Contas FIRST migrado para o agent_framework_oci.
|
||||
Use isolamento por tenant/agent/session, BusinessContext canônico,
|
||||
guardrails, judges, memória, RAG, MCP Router e observabilidade do framework.
|
||||
20
config/agents/contas_first/guardrails.yaml
Normal file
20
config/agents/contas_first/guardrails.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
# Guardrails do contas usando o pipeline calibrado nativo do framework.
|
||||
# Não importar pipeline legado do agent_contas_first.
|
||||
input:
|
||||
- code: MSK
|
||||
enabled: true
|
||||
- code: VLOOP
|
||||
enabled: true
|
||||
- code: PINJ
|
||||
enabled: true
|
||||
- code: DLEX_IN
|
||||
enabled: true
|
||||
output:
|
||||
- code: REVPREC
|
||||
enabled: true
|
||||
- code: PINJ
|
||||
enabled: true
|
||||
- code: DLEX_OUT
|
||||
enabled: true
|
||||
- code: RAGSEC
|
||||
enabled: true
|
||||
16
config/agents/contas_first/judges.yaml
Normal file
16
config/agents/contas_first/judges.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
enabled: true
|
||||
fail_closed: true
|
||||
profile: judge
|
||||
judges:
|
||||
- name: response_quality
|
||||
enabled: true
|
||||
threshold: 0.7
|
||||
- name: groundedness
|
||||
enabled: true
|
||||
threshold: 0.6
|
||||
- name: sentiment
|
||||
enabled: true
|
||||
fail_on_negative: false
|
||||
- name: tone
|
||||
enabled: true
|
||||
fail_closed: true
|
||||
7
config/agents/contas_first/prompt_policy.yaml
Normal file
7
config/agents/contas_first/prompt_policy.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
id: contas_first_prompt_policy
|
||||
version: 1
|
||||
description: Prompt base isolado do agent_contas_first migrado.
|
||||
system_prefix: |
|
||||
Você é um agente corporativo de telecom especializado em contas, faturas,
|
||||
pagamentos, segunda via, contestação, planos e serviços VAS.
|
||||
Use dados MCP e RAG como evidência. Não invente informação operacional.
|
||||
12
config/guardrails.yaml
Normal file
12
config/guardrails.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
input:
|
||||
- code: MSK
|
||||
enabled: true
|
||||
- code: VLOOP
|
||||
enabled: true
|
||||
output:
|
||||
- code: REVPREC
|
||||
enabled: true
|
||||
- code: PINJ
|
||||
enabled: true
|
||||
- code: DLEX_OUT
|
||||
enabled: true
|
||||
52
config/identity.yaml
Normal file
52
config/identity.yaml
Normal file
@@ -0,0 +1,52 @@
|
||||
identity:
|
||||
version: "2"
|
||||
required:
|
||||
- session_key
|
||||
keys:
|
||||
customer_key:
|
||||
description: Cliente/assinante canônico. Para telecom, normalmente msisdn.
|
||||
sources:
|
||||
- business_context.customer_key
|
||||
- customer_key
|
||||
- msisdn
|
||||
- ani
|
||||
- from
|
||||
- user_id
|
||||
contract_key:
|
||||
description: Fatura, contrato ou asset principal.
|
||||
sources:
|
||||
- business_context.contract_key
|
||||
- contract_key
|
||||
- invoice_id
|
||||
- current_invoice_number
|
||||
- past_invoice_number
|
||||
- asset_id
|
||||
interaction_key:
|
||||
description: Chave externa da interação/call/chat.
|
||||
sources:
|
||||
- business_context.interaction_key
|
||||
- interaction_key
|
||||
- ura_call_id
|
||||
- call_id
|
||||
- message_id
|
||||
account_key:
|
||||
description: Conta de cobrança/conta comercial.
|
||||
sources:
|
||||
- business_context.account_key
|
||||
- account_key
|
||||
- account_id
|
||||
- billing_account_id
|
||||
resource_key:
|
||||
description: Linha/produto/asset específico.
|
||||
sources:
|
||||
- business_context.resource_key
|
||||
- resource_key
|
||||
- asset_id
|
||||
- product_id
|
||||
session_key:
|
||||
description: Sessão técnica estável escopada por tenant e agente.
|
||||
sources:
|
||||
- business_context.session_key
|
||||
- session_key
|
||||
- conversation_key
|
||||
- session_id
|
||||
20
config/judges.yaml
Normal file
20
config/judges.yaml
Normal file
@@ -0,0 +1,20 @@
|
||||
enabled: true
|
||||
fail_closed: true
|
||||
profile: judge
|
||||
|
||||
judges:
|
||||
- name: response_quality
|
||||
enabled: true
|
||||
threshold: 0.7
|
||||
|
||||
- name: groundedness
|
||||
enabled: true
|
||||
threshold: 0.6
|
||||
|
||||
- name: sentiment
|
||||
enabled: true
|
||||
fail_on_negative: false
|
||||
|
||||
- name: tone
|
||||
enabled: true
|
||||
fail_closed: true
|
||||
76
config/mcp_parameter_mapping.yaml
Normal file
76
config/mcp_parameter_mapping.yaml
Normal file
@@ -0,0 +1,76 @@
|
||||
mcp_parameter_mapping:
|
||||
defaults:
|
||||
use_mock: false
|
||||
tools:
|
||||
consultar_fatura:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
contract_key: invoice_id
|
||||
account_key: customer_id
|
||||
interaction_key: ura_call_id
|
||||
session_key: session_id
|
||||
consultar_pagamentos:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
account_key: customer_id
|
||||
interaction_key: ura_call_id
|
||||
session_key: session_id
|
||||
consultar_plano:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
resource_key: asset_id
|
||||
account_key: customer_id
|
||||
session_key: session_id
|
||||
listar_servicos:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
resource_key: asset_id
|
||||
session_key: session_id
|
||||
recuperar_pdf_fatura:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
contract_key: invoice_id
|
||||
account_key: customer_id
|
||||
session_key: session_id
|
||||
registrar_protocolo:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
interaction_key: interaction_call_id
|
||||
session_key: session_id
|
||||
abrir_contestacao:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
contract_key: invoice_id
|
||||
account_key: customer_id
|
||||
interaction_key: ura_call_id
|
||||
session_key: session_id
|
||||
consultar_status_sr:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
interaction_key: protocol_number
|
||||
session_key: session_id
|
||||
atualizar_status_sr:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
interaction_key: protocol_number
|
||||
session_key: session_id
|
||||
cancelar_vas:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
resource_key: app_id
|
||||
session_key: session_id
|
||||
bloquear_vas:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
resource_key: app_id
|
||||
session_key: session_id
|
||||
enviar_sms:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
session_key: session_id
|
||||
registrar_tracking:
|
||||
map:
|
||||
customer_key: msisdn
|
||||
contract_key: invoice_id
|
||||
interaction_key: protocol_number
|
||||
session_key: session_id
|
||||
6
config/mcp_servers.docker.yaml
Normal file
6
config/mcp_servers.docker.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
servers:
|
||||
legacy_tim:
|
||||
transport: http
|
||||
endpoint: http://legacy-tim-mcp:8100/mcp
|
||||
enabled: true
|
||||
description: MCP Server adaptador dos serviços externos legados do Agent Contas FIRST/TIM.
|
||||
6
config/mcp_servers.yaml
Normal file
6
config/mcp_servers.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
servers:
|
||||
legacy_tim:
|
||||
transport: http
|
||||
endpoint: http://localhost:8100/mcp
|
||||
enabled: true
|
||||
description: MCP Server adaptador dos serviços externos legados do Agent Contas FIRST/TIM.
|
||||
8
config/prompt_policy.yaml
Normal file
8
config/prompt_policy.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
prompt_policy:
|
||||
default_profile: contas_agent
|
||||
profiles:
|
||||
contas_agent:
|
||||
system_prefix: |
|
||||
Agent Contas FIRST migrado. Use BusinessContext, MCP, RAG, memória,
|
||||
guardrails e judges do framework. Não duplique regras legadas.
|
||||
tone: objetivo, claro, seguro e auditável
|
||||
143
config/routing.yaml
Normal file
143
config/routing.yaml
Normal file
@@ -0,0 +1,143 @@
|
||||
router:
|
||||
mode: router
|
||||
fallback_agent: contas_agent
|
||||
confidence_threshold: 0.55
|
||||
allow_handoff: false
|
||||
|
||||
state_policies:
|
||||
- state: WAITING_BILLING_CONFIRMATION
|
||||
agent: contas_agent
|
||||
description: Mantém respostas curtas de confirmação dentro do fluxo de contas.
|
||||
- state: WAITING_SERVICE_CONFIRMATION
|
||||
agent: contas_agent
|
||||
description: Mantém confirmações de plano/serviço/VAS dentro do Agent Contas.
|
||||
|
||||
intents:
|
||||
- name: billing_invoice_explanation
|
||||
domain: telecom_contas
|
||||
agent: contas_agent
|
||||
description: Explicação de fatura, divergência de valor, cobrança proporcional, itens da conta e dúvidas de vencimento.
|
||||
priority: 10
|
||||
mcp_tools: [consultar_fatura, consultar_pagamentos]
|
||||
keywords: [fatura, conta, cobrança, valor, alto, divergente, proporcional, pro rata, vencimento, boleto, explicação]
|
||||
examples:
|
||||
- Minha fatura veio alta.
|
||||
- Quero entender uma cobrança proporcional.
|
||||
- Por que minha conta mudou de valor?
|
||||
|
||||
- name: billing_payment_status
|
||||
domain: telecom_contas
|
||||
agent: contas_agent
|
||||
description: Consulta pagamentos, baixa, status de pagamento e histórico.
|
||||
priority: 20
|
||||
mcp_tools: [consultar_pagamentos, consultar_fatura]
|
||||
keywords: [pagamento, paguei, baixa, comprovante, histórico, aberto, quitado]
|
||||
examples:
|
||||
- Meu pagamento já caiu?
|
||||
- Quero consultar meus pagamentos.
|
||||
|
||||
- name: billing_second_copy
|
||||
domain: telecom_contas
|
||||
agent: contas_agent
|
||||
description: Segunda via, código de barras e dados da fatura atual.
|
||||
priority: 30
|
||||
mcp_tools: [consultar_fatura]
|
||||
keywords: [segunda via, boleto, código de barras, barcode, copiar linha digitável]
|
||||
examples:
|
||||
- Preciso da segunda via da conta.
|
||||
- Me envie o boleto da fatura.
|
||||
|
||||
- name: billing_contestation
|
||||
domain: telecom_contas
|
||||
agent: contas_agent
|
||||
description: Contestação de cobrança, análise de item de fatura e abertura de fluxo com confirmação.
|
||||
priority: 40
|
||||
mcp_tools: [consultar_fatura, consultar_pagamentos]
|
||||
keywords: [contestar, contestação, cobrança indevida, não reconheço, reclamar, ajustar, crédito]
|
||||
examples:
|
||||
- Quero contestar essa cobrança.
|
||||
- Não reconheço um item da fatura.
|
||||
|
||||
- name: service_plan_information
|
||||
domain: telecom_contas
|
||||
agent: contas_agent
|
||||
description: Consulta plano, pacote, asset, internet, roaming e benefícios.
|
||||
priority: 50
|
||||
mcp_tools: [consultar_plano, listar_servicos]
|
||||
keywords: [plano, pacote, internet, roaming, benefício, asset, produto]
|
||||
examples:
|
||||
- Qual é meu plano?
|
||||
- Tenho roaming incluso?
|
||||
|
||||
- name: vas_services_information
|
||||
domain: telecom_contas
|
||||
agent: contas_agent
|
||||
description: Serviços adicionais, VAS, assinaturas, bloqueio/cancelamento informativo.
|
||||
priority: 60
|
||||
mcp_tools: [listar_servicos, consultar_plano]
|
||||
keywords: [vas, serviço adicional, assinatura, serviços digitais, cancelar serviço, bloquear serviço]
|
||||
examples:
|
||||
- Quais serviços VAS estão ativos?
|
||||
- Quero entender serviços digitais na minha conta.
|
||||
|
||||
- name: generic_billing_rag
|
||||
domain: telecom_contas
|
||||
agent: contas_agent
|
||||
description: Perguntas de política, regras de atendimento e conhecimento sem necessidade inicial de tool operacional.
|
||||
priority: 90
|
||||
mcp_tools: []
|
||||
keywords: [política, regra, procedimento, anatel, prazo, atendimento]
|
||||
examples:
|
||||
- Qual é a regra para cobrança proporcional?
|
||||
|
||||
- name: billing_protocol_status
|
||||
domain: telecom_contas
|
||||
agent: contas_agent
|
||||
description: Consulta status de protocolo/SR/contestação.
|
||||
priority: 35
|
||||
mcp_tools: [consultar_status_sr]
|
||||
keywords: [protocolo, sr, status da contestação, acompanhamento, andamento]
|
||||
examples:
|
||||
- Quero saber o status do meu protocolo.
|
||||
- Como está minha contestação?
|
||||
|
||||
- name: billing_protocol_register
|
||||
domain: telecom_contas
|
||||
agent: contas_agent
|
||||
description: Registro de protocolo de atendimento quando o fluxo exigir e houver confirmação.
|
||||
priority: 75
|
||||
mcp_tools: [registrar_protocolo]
|
||||
keywords: [gerar protocolo, registrar protocolo, abrir protocolo]
|
||||
examples:
|
||||
- Gere um protocolo para este atendimento.
|
||||
|
||||
- name: vas_cancel_or_block
|
||||
domain: telecom_contas
|
||||
agent: contas_agent
|
||||
description: Cancelamento/bloqueio de VAS/SVA/serviço adicional após confirmação explícita.
|
||||
priority: 45
|
||||
mcp_tools: [listar_servicos, cancelar_vas, bloquear_vas, registrar_protocolo]
|
||||
keywords: [cancelar vas, cancelar serviço, bloquear vas, bloquear serviço, remover assinatura]
|
||||
examples:
|
||||
- Quero cancelar esse serviço adicional.
|
||||
- Bloqueie esse VAS.
|
||||
|
||||
- name: send_billing_sms
|
||||
domain: telecom_contas
|
||||
agent: contas_agent
|
||||
description: Envio de SMS com link ou informação de atendimento quando autorizado.
|
||||
priority: 80
|
||||
mcp_tools: [enviar_sms]
|
||||
keywords: [enviar sms, mandar sms, envia por mensagem, link por sms]
|
||||
examples:
|
||||
- Envie o boleto por SMS.
|
||||
|
||||
- name: tracking_activity
|
||||
domain: telecom_contas
|
||||
agent: contas_agent
|
||||
description: Registro de tracking/activity operacional.
|
||||
priority: 85
|
||||
mcp_tools: [registrar_tracking]
|
||||
keywords: [registrar atividade, tracking, registrar acompanhamento]
|
||||
examples:
|
||||
- Registre a atividade do atendimento.
|
||||
167
config/tools.yaml
Normal file
167
config/tools.yaml
Normal file
@@ -0,0 +1,167 @@
|
||||
tools:
|
||||
consultar_fatura:
|
||||
description: Consulta dados de fatura, valores, vencimento, itens, status e linha digitável.
|
||||
mcp_server: legacy_tim
|
||||
enabled: true
|
||||
tool_type: query
|
||||
cache: { enabled: true, ttl_seconds: 300 }
|
||||
args_schema:
|
||||
msisdn: string
|
||||
invoice_id: string
|
||||
customer_id: string
|
||||
|
||||
consultar_pagamentos:
|
||||
description: Consulta histórico/status de pagamentos do cliente.
|
||||
mcp_server: legacy_tim
|
||||
enabled: true
|
||||
tool_type: query
|
||||
cache: { enabled: true, ttl_seconds: 300 }
|
||||
args_schema:
|
||||
msisdn: string
|
||||
customer_id: string
|
||||
|
||||
consultar_plano:
|
||||
description: Consulta contrato/plano ativo e atributos comerciais.
|
||||
mcp_server: legacy_tim
|
||||
enabled: true
|
||||
tool_type: query
|
||||
cache: { enabled: true, ttl_seconds: 600 }
|
||||
args_schema:
|
||||
msisdn: string
|
||||
asset_id: string
|
||||
customer_id: string
|
||||
|
||||
listar_servicos:
|
||||
description: Lista serviços ativos, VAS, SVA, bundles e benefícios da linha.
|
||||
mcp_server: legacy_tim
|
||||
enabled: true
|
||||
tool_type: query
|
||||
cache: { enabled: true, ttl_seconds: 600 }
|
||||
args_schema:
|
||||
msisdn: string
|
||||
asset_id: string
|
||||
|
||||
recuperar_pdf_fatura:
|
||||
description: Recupera PDF/segunda via da fatura quando disponível.
|
||||
mcp_server: legacy_tim
|
||||
enabled: true
|
||||
tool_type: query
|
||||
cache: { enabled: true, ttl_seconds: 300 }
|
||||
args_schema:
|
||||
msisdn: string
|
||||
invoice_id: string
|
||||
customer_id: string
|
||||
output: string
|
||||
|
||||
registrar_protocolo:
|
||||
description: Registra protocolo de atendimento nos serviços legados/Siebel quando aplicável.
|
||||
mcp_server: legacy_tim
|
||||
enabled: true
|
||||
tool_type: action
|
||||
confirmation_required: true
|
||||
requires: [msisdn]
|
||||
cache: { enabled: false }
|
||||
args_schema:
|
||||
msisdn: string
|
||||
protocol_number: string
|
||||
interaction_call_id: string
|
||||
reason: string
|
||||
channel: string
|
||||
|
||||
abrir_contestacao:
|
||||
description: Abre contestação de cobrança/SR para itens de fatura após confirmação.
|
||||
mcp_server: legacy_tim
|
||||
enabled: true
|
||||
tool_type: action
|
||||
confirmation_required: true
|
||||
requires: [msisdn, invoice_id]
|
||||
cache: { enabled: false }
|
||||
args_schema:
|
||||
msisdn: string
|
||||
invoice_id: string
|
||||
customer_id: string
|
||||
sr: string
|
||||
items: list
|
||||
reason: string
|
||||
amount: number
|
||||
|
||||
consultar_status_sr:
|
||||
description: Consulta status de SR/protocolo de contestação ou atendimento.
|
||||
mcp_server: legacy_tim
|
||||
enabled: true
|
||||
tool_type: query
|
||||
cache: { enabled: true, ttl_seconds: 180 }
|
||||
args_schema:
|
||||
msisdn: string
|
||||
sr: string
|
||||
protocol_number: string
|
||||
|
||||
atualizar_status_sr:
|
||||
description: Atualiza status de SR/protocolo nos serviços externos.
|
||||
mcp_server: legacy_tim
|
||||
enabled: true
|
||||
tool_type: action
|
||||
confirmation_required: true
|
||||
requires: [msisdn, protocol_number]
|
||||
cache: { enabled: false }
|
||||
args_schema:
|
||||
msisdn: string
|
||||
protocol_number: string
|
||||
status: string
|
||||
notes: string
|
||||
|
||||
cancelar_vas:
|
||||
description: Solicita cancelamento de VAS/SVA/serviço adicional após confirmação explícita.
|
||||
mcp_server: legacy_tim
|
||||
enabled: true
|
||||
tool_type: action
|
||||
confirmation_required: true
|
||||
requires: [msisdn]
|
||||
cache: { enabled: false }
|
||||
args_schema:
|
||||
msisdn: string
|
||||
app_id: string
|
||||
service_id: string
|
||||
csp_id: string
|
||||
|
||||
bloquear_vas:
|
||||
description: Solicita bloqueio de VAS/SVA/serviço adicional após confirmação explícita.
|
||||
mcp_server: legacy_tim
|
||||
enabled: true
|
||||
tool_type: action
|
||||
confirmation_required: true
|
||||
requires: [msisdn]
|
||||
cache: { enabled: false }
|
||||
args_schema:
|
||||
msisdn: string
|
||||
app_id: string
|
||||
service_id: string
|
||||
csp_id: string
|
||||
|
||||
enviar_sms:
|
||||
description: Envia SMS ao cliente com link/informação quando o fluxo exigir.
|
||||
mcp_server: legacy_tim
|
||||
enabled: true
|
||||
tool_type: action
|
||||
confirmation_required: true
|
||||
requires: [msisdn]
|
||||
cache: { enabled: false }
|
||||
args_schema:
|
||||
msisdn: string
|
||||
message: string
|
||||
long_url: string
|
||||
notify_url: string
|
||||
|
||||
registrar_tracking:
|
||||
description: Registra tracking/activity de atendimento ou fatura nos serviços externos.
|
||||
mcp_server: legacy_tim
|
||||
enabled: true
|
||||
tool_type: action
|
||||
confirmation_required: false
|
||||
cache: { enabled: false }
|
||||
args_schema:
|
||||
msisdn: string
|
||||
protocol_number: string
|
||||
invoice_id: string
|
||||
activity_type: string
|
||||
activity_status: string
|
||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
legacy-tim-mcp:
|
||||
image: python:3.12-slim
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./mcp_servers/legacy_tim_mcp:/app
|
||||
env_file:
|
||||
- ./mcp_servers/legacy_tim_mcp/.env.example
|
||||
command: bash -lc "pip install -r requirements.txt && uvicorn main:app --host 0.0.0.0 --port 8100"
|
||||
ports:
|
||||
- "8100:8100"
|
||||
|
||||
agent-contas:
|
||||
image: python:3.12-slim
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
# Monte o framework local se quiser usar pip install -e:
|
||||
# - /mnt/d/MSI Projects/agent_framework_oci/agent_framework:/framework/agent_framework
|
||||
env_file:
|
||||
- ./.env.example
|
||||
environment:
|
||||
MCP_SERVERS_CONFIG_PATH: ./config/mcp_servers.docker.yaml
|
||||
command: bash -lc "pip install -r requirements.txt && uvicorn app.main:app --host 0.0.0.0 --port 8000"
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
- legacy-tim-mcp
|
||||
95
docs/ATUALIZACAO_TEMPLATE_ANALYTICS_OUTPUT_SUPERVISOR.md
Normal file
95
docs/ATUALIZACAO_TEMPLATE_ANALYTICS_OUTPUT_SUPERVISOR.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Atualização do Template Backend — Analytics, Observer, NOC/GRL e OutputSupervisor
|
||||
|
||||
Esta versão do `agent_template_backend` foi atualizada para consumir as novidades transportadas para o `agent_framework`.
|
||||
|
||||
## 1. Analytics e Pub/Sub
|
||||
|
||||
O backend não chama mais diretamente apenas o publisher antigo de eventos. Agora ele cria um `AnalyticsPublisher`:
|
||||
|
||||
```python
|
||||
from agent_framework.analytics.factory import create_analytics_publisher
|
||||
from agent_framework.observability.observer import AgentObserver
|
||||
|
||||
analytics = create_analytics_publisher(settings)
|
||||
observer = AgentObserver(analytics=analytics)
|
||||
```
|
||||
|
||||
Com isso, o mesmo backend pode publicar em:
|
||||
|
||||
- OCI Streaming
|
||||
- GCP Pub/Sub
|
||||
- CompositePublisher, quando `ANALYTICS_PROVIDERS=oci_streaming,pubsub`
|
||||
- Noop, quando analytics estiver desligado
|
||||
|
||||
## 2. Configuração mínima
|
||||
|
||||
```env
|
||||
ENABLE_ANALYTICS=true
|
||||
ANALYTICS_PROVIDERS=pubsub
|
||||
GCP_PUBSUB_TOPIC_PATH=projects/<project-id>/topics/<topic-name>
|
||||
GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-service-account.json
|
||||
```
|
||||
|
||||
Para publicar simultaneamente em OCI Streaming e GCP Pub/Sub:
|
||||
|
||||
```env
|
||||
ENABLE_ANALYTICS=true
|
||||
ANALYTICS_PROVIDERS=oci_streaming,pubsub
|
||||
ENABLE_OCI_STREAMING=true
|
||||
OCI_STREAM_ENDPOINT=<endpoint>
|
||||
OCI_STREAM_OCID=<stream-ocid>
|
||||
GCP_PUBSUB_TOPIC_PATH=projects/<project-id>/topics/<topic-name>
|
||||
```
|
||||
|
||||
## 3. Observer corporativo
|
||||
|
||||
O workflow recebeu emissão automática dos principais eventos corporativos:
|
||||
|
||||
- `NOC.001`: início do workflow
|
||||
- `NOC.005`: exceção fatal no workflow
|
||||
- `NOC.006`: fim do workflow antes da resposta final
|
||||
- `IC.AGENT_COMPLETED`: evento informacional de conclusão
|
||||
- `GRL.001` a `GRL.009`: emitidos pelo `OutputSupervisor`
|
||||
|
||||
## 4. OutputSupervisor
|
||||
|
||||
Foi inserido um novo nó LangGraph:
|
||||
|
||||
```text
|
||||
agent -> output_supervisor -> output_guardrails -> judge -> supervisor_review -> persist
|
||||
```
|
||||
|
||||
O `OutputSupervisor` não substitui o supervisor de roteamento. Ele valida a saída candidata do agente usando o contrato corporativo:
|
||||
|
||||
- `allow`
|
||||
- `sanitize`
|
||||
- `retry`
|
||||
- `block`
|
||||
- `handover`
|
||||
- `observe`
|
||||
|
||||
Para compatibilidade com os guardrails já existentes, o template inclui o adapter `LegacyOutputGuardrailRail`, que converte decisões antigas `allowed=True/False` para `RailAction`.
|
||||
|
||||
## 5. Campos adicionados ao AgentState
|
||||
|
||||
```python
|
||||
supervisor_action: str
|
||||
supervisor_guidance: str
|
||||
supervisor_attempt: int
|
||||
supervisor_handover_reason: str
|
||||
output_supervisor_results: list[dict]
|
||||
output_guardrails_already_applied: bool
|
||||
```
|
||||
|
||||
## 6. Arquivos alterados
|
||||
|
||||
- `agent_template_backend/app/main.py`
|
||||
- `agent_template_backend/app/workflows/agent_graph.py`
|
||||
- `agent_template_backend/app/state.py`
|
||||
- `agent_template_backend/.env`
|
||||
- `agent_template_backend/requirements.txt`
|
||||
- `agent_framework/src/agent_framework/config/settings.py`
|
||||
|
||||
## 7. Observação importante
|
||||
|
||||
O `OutputSupervisor` roda os guardrails de saída por meio do adapter legado e marca `output_guardrails_already_applied=True`. Assim o nó `output_guardrails` permanece no grafo para compatibilidade, mas evita reexecutar a mesma validação quando o supervisor já aplicou os rails.
|
||||
45
docs/COMO_USAR_IC_NOC_GRL_NO_TEMPLATE.md
Normal file
45
docs/COMO_USAR_IC_NOC_GRL_NO_TEMPLATE.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Como usar IC, NOC e GRL no Template Backend
|
||||
|
||||
## IC — Item de Controle
|
||||
|
||||
Use IC para registrar eventos de negócio relevantes.
|
||||
|
||||
```python
|
||||
await observer.emit_ic(
|
||||
"IC.FATURA_CONSULTADA",
|
||||
{"session_id": session_id, "invoice_id": invoice_id},
|
||||
component="billing_agent",
|
||||
)
|
||||
```
|
||||
|
||||
## NOC — Evento operacional
|
||||
|
||||
Use NOC para saúde técnica, latência, erros e checkpoints operacionais.
|
||||
|
||||
```python
|
||||
await observer.emit_noc(
|
||||
"003",
|
||||
{"session_id": session_id, "resourceName": "ADB", "latencyMs": 120},
|
||||
component="repository",
|
||||
)
|
||||
```
|
||||
|
||||
## GRL — Evento de guardrail
|
||||
|
||||
Normalmente o framework emite GRL automaticamente. Use manualmente apenas para
|
||||
rails customizados dentro do agente.
|
||||
|
||||
```python
|
||||
await observer.emit_grl(
|
||||
"OBSERVE",
|
||||
{"session_id": session_id, "rail_code": "CUSTOM_POLICY"},
|
||||
component="custom_rail",
|
||||
)
|
||||
```
|
||||
|
||||
## Onde já existe no template
|
||||
|
||||
- `app/workflows/agent_graph.py` emite IC/NOC no ciclo do workflow.
|
||||
- `app/agents/runtime.py` emite IC para MCP/tools.
|
||||
- `app/agents/*_agent.py` contém exemplos dentro do método `run()`.
|
||||
- `app/examples/` contém exemplos isolados.
|
||||
48
docs/CONVERSATION_SUMMARY_MEMORY_BACKEND.md
Normal file
48
docs/CONVERSATION_SUMMARY_MEMORY_BACKEND.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Backends atualizados para ConversationSummaryMemory
|
||||
|
||||
Esta versão dos backends foi compatibilizada com a versão do framework que adiciona `ConversationSummaryMemory`.
|
||||
|
||||
## O que mudou
|
||||
|
||||
- `app/main.py` agora inicializa `create_conversation_summary_memory(...)` junto com `create_memory(...)`.
|
||||
- `AgentWorkflow` recebe `summary_memory` e repassa para os agentes.
|
||||
- Os agentes não montam mais prompts manuais para o LLM; agora usam `build_messages()` do framework.
|
||||
- Antes da chamada ao LLM, os agentes executam `await self.prepare_memory_context(state)`.
|
||||
- Quando habilitado por `.env`, o prompt passa a receber:
|
||||
- resumo acumulado da conversa;
|
||||
- últimas mensagens completas;
|
||||
- mensagem atual;
|
||||
- BusinessContext;
|
||||
- MCP results;
|
||||
- RAG context e metadata.
|
||||
|
||||
## Configuração
|
||||
|
||||
```env
|
||||
ENABLE_CONVERSATION_SUMMARY_MEMORY=true
|
||||
MEMORY_CONTEXT_STRATEGY=summary
|
||||
MEMORY_HISTORY_LIMIT=80
|
||||
MEMORY_RECENT_MESSAGES_LIMIT=8
|
||||
MEMORY_SUMMARY_TRIGGER_MESSAGES=20
|
||||
MEMORY_MAX_SUMMARY_CHARS=6000
|
||||
MEMORY_SUMMARY_USE_LLM=true
|
||||
MEMORY_INJECT_RECENT_MESSAGES=true
|
||||
MEMORY_INJECT_SUMMARY=true
|
||||
```
|
||||
|
||||
## Backends alterados
|
||||
|
||||
- `backoffice_convertido_framework`
|
||||
- `agent_template_backend`
|
||||
- `agent_template_backend_day_zero`
|
||||
|
||||
## Observação importante
|
||||
|
||||
Estes backends esperam que o pacote `agent_framework` instalado/conectado seja a versão com os módulos:
|
||||
|
||||
- `agent_framework.memory.summary_memory`
|
||||
- `agent_framework.memory.summary_store`
|
||||
- `AgentRuntimeMixin.prepare_memory_context()`
|
||||
- `AgentRuntimeMixin.build_messages()` com injeção de memória
|
||||
|
||||
Use junto com o ZIP `agent_framework_conversation_summary_memory.zip`.
|
||||
51
docs/ENV_REAL_INTEGRATION_STATUS.md
Normal file
51
docs/ENV_REAL_INTEGRATION_STATUS.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Status real do `.env` legado TIM/FIRST
|
||||
|
||||
Esta versão removeu a duplicidade do `.env.example` e do `mcp_servers/legacy_tim_mcp/.env.example`.
|
||||
|
||||
## Decisão aplicada
|
||||
|
||||
- `TIM_MCP_USE_MOCK=false` por padrão.
|
||||
- Mock só é usado quando você definir explicitamente `TIM_MCP_USE_MOCK=true`.
|
||||
- Os defaults reais encontrados no `agent_contas_first` original foram copiados.
|
||||
- Secrets e URLs que não existiam no original continuam vazios de propósito.
|
||||
|
||||
## Defaults efetivamente encontrados no original
|
||||
|
||||
| Variável nova | Valor preenchido | Origem no legado |
|
||||
|---|---|---|
|
||||
| `TIM_PROFILE_BILL_URL` | `http://10.151.3.100:8000` | `TIM_URL_PERFIL_FATURA` / `tim_profile_bill_url` |
|
||||
| `TIM_QUERY_VAS_URL` | `http://10.151.3.100:8000` | `TIM_URL_CONSULTA_VAS` / `TIM_CONSULTA_URL` / `tim_query_url` |
|
||||
| `TIM_BLOCK_VAS_URL` | `http://10.151.3.100:8000/customers/v1/partialServiceBlocking` | `TIM_URL_BLOQUEIO_VAS` / `TIM_BLOQUEIO_URL` / `tim_block_url` |
|
||||
| `TIM_CLIENT_ID` | `AIAGENTCR` | `tim_default_client_id` |
|
||||
| `TIM_USER_ID` | `AIAGENTCR` | usado como default nos commands/SR |
|
||||
| `TIM_DEFAULT_CSP_ID` | `740` | `tim_default_csp_id` |
|
||||
| `TIM_DEFAULT_CHANNEL` | `APP` | `tim_default_channel` |
|
||||
| `TIM_BLOCK_VAS_OPERATION_TYPE` | `block` | `tim_block_operation_type` |
|
||||
| `TIM_BLOCK_VAS_PAYLOAD_MODE` | `auto` | `tim_block_payload_mode` |
|
||||
| `TIM_BLOCK_VAS_ACCEPT_ENCODING` | `gzip,deflate` | `tim_block_accept_encoding` |
|
||||
|
||||
## Valores que permanecem vazios
|
||||
|
||||
Permanecem vazios porque o pacote original não trouxe valores reais para eles:
|
||||
|
||||
- `TIM_COMPLETE_INVOICES_URL`
|
||||
- `TIM_CONTRATO_URL`
|
||||
- `TIM_BILL_PDF_URL`
|
||||
- `TIM_PROTOCOL_URL`
|
||||
- `TIM_CUSTOMER_CONTESTATION_URL`
|
||||
- `TIM_SERVICE_REQUEST_STATUS_URL`
|
||||
- `TIM_CANCEL_VAS_URL`
|
||||
- `TIM_SMS_URL`
|
||||
- `TIM_TRACKING_ACTIVITIES_URL`
|
||||
- todos os `*_AUTH`, `TIM_AUTHORIZATION_OAM`, `TIM_CN_FIELD`, `TIM_TYPE_FIELD`
|
||||
|
||||
## Onde alterar
|
||||
|
||||
Para executar localmente:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
cp mcp_servers/legacy_tim_mcp/.env.example mcp_servers/legacy_tim_mcp/.env
|
||||
```
|
||||
|
||||
Depois preencha apenas os endpoints/secrets que existirem no seu ambiente.
|
||||
84
docs/FRAMEWORK_CHANNEL_INPUT_MODE.md
Normal file
84
docs/FRAMEWORK_CHANNEL_INPUT_MODE.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# FRAMEWORK_CHANNEL_INPUT_MODE
|
||||
|
||||
This backend setting controls what kind of channel input the Agent Framework backend accepts.
|
||||
|
||||
It replaces the ambiguous use of `CHANNEL_GATEWAY_MODE` inside the backend.
|
||||
|
||||
## Values
|
||||
|
||||
```env
|
||||
FRAMEWORK_CHANNEL_INPUT_MODE=embedded
|
||||
```
|
||||
|
||||
The backend may use internal channel adapters to interpret simple/native channel payloads. This is useful for demos, labs, local frontend, curl tests, and simple environments.
|
||||
|
||||
```env
|
||||
FRAMEWORK_CHANNEL_INPUT_MODE=external
|
||||
```
|
||||
|
||||
The backend accepts only a normalized `GatewayRequest` produced by an external Channel Gateway. It does not parse native WhatsApp, Voice, Teams, or other channel payloads.
|
||||
|
||||
## Recommended enterprise setup
|
||||
|
||||
In the external channel gateway service:
|
||||
|
||||
```env
|
||||
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
|
||||
```
|
||||
|
||||
In this backend:
|
||||
|
||||
```env
|
||||
FRAMEWORK_CHANNEL_INPUT_MODE=external
|
||||
```
|
||||
|
||||
Flow:
|
||||
|
||||
```text
|
||||
External channel / browser / customer adapter
|
||||
↓
|
||||
channel_gateway:7000
|
||||
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
|
||||
↓ GatewayRequest
|
||||
agent_template_backend:8000
|
||||
FRAMEWORK_CHANNEL_INPUT_MODE=external
|
||||
↓
|
||||
LangGraph / Agents / MCP / Guardrails
|
||||
```
|
||||
|
||||
## Valid direct request to backend in external mode
|
||||
|
||||
```bash
|
||||
curl -s -X POST "http://localhost:8000/gateway/message" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"channel": "web",
|
||||
"tenant_id": "default",
|
||||
"agent_id": "telecom_contas",
|
||||
"payload": {
|
||||
"message": "Quero consultar minha fatura",
|
||||
"session_id": "backend-external-ok-001"
|
||||
}
|
||||
}' | jq
|
||||
```
|
||||
|
||||
## Invalid direct request to backend in external mode
|
||||
|
||||
```bash
|
||||
curl -i -s -X POST "http://localhost:8000/gateway/message" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"message": "Quero consultar minha fatura",
|
||||
"session_id": "raw-payload-error-001"
|
||||
}'
|
||||
```
|
||||
|
||||
Expected result: HTTP 422.
|
||||
|
||||
## Legacy compatibility
|
||||
|
||||
`CHANNEL_GATEWAY_MODE` is still present as a legacy alias for older environments, but new deployments should use:
|
||||
|
||||
```env
|
||||
FRAMEWORK_CHANNEL_INPUT_MODE=embedded|external
|
||||
```
|
||||
127
docs/GUARDRAILS_PARALLELOS_OBSERVER_IC.md
Normal file
127
docs/GUARDRAILS_PARALLELOS_OBSERVER_IC.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Guardrails paralelos fail-fast e Observer IC
|
||||
|
||||
## O que foi implementado
|
||||
|
||||
### 1. ParallelRailExecutor
|
||||
|
||||
Arquivo principal:
|
||||
|
||||
```text
|
||||
agent_framework/src/agent_framework/guardrails/parallel_executor.py
|
||||
```
|
||||
|
||||
Também foi criado um alias de compatibilidade:
|
||||
|
||||
```text
|
||||
agent_framework/src/agent_framework/guardrails/executor.py
|
||||
```
|
||||
|
||||
Esse alias evita erro quando algum código antigo importar:
|
||||
|
||||
```python
|
||||
from agent_framework.guardrails.executor import ParallelRailExecutor
|
||||
```
|
||||
|
||||
### 2. Execução paralela no GuardrailPipeline
|
||||
|
||||
Arquivo alterado:
|
||||
|
||||
```text
|
||||
agent_framework/src/agent_framework/guardrails/pipeline.py
|
||||
```
|
||||
|
||||
O pipeline continua retornando o contrato antigo:
|
||||
|
||||
```python
|
||||
(texto_final, list[RailDecision])
|
||||
```
|
||||
|
||||
mas internamente pode executar rails em paralelo com fail-fast.
|
||||
|
||||
### 3. Execução paralela no OutputSupervisor
|
||||
|
||||
Arquivo alterado:
|
||||
|
||||
```text
|
||||
agent_framework/src/agent_framework/guardrails/output_supervisor.py
|
||||
```
|
||||
|
||||
O `OutputSupervisor` agora usa `ParallelRailExecutor` quando habilitado.
|
||||
|
||||
### 4. Configuração
|
||||
|
||||
Novas configurações:
|
||||
|
||||
```env
|
||||
ENABLE_PARALLEL_GUARDRAILS=true
|
||||
GUARDRAILS_FAIL_FAST=true
|
||||
```
|
||||
|
||||
Também foram adicionadas em:
|
||||
|
||||
```text
|
||||
agent_framework/src/agent_framework/config/settings.py
|
||||
.env
|
||||
.env.example
|
||||
agent_template_backend/.env
|
||||
agent_template_backend_day_zero/.env
|
||||
```
|
||||
|
||||
### 5. Observer IC
|
||||
|
||||
O `AgentObserver` já tinha `emit_ic()`.
|
||||
|
||||
Foi complementada a API global compatível com FIRST/TIM:
|
||||
|
||||
```python
|
||||
from agent_framework.observer import ic, aic, noc, anoc, grl, agrl
|
||||
```
|
||||
|
||||
Exemplos:
|
||||
|
||||
```python
|
||||
ic("AGENT_COMPLETED", data={"session_id": "..."})
|
||||
await aic("MCP_TOOL_CALLED", data={"tool_name": "consultar_fatura"})
|
||||
```
|
||||
|
||||
### 6. ICs automáticos no template backend
|
||||
|
||||
O backend emite agora:
|
||||
|
||||
```text
|
||||
IC.AGENT_STARTED
|
||||
IC.ROUTE_SELECTED
|
||||
IC.MCP_TOOL_CALLED
|
||||
IC.TOOL_CALLED
|
||||
IC.AGENT_COMPLETED
|
||||
```
|
||||
|
||||
Além dos eventos já existentes:
|
||||
|
||||
```text
|
||||
NOC.001
|
||||
NOC.005
|
||||
NOC.006
|
||||
GRL.001 ... GRL.009
|
||||
```
|
||||
|
||||
## Validações executadas
|
||||
|
||||
Foram executadas validações locais com `PYTHONPATH=agent_framework/src`:
|
||||
|
||||
```bash
|
||||
python3 -m compileall -q agent_framework/src/agent_framework agent_template_backend/app agent_template_backend_day_zero/app
|
||||
```
|
||||
|
||||
Smoke tests executados:
|
||||
|
||||
```text
|
||||
1. Import de ParallelRailExecutor via agent_framework.guardrails
|
||||
2. Import de ParallelRailExecutor via agent_framework.guardrails.executor
|
||||
3. Execução fail-fast: FastBlock cancela SlowAllow
|
||||
4. GuardrailPipeline paralelo retorna RailDecision legado
|
||||
5. OutputSupervisor paralelo retorna RailAction.BLOCK
|
||||
6. API global observer.ic/noc/grl/aic/anoc/agrl
|
||||
```
|
||||
|
||||
Observação: o import completo do `agent_template_backend.app.workflows.agent_graph` depende de `langgraph`, que não está instalado no sandbox de validação. O arquivo foi validado por `compileall`, e a dependência já consta em `agent_template_backend/requirements.txt`.
|
||||
42
docs/IMPLEMENTACAO_IC_NOC_GRL_SEM_REMOVER_LOGICA.md
Normal file
42
docs/IMPLEMENTACAO_IC_NOC_GRL_SEM_REMOVER_LOGICA.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# Implementação IC/NOC/GRL preservando lógica existente
|
||||
|
||||
Esta versão mantém a lógica original dos agentes do `agent_template_backend` e adiciona observabilidade corporativa.
|
||||
|
||||
## IC adicionados nos agentes
|
||||
|
||||
Cada agente agora emite eventos de negócio sem alterar a resposta final:
|
||||
|
||||
- `IC.BILLING_AGENT_STARTED` / `IC.BILLING_AGENT_COMPLETED`
|
||||
- `IC.ORDERS_AGENT_STARTED` / `IC.ORDERS_AGENT_COMPLETED`
|
||||
- `IC.PRODUCT_AGENT_STARTED` / `IC.PRODUCT_AGENT_COMPLETED`
|
||||
- `IC.SUPPORT_AGENT_STARTED` / `IC.SUPPORT_AGENT_COMPLETED`
|
||||
- `IC.<AGENT>_MCP_CONTEXT_COLLECTED` quando houver dados MCP
|
||||
- `IC.<AGENT>_RAG_CONTEXT_RETRIEVED` quando RAG estiver habilitado
|
||||
|
||||
O mixin `AgentRuntimeMixin` também emite:
|
||||
|
||||
- `IC.MCP_TOOL_CALLED` antes da chamada MCP
|
||||
- `IC.TOOL_CALLED` após a chamada MCP
|
||||
|
||||
## NOC
|
||||
|
||||
O workflow já emite eventos operacionais principais:
|
||||
|
||||
- `NOC.001` no início da execução
|
||||
- `NOC.005` em exceção fatal
|
||||
- `NOC.006` na persistência/finalização
|
||||
|
||||
## GRL
|
||||
|
||||
O backend agora também exemplifica emissão GRL no workflow:
|
||||
|
||||
- `GRL.001` início do pipeline de guardrails
|
||||
- `GRL.002` decisão allow
|
||||
- `GRL.004` decisão block
|
||||
- `GRL.009` decisão final agregada
|
||||
|
||||
Quando `OutputSupervisor` está habilitado, ele continua sendo o principal mecanismo corporativo de supervisão de saída.
|
||||
|
||||
## Garantia
|
||||
|
||||
A lógica original dos agentes não foi substituída por stubs. As chamadas LLM, MCP, RAG, cache e os retornos originais foram preservados.
|
||||
5
docs/LANGFUSE_SINGLE_TRACE_OBSERVER_FIX.md
Normal file
5
docs/LANGFUSE_SINGLE_TRACE_OBSERVER_FIX.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Langfuse single trace observer fix
|
||||
|
||||
This backend now uses `TelemetryBackedAgentObserver` instead of publishing IC/NOC/GRL through `AgentObserver(analytics=...)`.
|
||||
|
||||
Why: when analytics includes the Langfuse provider, observer events such as `IC.AGENT_COMPLETED` and `NOC.006` may create a second root trace with little detail. Emitting those events through `Telemetry.event(...)` keeps them inside the active request/workflow trace.
|
||||
129
docs/MCP_DIAGNOSTIC_FIX_V6.md
Normal file
129
docs/MCP_DIAGNOSTIC_FIX_V6.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# Correção V6 — Diagnóstico MCP e Judges
|
||||
|
||||
## Problema observado
|
||||
|
||||
No teste, o `agent_framework_oci` acionava o MCP Router, mas os resultados das tools vinham como:
|
||||
|
||||
```text
|
||||
ok: False
|
||||
result: None
|
||||
error: ''
|
||||
metadata: {}
|
||||
cached: False
|
||||
```
|
||||
|
||||
Isso impedia saber se a falha era URL, autenticação, payload, timeout, status HTTP, rede ou bug no adapter.
|
||||
|
||||
Também apareceu:
|
||||
|
||||
```text
|
||||
Judge calibrado declarado em judges.yaml, mas nenhum LLM foi fornecido ao pipeline.
|
||||
```
|
||||
|
||||
## Correções aplicadas
|
||||
|
||||
### 1. MCP server preserva erro real
|
||||
|
||||
Arquivo alterado:
|
||||
|
||||
```text
|
||||
mcp_servers/legacy_tim_mcp/main.py
|
||||
```
|
||||
|
||||
Agora toda falha HTTP ou de rede retorna `metadata` com:
|
||||
|
||||
- `server`
|
||||
- `tool`
|
||||
- `mock`
|
||||
- `prefix`
|
||||
- `method`
|
||||
- `url`
|
||||
- `status_code`, quando houver resposta HTTP
|
||||
- `response_content_type`
|
||||
- `response_body_preview`
|
||||
- `exception_type`
|
||||
- `timeout_seconds`
|
||||
- `request_payload_keys`
|
||||
- headers sanitizados, sem segredo
|
||||
|
||||
Exemplo esperado:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"result": null,
|
||||
"error": "TIM_COMPLETE_INVOICES falhou: status=400 body=...",
|
||||
"metadata": {
|
||||
"server": "legacy_tim",
|
||||
"tool": "consultar_fatura",
|
||||
"mock": false,
|
||||
"method": "POST",
|
||||
"url": "http://10.151.3.100:8000/customers/v1/completeInvoices",
|
||||
"status_code": 400,
|
||||
"exception_type": "HTTPStatusError",
|
||||
"response_body_preview": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Judges recebem LLM do framework
|
||||
|
||||
Arquivo alterado:
|
||||
|
||||
```text
|
||||
app/workflows/agent_graph.py
|
||||
```
|
||||
|
||||
Antes:
|
||||
|
||||
```python
|
||||
self.judges = JudgePipeline()
|
||||
```
|
||||
|
||||
Depois:
|
||||
|
||||
```python
|
||||
self.judges = JudgePipeline(llm=llm, settings=settings)
|
||||
```
|
||||
|
||||
Assim o `judges.yaml` usa o LLM criado pelo backend e o profile `judge` do `llm_profiles.yaml`.
|
||||
|
||||
### 3. `.env` corrigido para uso com `source .env`
|
||||
|
||||
Valores com espaço, como `Basic ...` e `Bearer ...`, foram colocados entre aspas.
|
||||
|
||||
Exemplo:
|
||||
|
||||
```env
|
||||
TIM_COMPLETE_INVOICES_AUTH="Basic ..."
|
||||
```
|
||||
|
||||
## Como validar
|
||||
|
||||
Suba o MCP:
|
||||
|
||||
```bash
|
||||
./scripts/start_legacy_tim_mcp.sh
|
||||
```
|
||||
|
||||
Teste direto:
|
||||
|
||||
```bash
|
||||
./tests/curl_mcp_consultar_fatura.sh
|
||||
```
|
||||
|
||||
Depois teste pelo backend:
|
||||
|
||||
```bash
|
||||
./tests/curl_gateway_fatura.sh
|
||||
```
|
||||
|
||||
No log, procure por:
|
||||
|
||||
```text
|
||||
mcp_results
|
||||
metadata
|
||||
status_code
|
||||
response_body_preview
|
||||
mock: false
|
||||
```
|
||||
56
docs/MCP_ENV_LOADING_FIX_V8.md
Normal file
56
docs/MCP_ENV_LOADING_FIX_V8.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# MCP ENV Loading Fix - V8
|
||||
|
||||
## Problema encontrado
|
||||
|
||||
No pacote V7, o MCP Server podia retornar:
|
||||
|
||||
```text
|
||||
Endpoint externo não configurado para TIM_COMPLETE_INVOICES
|
||||
```
|
||||
|
||||
mesmo quando `.env` continha:
|
||||
|
||||
```env
|
||||
TIM_COMPLETE_INVOICES_URL=http://10.151.3.100:8000/customers/v1/completeInvoices
|
||||
```
|
||||
|
||||
A causa era que o MCP dependia do ambiente já estar carregado pelo shell (`source .env`) ou pelo Docker `env_file`. Quando o `uvicorn main:app` era iniciado diretamente, o arquivo `.env` não era carregado automaticamente.
|
||||
|
||||
## Correção aplicada
|
||||
|
||||
O MCP Server agora carrega automaticamente:
|
||||
|
||||
1. `.env` do diretório corrente.
|
||||
2. `.env` local do MCP Server: `mcp_servers/legacy_tim_mcp/.env`.
|
||||
3. `.env` da raiz do projeto.
|
||||
|
||||
A carga preenche variáveis ausentes ou exportadas como string vazia.
|
||||
|
||||
## Novos endpoints de diagnóstico
|
||||
|
||||
```bash
|
||||
curl http://localhost:8100/health | jq
|
||||
curl http://localhost:8100/debug/config | jq
|
||||
```
|
||||
|
||||
O retorno agora mostra:
|
||||
|
||||
- arquivos `.env` carregados;
|
||||
- endpoints configurados;
|
||||
- URLs resolvidas;
|
||||
- presença de autenticação sem expor o secret.
|
||||
|
||||
## Validação esperada
|
||||
|
||||
Para `TIM_COMPLETE_INVOICES`, deve aparecer:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "http://10.151.3.100:8000/customers/v1/completeInvoices",
|
||||
"has_auth": true,
|
||||
"timeout": 30.0,
|
||||
"client_id": "AIAGENTCR"
|
||||
}
|
||||
```
|
||||
|
||||
Se a URL aparecer vazia, o MCP não está lendo o `.env` correto ou o arquivo não contém a variável.
|
||||
44
docs/MCP_EXTERNAL_SERVICES_IMPLEMENTATION.md
Normal file
44
docs/MCP_EXTERNAL_SERVICES_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Implementação MCP dos serviços externos
|
||||
|
||||
Esta versão adiciona um MCP Server `legacy_tim_mcp` para adaptar os comandos externos do legado ao contrato do `agent_framework_oci`.
|
||||
|
||||
## Contrato
|
||||
|
||||
O framework chama:
|
||||
|
||||
```text
|
||||
POST /mcp/tools/call
|
||||
```
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{"tool_name":"consultar_fatura","arguments":{"msisdn":"11999999999"}}
|
||||
```
|
||||
|
||||
## Onde configurar
|
||||
|
||||
- Backend: `config/mcp_servers.yaml`
|
||||
- Tools: `config/tools.yaml`
|
||||
- Mapeamento: `config/mcp_parameter_mapping.yaml`
|
||||
- MCP Server: `mcp_servers/legacy_tim_mcp/.env`
|
||||
|
||||
## Serviços cobertos
|
||||
|
||||
- Fatura completa / perfil de fatura
|
||||
- Pagamentos
|
||||
- Contrato/plano
|
||||
- VAS/SVA/listagem de serviços
|
||||
- PDF/segunda via
|
||||
- Protocolo
|
||||
- Contestação/SR
|
||||
- Status/atualização de SR
|
||||
- Cancelamento/bloqueio VAS
|
||||
- SMS
|
||||
- Tracking activities
|
||||
|
||||
## Modo mock vs real
|
||||
|
||||
`TIM_MCP_USE_MOCK=true` devolve respostas simuladas.
|
||||
|
||||
`TIM_MCP_USE_MOCK=false` exige endpoints reais `TIM_*_URL`.
|
||||
104
docs/MCP_LEGACY_COMMAND_MIGRATION_V7.md
Normal file
104
docs/MCP_LEGACY_COMMAND_MIGRATION_V7.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# V7 — MCP legado TIM/FIRST com padrão Command + ApiGateway
|
||||
|
||||
Esta versão corrige a camada MCP do `agent_contas_first` para ficar mais próxima do projeto original.
|
||||
|
||||
## O que mudou
|
||||
|
||||
A versão anterior ainda fazia chamadas HTTP muito genéricas. A V7 implementa no MCP Server local uma camada inspirada no legado:
|
||||
|
||||
```text
|
||||
MCP Tool
|
||||
-> LegacyTimCommand
|
||||
-> EndpointConfig
|
||||
-> HttpApiGateway
|
||||
-> GatewayError rico
|
||||
-> retorno MCP com error/metadata preservados
|
||||
```
|
||||
|
||||
## Evidência esperada nos logs
|
||||
|
||||
Em caso de erro real do backend TIM, o `mcp_results` deve aparecer assim:
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_name": "consultar_fatura",
|
||||
"server_name": "legacy_tim",
|
||||
"ok": false,
|
||||
"error": "TIM_COMPLETE_INVOICES falhou: status=400 body=...",
|
||||
"metadata": {
|
||||
"server": "legacy_tim",
|
||||
"tool": "consultar_fatura",
|
||||
"mock": false,
|
||||
"endpoint": "TIM_COMPLETE_INVOICES",
|
||||
"method": "POST",
|
||||
"url": "http://10.151.3.100:8000/customers/v1/completeInvoices",
|
||||
"status_code": 400,
|
||||
"error_code": "HTTP_400",
|
||||
"provider_service": "...",
|
||||
"provider_error_code": "...",
|
||||
"provider_error_message": "...",
|
||||
"message_id": "...",
|
||||
"kong_request_id": "...",
|
||||
"latency_ms": 123,
|
||||
"exception_type": "HTTPStatusError",
|
||||
"response_body_preview": "..."
|
||||
},
|
||||
"cached": false
|
||||
}
|
||||
```
|
||||
|
||||
Se ainda aparecer:
|
||||
|
||||
```json
|
||||
{"ok": false, "error": "", "metadata": {}}
|
||||
```
|
||||
|
||||
então provavelmente uma janela antiga do `legacy_tim_mcp` ainda está rodando ou o backend não foi reiniciado depois da troca do pacote.
|
||||
|
||||
## Tools migradas para Commands
|
||||
|
||||
| MCP Tool | Command V7 | Endpoint |
|
||||
|---|---|---|
|
||||
| `consultar_fatura` | `CompleteInvoicesCommand` | `TIM_COMPLETE_INVOICES` |
|
||||
| `consultar_pagamentos` | `ProfileBillCommand` | `TIM_PROFILE_BILL` |
|
||||
| `consultar_plano` | `ContractInformationCommand` | `TIM_CONTRATO` |
|
||||
| `listar_servicos` | `QueryVasCommand` | `TIM_QUERY_VAS` |
|
||||
| `recuperar_pdf_fatura` | `InvoiceRecoverCommand` | `TIM_BILL_PDF` |
|
||||
| `registrar_protocolo` | `ProtocolCommand` | `TIM_PROTOCOL` |
|
||||
| `abrir_contestacao` | `CustomerContestationCommand` | `TIM_CUSTOMER_CONTESTATION` |
|
||||
| `consultar_status_sr` | `ServiceRequestStatusCommand` | `TIM_SERVICE_REQUEST_STATUS` |
|
||||
| `cancelar_vas` | `CancelVasCommand` | `TIM_CANCEL_VAS` |
|
||||
| `bloquear_vas` | `BlockVasCommand` | `TIM_BLOCK_VAS` |
|
||||
| `enviar_sms` | `SmsCommand` | `TIM_SMS` |
|
||||
| `registrar_tracking` | `TrackingActivitiesCommand` | `TIM_TRACKING_ACTIVITIES` |
|
||||
|
||||
## Como testar só o MCP
|
||||
|
||||
```bash
|
||||
cd mcp_servers/legacy_tim_mcp
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
uvicorn main:app --host 0.0.0.0 --port 8100 --reload
|
||||
```
|
||||
|
||||
Em outro terminal:
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8100/health | jq
|
||||
|
||||
curl -s -X POST http://localhost:8100/mcp/tools/call \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"tool_name":"consultar_fatura",
|
||||
"arguments":{"msisdn":"11999999999","invoice_id":"3000131180"}
|
||||
}' | jq
|
||||
```
|
||||
|
||||
## Checklist de execução
|
||||
|
||||
1. Pare qualquer processo antigo do MCP na porta `8100`.
|
||||
2. Suba o MCP V7.
|
||||
3. Reinicie o backend do agente.
|
||||
4. Rode o curl do gateway.
|
||||
5. Verifique se `mcp_results[].metadata.endpoint`, `status_code`, `url` e `response_body_preview` aparecem.
|
||||
49
docs/MCP_REAL_INTEGRATION_NOTES.md
Normal file
49
docs/MCP_REAL_INTEGRATION_NOTES.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# MCP Legacy TIM/FIRST — integração real
|
||||
|
||||
Esta versão troca o MCP puramente mockado por um adapter HTTP real baseado nos contratos encontrados no projeto original `agent_contas_first`.
|
||||
|
||||
## Modo de execução
|
||||
|
||||
Por padrão:
|
||||
|
||||
```env
|
||||
TIM_MCP_USE_MOCK=false
|
||||
```
|
||||
|
||||
Com isso, as tools chamam os backends externos configurados no `.env`. Para testes locais sem rede TIM, use:
|
||||
|
||||
```env
|
||||
TIM_MCP_USE_MOCK=true
|
||||
```
|
||||
|
||||
## Contratos implementados a partir do legado
|
||||
|
||||
| Tool MCP | Endpoint/env | Método | Contrato legado usado |
|
||||
|---|---|---:|---|
|
||||
| `consultar_fatura` | `TIM_COMPLETE_INVOICES_URL` | POST | `CompleteInvoicesCommand`: `{"msisdn": "..."}` |
|
||||
| `consultar_pagamentos` | `TIM_PROFILE_BILL_URL` | POST | `QueryProfileBillCommand`: `{"msisdn": "..."}` |
|
||||
| `consultar_plano` | `TIM_CONTRATO_URL/{msisdn}` | GET | `ContratoCommand` |
|
||||
| `listar_servicos` | `TIM_QUERY_VAS_URL/{msisdn}` | GET | `QueryVasCommand` |
|
||||
| `recuperar_pdf_fatura` | `TIM_BILL_PDF_URL?invoiceId=&msisdn=&customerId=` | GET | `InvoiceRecoverCommand` / `BillPdfCommand` |
|
||||
| `explicar_fatura` | `TIM_INVOICE_EXPLANATION_URL/{msisdn}?channel=APP` | GET | `InvoiceExplanationCommand` |
|
||||
| `registrar_protocolo` | `TIM_PROTOCOL_URL` | POST | `RegisterProtocolV2Command` |
|
||||
| `abrir_contestacao` | `TIM_CUSTOMER_CONTESTATION_URL` | POST | `CustomerContestationCommand` |
|
||||
| `consultar_status_sr` / `atualizar_status_sr` | `TIM_SERVICE_REQUEST_STATUS_URL` | POST | `UpdateServiceRequestStatusCommand` |
|
||||
| `cancelar_vas` | `TIM_CANCEL_VAS_URL` | DELETE | `CancellationVasCommand` |
|
||||
| `bloquear_vas` | `TIM_BLOCK_VAS_URL` | POST | `BlockVasCommand` |
|
||||
| `enviar_sms` | `TIM_SMS_URL` | POST | `SendSmsCommand` |
|
||||
| `registrar_tracking` | `TIM_TRACKING_ACTIVITIES_URL` | POST | `TrackingActivitiesCommand` |
|
||||
|
||||
## Observação importante
|
||||
|
||||
O projeto original não trazia secrets reais. O adapter usa os nomes de variáveis do legado e aliases compatíveis, mas os valores de autenticação precisam ser preenchidos no `.env` do ambiente.
|
||||
|
||||
## Validação rápida
|
||||
|
||||
```bash
|
||||
./scripts/start_legacy_tim_mcp.sh
|
||||
curl http://localhost:8100/health
|
||||
curl -s -X POST http://localhost:8100/mcp/tools/call \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"tool_name":"consultar_fatura","arguments":{"msisdn":"11999999999"}}' | jq
|
||||
```
|
||||
55
docs/TIM_LEGACY_ENV_MAPPING.md
Normal file
55
docs/TIM_LEGACY_ENV_MAPPING.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# TIM/FIRST Legacy Environment Mapping
|
||||
|
||||
Este arquivo documenta o que foi recuperado do `agent_contas_first` original para configurar o MCP server adaptado.
|
||||
|
||||
## Resultado da auditoria
|
||||
|
||||
O pacote original não trouxe `.env`, Secret, ConfigMap ou arquivo com credenciais reais. A fonte disponível foi `agente_contas_tim/config.py`, que define aliases e alguns defaults. Por isso:
|
||||
|
||||
- URLs com default no código foram preenchidas.
|
||||
- URLs que no legado tinham default vazio permaneceram vazias.
|
||||
- Tokens/Authorization permaneceram vazios por segurança e porque não estavam no pacote.
|
||||
- O MCP server agora aceita tanto os nomes novos quanto os aliases antigos.
|
||||
|
||||
## Tabela de mapeamento
|
||||
|
||||
| Variável nova | Alias/variável no legado | Valor inferido | Status |
|
||||
|---|---|---:|---|
|
||||
| `TIM_QUERY_VAS_URL` | `TIM_URL_CONSULTA_VAS`, `TIM_CONSULTA_URL` | `http://10.151.3.100:8000` | preenchido |
|
||||
| `TIM_QUERY_VAS_AUTH` | `TIM_QUERY_AUTH`, `TIM_CONSULTA_AUTH` | vazio | requer secret |
|
||||
| `TIM_BLOCK_VAS_URL` | `TIM_URL_BLOQUEIO_VAS`, `TIM_BLOQUEIO_URL` | `http://10.151.3.100:8000/customers/v1/partialServiceBlocking` | preenchido |
|
||||
| `TIM_BLOCK_VAS_AUTH` | `TIM_BLOCK_AUTH`, `TIM_BLOQUEIO_AUTH` | vazio | requer secret |
|
||||
| `TIM_CANCEL_VAS_URL` | `TIM_CANCELLATION_URL`, `TIM_CANCELAMENTO_URL` | vazio | não encontrado no pacote |
|
||||
| `TIM_CANCEL_VAS_AUTH` | `TIM_CANCELLATION_AUTH`, `TIM_CANCELAMENTO_AUTH` | vazio | requer secret |
|
||||
| `TIM_AUTHORIZATION_OAM` | `TIM_CANCELLATION_AUTH_OAM`, `TIM_CANCELAMENTO_AUTH_OAM`, `TIM_CANCELAMENTO_AUTHORIZATION_OAM` | vazio | requer secret |
|
||||
| `TIM_CN_FIELD` | `TIM_CANCELLATION_CN_FIELD`, `TIM_CANCELAMENTO_CN_FIELD` | vazio | não encontrado no pacote |
|
||||
| `TIM_TYPE_FIELD` | `TIM_CANCELLATION_TYPE_FIELD`, `TIM_CANCELAMENTO_TYPE_FIELD` | vazio | não encontrado no pacote |
|
||||
| `TIM_PROFILE_BILL_URL` | `TIM_URL_PERFIL_FATURA` | `http://10.151.3.100:8000` | preenchido |
|
||||
| `TIM_COMPLETE_INVOICES_URL` | `TIM_COMPLETE_INVOICES_URL` | vazio | não encontrado no pacote |
|
||||
| `TIM_CONTRATO_URL` | `TIM_CONTRATO_URL` | vazio | não encontrado no pacote |
|
||||
| `TIM_BILL_PDF_URL` | `TIM_URL_INVOICE_RECOVER` | vazio | não encontrado no pacote |
|
||||
| `TIM_BILL_PDF_AUTH` | `TIM_INVOICE_RECOVER_AUTH` | vazio | requer secret |
|
||||
| `TIM_PROTOCOL_URL` | `TIM_PROTOCOL_URL` | vazio | não encontrado no pacote |
|
||||
| `TIM_CUSTOMER_CONTESTATION_URL` | `TIM_CUSTOMER_CONTESTATION_URL` | vazio | não encontrado no pacote |
|
||||
| `TIM_SERVICE_REQUEST_STATUS_URL` | `TIM_SERVICE_REQUEST_STATUS_URL` | vazio | não encontrado no pacote |
|
||||
| `TIM_SMS_AUTH` | `SMS_BARCODE_AUTH`, `TIM_SMS_AUTH` | vazio | requer secret |
|
||||
| `TIM_TRACKING_ACTIVITIES_URL` | `TIM_TRACKING_ACTIVITIES_URL` | vazio | não encontrado no pacote |
|
||||
| `TIM_TRACKING_ACTIVITIES_USER_LOGIN` | igual | vazio | obrigatório no legado, não encontrado |
|
||||
| `TIM_TRACKING_ACTIVITIES_CHANNEL` | igual | vazio | obrigatório no legado, não encontrado |
|
||||
| `TIM_TRACKING_ACTIVITIES_CLIENT_ID` | igual | vazio | obrigatório no legado, não encontrado |
|
||||
|
||||
## Implicação para teste
|
||||
|
||||
Para teste local sem rede TIM, use:
|
||||
|
||||
```env
|
||||
TIM_MCP_USE_MOCK=true
|
||||
```
|
||||
|
||||
Para teste integrado real, altere para:
|
||||
|
||||
```env
|
||||
TIM_MCP_USE_MOCK=false
|
||||
```
|
||||
|
||||
e preencha as URLs/secrets vazias acima.
|
||||
62
docs/VALIDACAO_BACKEND_IC_NOC_GRL.md
Normal file
62
docs/VALIDACAO_BACKEND_IC_NOC_GRL.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Validação da versão com IC/NOC/GRL
|
||||
|
||||
Validações executadas nesta geração:
|
||||
|
||||
1. `python -m compileall -q agent_template_backend/app`
|
||||
- Resultado: OK.
|
||||
|
||||
2. Smoke test dos agentes com LLM fake e Observer fake:
|
||||
- `BillingAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
|
||||
- `OrdersAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
|
||||
- `ProductAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
|
||||
- `SupportAgent`: preservou resposta gerada pelo LLM e emitiu IC de início/fim.
|
||||
|
||||
3. Verificação de regressão:
|
||||
- Nenhum agente retorna `Template Enterprise ativo`.
|
||||
- A lógica LLM/MCP/RAG/cache existente foi preservada.
|
||||
|
||||
## Eventos adicionados
|
||||
|
||||
### IC
|
||||
|
||||
Nos agentes:
|
||||
|
||||
- `IC.BILLING_AGENT_STARTED`
|
||||
- `IC.BILLING_MCP_CONTEXT_COLLECTED`
|
||||
- `IC.BILLING_RAG_CONTEXT_RETRIEVED`
|
||||
- `IC.BILLING_AGENT_COMPLETED`
|
||||
- `IC.ORDERS_AGENT_STARTED`
|
||||
- `IC.ORDERS_MCP_CONTEXT_COLLECTED`
|
||||
- `IC.ORDERS_RAG_CONTEXT_RETRIEVED`
|
||||
- `IC.ORDERS_AGENT_COMPLETED`
|
||||
- `IC.PRODUCT_AGENT_STARTED`
|
||||
- `IC.PRODUCT_MCP_CONTEXT_COLLECTED`
|
||||
- `IC.PRODUCT_RAG_CONTEXT_RETRIEVED`
|
||||
- `IC.PRODUCT_AGENT_COMPLETED`
|
||||
- `IC.SUPPORT_AGENT_STARTED`
|
||||
- `IC.SUPPORT_MCP_CONTEXT_COLLECTED`
|
||||
- `IC.SUPPORT_RAG_CONTEXT_RETRIEVED`
|
||||
- `IC.SUPPORT_AGENT_COMPLETED`
|
||||
|
||||
No runtime MCP:
|
||||
|
||||
- `IC.MCP_TOOL_CALLED`
|
||||
- `IC.TOOL_CALLED`
|
||||
|
||||
### NOC
|
||||
|
||||
Já integrados no workflow:
|
||||
|
||||
- `NOC.001` início da execução
|
||||
- `NOC.005` erro fatal
|
||||
- `NOC.006` finalização/persistência
|
||||
|
||||
### GRL
|
||||
|
||||
No workflow de guardrails:
|
||||
|
||||
- `GRL.001` início da avaliação
|
||||
- `GRL.002` allow
|
||||
- `GRL.004` block
|
||||
- `GRL.009` decisão final
|
||||
|
||||
3
docs/VALIDACAO_TEMPLATE_ENTERPRISE.txt
Normal file
3
docs/VALIDACAO_TEMPLATE_ENTERPRISE.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
compileall app: OK
|
||||
Arquivos de exemplos IC/NOC/GRL adicionados.
|
||||
Agentes preservam implementação original comentada.
|
||||
BIN
legacy_reference/.DS_Store
vendored
Normal file
BIN
legacy_reference/.DS_Store
vendored
Normal file
Binary file not shown.
1581
legacy_reference/docs/conversation_spec.md
Normal file
1581
legacy_reference/docs/conversation_spec.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
legacy_reference/workflows/.DS_Store
vendored
Normal file
BIN
legacy_reference/workflows/.DS_Store
vendored
Normal file
Binary file not shown.
3
legacy_reference/workflows/__init__.py
Normal file
3
legacy_reference/workflows/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from agente_contas_tim.workflows.service import WorkflowService
|
||||
|
||||
__all__ = ["WorkflowService"]
|
||||
15
legacy_reference/workflows/actions/__init__.py
Normal file
15
legacy_reference/workflows/actions/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from agente_contas_tim.workflows.actions.discovery import ensure_actions_loaded
|
||||
from agente_contas_tim.workflows.actions.registry import (
|
||||
DEFAULT_ACTION_REGISTRY,
|
||||
ActionRegistry,
|
||||
WorkflowRuntimeContext,
|
||||
workflow_action,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ActionRegistry",
|
||||
"DEFAULT_ACTION_REGISTRY",
|
||||
"WorkflowRuntimeContext",
|
||||
"ensure_actions_loaded",
|
||||
"workflow_action",
|
||||
]
|
||||
304
legacy_reference/workflows/actions/common/actions.py
Normal file
304
legacy_reference/workflows/actions/common/actions.py
Normal file
@@ -0,0 +1,304 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
|
||||
from typing import Any
|
||||
from agente_contas_tim.constants.ic_tags_enum import SADTag, TMDTag
|
||||
from agente_contas_tim.integrations.rct_policy import RCTOperation, rct_tags_for_attempt
|
||||
from agente_contas_tim.workflows.actions.registry import (
|
||||
WorkflowRuntimeContext,
|
||||
workflow_action,
|
||||
)
|
||||
from agente_contas_tim.workflows.runtime_types import ActionResult
|
||||
from agente_contas_tim.workflows.actions.common.helpers import (
|
||||
_emit_ic,
|
||||
_normalize_bool,
|
||||
_result_failed_or_missing_data,
|
||||
_runtime_llm_callbacks,
|
||||
_runtime_llm_metadata,
|
||||
_to_dict,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
|
||||
|
||||
|
||||
@workflow_action("finalizar_atendimento_action")
|
||||
def finalizar_atendimento_action(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
if not isinstance(input_state, dict):
|
||||
input_state = {}
|
||||
_emit_ic(SADTag.FINALIZACAO, input_state)
|
||||
status = str(params.get("status", "")).strip()
|
||||
summary = str(params.get("summary", "")).strip()
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"success": True,
|
||||
"status": status,
|
||||
"summary": summary,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("formatar_capability_resposta")
|
||||
def formatar_capability_resposta(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
|
||||
source_node = str(params.get("source_node", "resolve"))
|
||||
payload = vars_state.get(source_node, {})
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
msisdn = str(params.get("msisdn", input_state.get("msisdn", "")))
|
||||
servico = str(params.get("servico", input_state.get("servico", "")))
|
||||
nome_plano = str(params.get("nome_plano", input_state.get("nome_plano", "")))
|
||||
tipo = str(params.get("tipo", "")).strip().lower()
|
||||
|
||||
instrucoes = str(payload.get("content", "")).strip()
|
||||
mensagem = ""
|
||||
|
||||
if tipo == "vas_estrategico":
|
||||
mensagem = (
|
||||
f"O serviço {servico} já está incluso no seu plano "
|
||||
f"na linha de final {msisdn[-2:]} e não gera "
|
||||
"cobrança adicional na fatura."
|
||||
)
|
||||
elif tipo == "valor_divergente":
|
||||
mensagem = (
|
||||
f"Identificamos uma alteração no valor do plano "
|
||||
f"na linha de final {msisdn[-2:]}. Vou verificar "
|
||||
"os detalhes para você."
|
||||
)
|
||||
elif tipo == "pro_rata":
|
||||
mensagem = (
|
||||
f"A fatura da linha de final {msisdn[-2:]} possui uma "
|
||||
"cobrança proporcional (pro rata) porque houve mudança "
|
||||
"de plano durante o ciclo de faturamento. Na próxima "
|
||||
"fatura o valor volta ao normal do novo plano."
|
||||
)
|
||||
elif tipo == "termino_desconto":
|
||||
_emit_ic(TMDTag.CHEGADA_FLUXO, input_state)
|
||||
plan_info = f" do plano {nome_plano}" if nome_plano else ""
|
||||
mensagem = (
|
||||
f"O desconto de fidelidade{plan_info} vinculado à "
|
||||
f"linha de final {msisdn[-2:]} chegou ao fim. "
|
||||
"O período promocional contratado expirou e o valor "
|
||||
"do plano volta ao preço original."
|
||||
)
|
||||
_emit_ic(TMDTag.FIM_FLUXO, input_state)
|
||||
|
||||
response: dict[str, Any] = {
|
||||
"success": True,
|
||||
"instrucoes": instrucoes,
|
||||
"mensagem": mensagem,
|
||||
"msisdn": msisdn,
|
||||
}
|
||||
if servico:
|
||||
response["servico"] = servico
|
||||
if nome_plano:
|
||||
response["nome_plano"] = nome_plano
|
||||
return ActionResult.ok(response)
|
||||
|
||||
|
||||
@workflow_action("combinar_divergencia")
|
||||
def combinar_divergencia(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
|
||||
divergence = vars_state.get("consultar_divergencia", {})
|
||||
explanation = vars_state.get("invoice_explanation", {})
|
||||
|
||||
if not isinstance(divergence, dict):
|
||||
divergence = {}
|
||||
if not isinstance(explanation, dict):
|
||||
explanation = {}
|
||||
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
msisdn = str(input_state.get("msisdn", ""))
|
||||
|
||||
resumo = str(
|
||||
divergence.get("resumo")
|
||||
or divergence.get("mensagem")
|
||||
or ""
|
||||
).strip()
|
||||
detalhes = divergence.get("detalhes", {})
|
||||
explicacao = str(explanation.get("mensagem", "")).strip()
|
||||
|
||||
success = bool(resumo or explicacao)
|
||||
mensagem = resumo or explicacao
|
||||
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"success": success,
|
||||
"resumo": resumo,
|
||||
"detalhes": detalhes if isinstance(detalhes, dict) else {},
|
||||
"mensagem": mensagem,
|
||||
"explicacao": explicacao,
|
||||
"msisdn": msisdn,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("consultar_divergencia")
|
||||
def query_divergence(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Consulta resumo de divergência da fatura."""
|
||||
result = runtime.factory.create_divergence(
|
||||
msisdn=str(params["msisdn"]),
|
||||
).execute()
|
||||
if _result_failed_or_missing_data(result, state=state):
|
||||
return ActionResult.fail(
|
||||
result.error or "Falha na consulta de divergencia",
|
||||
**result.metadata,
|
||||
)
|
||||
return ActionResult.ok(_to_dict(result.data), **result.metadata)
|
||||
|
||||
|
||||
@workflow_action("resolve_capability")
|
||||
def resolve_capability_action(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Resolve uma capability via LLM Gateway (sem invocar LLM)."""
|
||||
if runtime.llm_gateway is None:
|
||||
return ActionResult.ok({"content": "", "source": "unavailable"})
|
||||
|
||||
capability_id = str(params.get("capability_id", "")).strip()
|
||||
if not capability_id:
|
||||
return ActionResult.fail(
|
||||
"capability_id obrigatorio para resolve_capability",
|
||||
)
|
||||
|
||||
try:
|
||||
resolved = runtime.llm_gateway.resolve_capability(
|
||||
capability_id=capability_id,
|
||||
)
|
||||
return ActionResult.ok({
|
||||
"capability_id": resolved.capability_id,
|
||||
"prompt_id": resolved.prompt_id,
|
||||
"content": resolved.content,
|
||||
"source": resolved.source,
|
||||
"version": resolved.version,
|
||||
})
|
||||
except Exception as exc:
|
||||
return ActionResult.ok(
|
||||
{"content": "", "source": "fallback", "error": str(exc)},
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("no_op")
|
||||
def no_op(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
return ActionResult.ok({})
|
||||
|
||||
|
||||
@workflow_action("llm_capability")
|
||||
def llm_capability(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
if runtime.llm_gateway is None:
|
||||
return ActionResult.fail("LLM gateway nao configurado")
|
||||
|
||||
capability_id = str(params.get("capability_id", "")).strip()
|
||||
if not capability_id:
|
||||
return ActionResult.fail("capability_id obrigatorio para llm_capability")
|
||||
|
||||
variables = params.get("variables", {})
|
||||
if not isinstance(variables, dict):
|
||||
return ActionResult.fail("variables deve ser um objeto")
|
||||
|
||||
user_text_raw = params.get("user_text")
|
||||
user_text = None if user_text_raw is None else str(user_text_raw)
|
||||
|
||||
result = runtime.llm_gateway.execute(
|
||||
capability_id=capability_id,
|
||||
variables=variables,
|
||||
user_text=user_text,
|
||||
callbacks=_runtime_llm_callbacks(runtime),
|
||||
tags=["workflow_action"],
|
||||
metadata=_runtime_llm_metadata(runtime),
|
||||
)
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"capability_id": result.capability_id,
|
||||
"prompt_id": result.prompt_id,
|
||||
"content": result.content,
|
||||
"source": result.source,
|
||||
"version": result.version,
|
||||
"metadata": dict(result.metadata),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("buscar_fatura")
|
||||
def buscar_fatura(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Busca fatura (PDF) do cliente ou dados interpretados."""
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
if not isinstance(input_state, dict):
|
||||
input_state = {}
|
||||
tentativa = int(params.get("tentativa_anterior") or 0) + 1
|
||||
|
||||
result = runtime.factory.create_bill_pdf(
|
||||
invoice_id=str(params["invoice_id"]),
|
||||
msisdn=str(params["msisdn"]),
|
||||
customer_id=str(params["customer_id"]),
|
||||
output=str(params.get("output", "")),
|
||||
include_danfe=_normalize_bool(params.get("include_danfe", False)),
|
||||
).execute()
|
||||
|
||||
if _result_failed_or_missing_data(result, state=state):
|
||||
for tag in rct_tags_for_attempt(RCTOperation.PDF_FATURA, tentativa, success=False):
|
||||
_emit_ic(tag, input_state)
|
||||
return ActionResult.fail(
|
||||
result.error or "Falha ao buscar fatura",
|
||||
**result.metadata,
|
||||
)
|
||||
|
||||
for tag in rct_tags_for_attempt(RCTOperation.PDF_FATURA, tentativa, success=True):
|
||||
_emit_ic(tag, input_state)
|
||||
|
||||
payload = _to_dict(result.data)
|
||||
if isinstance(payload, dict):
|
||||
file_content = payload.get("file_content")
|
||||
if isinstance(file_content, (bytes, bytearray)):
|
||||
payload["file_content_b64"] = base64.b64encode(
|
||||
bytes(file_content)
|
||||
).decode("ascii")
|
||||
payload["file_content"] = None
|
||||
|
||||
return ActionResult.ok(payload, **result.metadata)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'finalizar_atendimento_action',
|
||||
'formatar_capability_resposta',
|
||||
'combinar_divergencia',
|
||||
'query_divergence',
|
||||
'resolve_capability_action',
|
||||
'no_op',
|
||||
'llm_capability',
|
||||
'buscar_fatura',
|
||||
]
|
||||
574
legacy_reference/workflows/actions/common/helpers.py
Normal file
574
legacy_reference/workflows/actions/common/helpers.py
Normal file
@@ -0,0 +1,574 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import unicodedata as ud
|
||||
|
||||
from dataclasses import (
|
||||
asdict,
|
||||
is_dataclass,
|
||||
)
|
||||
from datetime import (
|
||||
datetime,
|
||||
timezone,
|
||||
)
|
||||
from decimal import (
|
||||
Decimal,
|
||||
InvalidOperation,
|
||||
)
|
||||
from typing import Any
|
||||
from pydantic import BaseModel
|
||||
from agente_contas_tim.integrations import agent_framework_bridge
|
||||
from agente_contas_tim.integrations.noc_events import emit_api_content_mismatch
|
||||
from agente_contas_tim.models.service_info import ServiceInfo
|
||||
from agente_contas_tim.observability import get_session_id
|
||||
|
||||
_IDEMPOTENCY_TTL_SECONDS = int(os.getenv("TIM_IDEMPOTENCY_TTL_SECONDS", "3600"))
|
||||
_IDEMPOTENCY_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _emit_vaa(
|
||||
tag: str,
|
||||
ic_base: dict[str, Any],
|
||||
*,
|
||||
gsm: str = "",
|
||||
extra_metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Emite um IC do fluxo VAS Avulso (VAA.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',
|
||||
]
|
||||
2016
legacy_reference/workflows/actions/contestacao/actions.py
Normal file
2016
legacy_reference/workflows/actions/contestacao/actions.py
Normal file
File diff suppressed because it is too large
Load Diff
1207
legacy_reference/workflows/actions/contestacao/helpers.py
Normal file
1207
legacy_reference/workflows/actions/contestacao/helpers.py
Normal file
File diff suppressed because it is too large
Load Diff
28
legacy_reference/workflows/actions/discovery.py
Normal file
28
legacy_reference/workflows/actions/discovery.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import pkgutil
|
||||
from threading import Lock
|
||||
|
||||
_loaded_packages: set[str] = set()
|
||||
_lock = Lock()
|
||||
|
||||
|
||||
def _load_actions(package_name: str) -> None:
|
||||
package = importlib.import_module(package_name)
|
||||
package_path = getattr(package, "__path__", None)
|
||||
if package_path is None:
|
||||
return
|
||||
|
||||
for module in pkgutil.walk_packages(package_path, package_name + "."):
|
||||
importlib.import_module(module.name)
|
||||
|
||||
|
||||
def ensure_actions_loaded(
|
||||
package_name: str = "agente_contas_tim.workflows.actions",
|
||||
) -> None:
|
||||
with _lock:
|
||||
if package_name in _loaded_packages:
|
||||
return
|
||||
_load_actions(package_name)
|
||||
_loaded_packages.add(package_name)
|
||||
53
legacy_reference/workflows/actions/inputs.py
Normal file
53
legacy_reference/workflows/actions/inputs.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
|
||||
class CancelarVasAvulsoItem(BaseModel):
|
||||
"""Item de entrada do action `cancelamento_vas_avulso_batch`."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
str_strip_whitespace=True,
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
msisdn: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description=(
|
||||
"Numero completo da linha do cliente que possui o "
|
||||
"servico VAS avulso a cancelar."
|
||||
),
|
||||
validation_alias=AliasChoices("msisdn", "Msisdn", "MSISDN"),
|
||||
)
|
||||
|
||||
service: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description=(
|
||||
"Nome do servico VAS avulso a cancelar. "
|
||||
"Ex: 'TIM Fashion Mensal', 'Galinha Pintadinha'."
|
||||
"O nome deve ser fiel ao JSON da fatura SEM regra de vocalização ou modificação"
|
||||
"'Aluguel de filmes 1', 'Aluguel de Filme 3'"
|
||||
),
|
||||
validation_alias=AliasChoices("service", "servico", "name"),
|
||||
)
|
||||
|
||||
value: float = Field(
|
||||
...,
|
||||
description= "Valor do serviço"
|
||||
)
|
||||
|
||||
def parse_cancel_vas_items(raw: Any) -> list[CancelarVasAvulsoItem]:
|
||||
"""Valida cada item via Pydantic, descartando entradas invalidas."""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
parsed: list[CancelarVasAvulsoItem] = []
|
||||
for item in raw:
|
||||
try:
|
||||
parsed.append(CancelarVasAvulsoItem.model_validate(item))
|
||||
except ValidationError:
|
||||
continue
|
||||
return parsed
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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',
|
||||
]
|
||||
726
legacy_reference/workflows/actions/pro_rata/actions.py
Normal file
726
legacy_reference/workflows/actions/pro_rata/actions.py
Normal file
@@ -0,0 +1,726 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from agente_contas_tim.protocol_triplets import resolve_protocol_triplet
|
||||
from agente_contas_tim.text_utils import vocalize_digits
|
||||
from agente_contas_tim.workflows.actions.registry import (
|
||||
WorkflowRuntimeContext,
|
||||
workflow_action,
|
||||
)
|
||||
from agente_contas_tim.workflows.runtime_types import ActionResult
|
||||
from agente_contas_tim.workflows.actions.common.helpers import (
|
||||
_final_msisdn,
|
||||
_first_text_from_params_or_state,
|
||||
_first_value_from_params_or_state,
|
||||
_format_amount,
|
||||
_normalize_number_text,
|
||||
_result_failed_or_missing_data,
|
||||
_runtime_llm_callbacks,
|
||||
_runtime_llm_metadata,
|
||||
_to_dict,
|
||||
_utc_now_iso,
|
||||
)
|
||||
from agente_contas_tim.workflows.actions.invoice_explanation.helpers import _normalizar_mensagem_voz
|
||||
from agente_contas_tim.workflows.actions.pro_rata.helpers import (
|
||||
_build_pro_rata_ajuste_recusado_msg,
|
||||
_build_pro_rata_contestacao_tool_payload,
|
||||
_build_pro_rata_contestation_items,
|
||||
_build_pro_rata_controle_msg,
|
||||
_build_pro_rata_human_validation_text,
|
||||
_build_pro_rata_oferta_ajuste_msg,
|
||||
_build_pro_rata_sem_controle_msg,
|
||||
_decimal_from_any,
|
||||
_extract_pro_rata_contestation_context,
|
||||
_fetch_invoice_payload_pro_rata,
|
||||
_money,
|
||||
_resolve_plano_controle,
|
||||
_resolve_pro_rata_liquid_value,
|
||||
_resolve_pro_rata_period_days,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
|
||||
|
||||
|
||||
@workflow_action("formatar_pro_rata")
|
||||
def formatar_pro_rata(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Reescreve a mensagem crua do pro-rata aplicando vocalizacao para TTS.
|
||||
|
||||
Recebe `mensagem_base` (obrigatorio) e, opcionalmente, `trailer_override`
|
||||
para forcar uma frase final (ex.: caminho de reperguntar). Em caso de
|
||||
falha do LLM, faz fallback para a mensagem crua para nao bloquear o
|
||||
fluxo.
|
||||
"""
|
||||
mensagem_base = str(params.get("mensagem_base", "") or "").strip()
|
||||
if not mensagem_base:
|
||||
return ActionResult.fail(
|
||||
"mensagem_base obrigatoria para formatar_pro_rata"
|
||||
)
|
||||
|
||||
trailer_override = str(params.get("trailer_override", "") or "").strip()
|
||||
|
||||
mensagem = ""
|
||||
if runtime.llm_gateway is not None:
|
||||
try:
|
||||
llm_result = runtime.llm_gateway.execute(
|
||||
capability_id="fluxo_pro_rata_reescrita",
|
||||
variables={
|
||||
"mensagem_base": mensagem_base,
|
||||
"trailer_override": trailer_override,
|
||||
},
|
||||
user_text=mensagem_base,
|
||||
callbacks=_runtime_llm_callbacks(runtime),
|
||||
tags=["workflow_action"],
|
||||
metadata=_runtime_llm_metadata(runtime),
|
||||
)
|
||||
mensagem = str(getattr(llm_result, "content", "") or "").strip()
|
||||
mensagem = _normalizar_mensagem_voz(
|
||||
mensagem,
|
||||
trailer_override=trailer_override,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"formatar_pro_rata: falha ao invocar capability "
|
||||
"fluxo_pro_rata_reescrita"
|
||||
)
|
||||
|
||||
if not mensagem:
|
||||
mensagem = (
|
||||
f"{mensagem_base} {trailer_override}".strip()
|
||||
if trailer_override
|
||||
else mensagem_base
|
||||
)
|
||||
|
||||
return ActionResult.ok({"mensagem": mensagem})
|
||||
|
||||
|
||||
@workflow_action("preparar_pro_rata")
|
||||
def preparar_pro_rata(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Monta mensagem inicial do pro-rata e decide se pausa por Plano Controle."""
|
||||
raw_planos = params.get("planos")
|
||||
planos = [p for p in (raw_planos or []) if isinstance(p, dict)]
|
||||
has_controle = bool(params.get("has_plano_controle", False))
|
||||
|
||||
if len(planos) != 2 or not all(
|
||||
str(p.get("desc", "")).strip() for p in planos
|
||||
):
|
||||
return ActionResult.fail(
|
||||
"pro_rata exige exatamente dois planos com 'desc' preenchido."
|
||||
)
|
||||
|
||||
if not has_controle:
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"await_user_input": False,
|
||||
"mensagem": _build_pro_rata_sem_controle_msg(planos),
|
||||
"mensagem_pos_aceite": "",
|
||||
"mensagem_reperguntar_esclarecimento": "",
|
||||
"mensagem_oferta_ajuste": "",
|
||||
"mensagem_reperguntar_ajuste": "",
|
||||
"mensagem_ajuste_recusado": "",
|
||||
"has_plano_controle": False,
|
||||
}
|
||||
)
|
||||
|
||||
mensagem = _build_pro_rata_controle_msg(planos)
|
||||
mensagem_oferta_ajuste = _build_pro_rata_oferta_ajuste_msg()
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"await_user_input": True,
|
||||
"mensagem": mensagem,
|
||||
"mensagem_pos_aceite": "",
|
||||
"mensagem_reperguntar_esclarecimento": (
|
||||
"Para seguirmos, preciso de uma confirmacao. " + mensagem
|
||||
),
|
||||
"mensagem_oferta_ajuste": mensagem_oferta_ajuste,
|
||||
"mensagem_reperguntar_ajuste": (
|
||||
"Para seguirmos, preciso de uma confirmacao. "
|
||||
+ mensagem_oferta_ajuste
|
||||
),
|
||||
"mensagem_ajuste_recusado": _build_pro_rata_ajuste_recusado_msg(),
|
||||
"has_plano_controle": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("calcular_itens_contestacao_pro_rata")
|
||||
def calcular_itens_contestacao_pro_rata(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Calcula itens/valores a contestar no fluxo de pro rata."""
|
||||
raw_planos = _first_value_from_params_or_state(state, params, "planos")
|
||||
planos = [p for p in (raw_planos or []) if isinstance(p, dict)]
|
||||
if not planos:
|
||||
return ActionResult.fail(
|
||||
"planos obrigatorio para calcular itens de contestacao do pro_rata"
|
||||
)
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
total = Decimal("0")
|
||||
for plano in planos:
|
||||
item_name = str(
|
||||
plano.get("desc")
|
||||
or plano.get("name")
|
||||
or plano.get("item_name")
|
||||
or ""
|
||||
).strip()
|
||||
if not item_name:
|
||||
continue
|
||||
valor = _normalize_number_text(
|
||||
plano.get("value")
|
||||
or plano.get("valor")
|
||||
or plano.get("claimed_amount")
|
||||
or "0"
|
||||
)
|
||||
valor_decimal = Decimal(valor)
|
||||
total += valor_decimal
|
||||
items.append(
|
||||
{
|
||||
"item_name": item_name,
|
||||
"item_type": "PRO_RATA",
|
||||
"claimed_amount": valor,
|
||||
"validated_amount": valor,
|
||||
}
|
||||
)
|
||||
|
||||
if not items:
|
||||
return ActionResult.fail(
|
||||
"Nao foi possivel calcular itens de contestacao a partir dos planos."
|
||||
)
|
||||
|
||||
total_txt = _normalize_number_text(format(total, "f"))
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"items": items,
|
||||
"invoice_amount_open": total_txt,
|
||||
"invoice_amount": total_txt,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("executar_contestacao_plano_controle")
|
||||
def executar_contestacao_plano_controle(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Delega para o workflow/tool contestacao_tool apos calcular os itens."""
|
||||
context, context_error = _extract_pro_rata_contestation_context(state, params)
|
||||
if context is None:
|
||||
return ActionResult.fail(
|
||||
context_error or "Dados invalidos para contestacao de pro_rata"
|
||||
)
|
||||
|
||||
if runtime.workflow_runner is None:
|
||||
return ActionResult.fail(
|
||||
"workflow_runner nao configurado para delegar contestacao_tool"
|
||||
)
|
||||
contestacao_payload = _build_pro_rata_contestacao_tool_payload(
|
||||
state,
|
||||
params,
|
||||
context=context,
|
||||
)
|
||||
|
||||
contestacao_result = runtime.workflow_runner(
|
||||
"contestacao_tool",
|
||||
contestacao_payload,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
if contestacao_result.status != "COMPLETED" or not isinstance(
|
||||
contestacao_result.data, dict
|
||||
):
|
||||
return ActionResult.fail(
|
||||
str(contestacao_result.error or "Falha ao abrir contestacao de pro_rata"),
|
||||
**(
|
||||
contestacao_result.metadata
|
||||
if isinstance(contestacao_result.metadata, dict)
|
||||
else {}
|
||||
),
|
||||
)
|
||||
|
||||
contest_payload = contestacao_result.data
|
||||
contestacao_lines = (
|
||||
contest_payload.get("linhas", [])
|
||||
if isinstance(contest_payload.get("linhas"), list)
|
||||
else []
|
||||
)
|
||||
protocolo_id = ""
|
||||
if contestacao_lines and isinstance(contestacao_lines[0], dict):
|
||||
protocolo_id = str(contestacao_lines[0].get("protocolo_id", "")).strip()
|
||||
if not protocolo_id:
|
||||
protocolo_id = str(
|
||||
contest_payload.get("protocol_number")
|
||||
or contest_payload.get("protocolo_id")
|
||||
or ""
|
||||
).strip()
|
||||
mensagem = str(contest_payload.get("mensagem", "")).strip()
|
||||
if not mensagem:
|
||||
mensagem = (
|
||||
"Contestacao de pro rata registrada com sucesso. "
|
||||
f"Protocolo: {protocolo_id or 'n/a'}."
|
||||
)
|
||||
plano_names = [
|
||||
str(item.get("item_name", "")).strip()
|
||||
for item in context["items"]
|
||||
if isinstance(item, dict) and str(item.get("item_name", "")).strip()
|
||||
]
|
||||
transaction_id = str(
|
||||
contest_payload.get("contestation_id")
|
||||
or contest_payload.get("sr")
|
||||
or protocolo_id
|
||||
or ""
|
||||
).strip()
|
||||
audit = {
|
||||
"plano_cliente": ", ".join(plano_names),
|
||||
"valor_calculado_credito": str(context["invoice_amount"]),
|
||||
"data_hora_contestacao": _utc_now_iso(),
|
||||
"transaction_id_ajuste": transaction_id,
|
||||
"mensagem_confirmacao": mensagem,
|
||||
}
|
||||
raw_devolucao = params.get("devolucao")
|
||||
devolucao = dict(raw_devolucao) if isinstance(raw_devolucao, dict) else {}
|
||||
devolucao.setdefault("items", context["items"])
|
||||
devolucao["invoice_amount_open"] = context["invoice_amount_open"]
|
||||
devolucao["invoice_amount"] = context["invoice_amount"]
|
||||
format_text = str(
|
||||
contest_payload.get("format_text")
|
||||
or (
|
||||
"sms"
|
||||
if bool(
|
||||
contest_payload.get("sms_enviado")
|
||||
or contest_payload.get("sms_sent")
|
||||
)
|
||||
else "conta_futura"
|
||||
)
|
||||
).strip()
|
||||
devolucao["format_text"] = format_text
|
||||
devolucao["resolution_type"] = str(
|
||||
contest_payload.get("resolution_type")
|
||||
or ("new_boleto" if format_text == "sms" else "credit_bill")
|
||||
).strip()
|
||||
devolucao["sms_enviado"] = bool(
|
||||
contest_payload.get("sms_enviado")
|
||||
or contest_payload.get("sms_sent")
|
||||
or format_text == "sms"
|
||||
)
|
||||
devolucao["sms_sent"] = devolucao["sms_enviado"]
|
||||
for key in (
|
||||
"decision_reason",
|
||||
"data_credito_proxima_fatura",
|
||||
"barcode",
|
||||
"items_response",
|
||||
"sr_conta_certa_id",
|
||||
):
|
||||
if key in contest_payload:
|
||||
devolucao[key] = contest_payload[key]
|
||||
if protocolo_id:
|
||||
devolucao["protocolo_id"] = protocolo_id
|
||||
if transaction_id:
|
||||
devolucao["transaction_id_ajuste"] = transaction_id
|
||||
logger.info(
|
||||
"pro_rata.contestacao.audit msisdn_final=%s plano_cliente=%s valor_credito=%s transaction_id=%s data_hora=%s mensagem_confirmacao=%s",
|
||||
_final_msisdn(str(context["msisdn"])),
|
||||
audit["plano_cliente"],
|
||||
audit["valor_calculado_credito"],
|
||||
audit["transaction_id_ajuste"],
|
||||
audit["data_hora_contestacao"],
|
||||
audit["mensagem_confirmacao"],
|
||||
)
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"success": True,
|
||||
"mensagem": mensagem,
|
||||
"protocolo_id": protocolo_id,
|
||||
"contestation_id": str(
|
||||
contest_payload.get("contestation_id")
|
||||
or ""
|
||||
).strip(),
|
||||
"sr": str(
|
||||
contest_payload.get("sr")
|
||||
or protocolo_id
|
||||
).strip(),
|
||||
"items": context["items"],
|
||||
"invoice_amount_open": context["invoice_amount_open"],
|
||||
"invoice_amount": context["invoice_amount"],
|
||||
"devolucao": devolucao,
|
||||
"audit": audit,
|
||||
"response": contest_payload,
|
||||
},
|
||||
**(
|
||||
contestacao_result.metadata
|
||||
if isinstance(contestacao_result.metadata, dict)
|
||||
else {}
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("definir_devolucao_ajuste_pro_rata")
|
||||
def definir_devolucao_ajuste_pro_rata(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Calcula devolucao do ajuste pro-rata para Plano Controle.
|
||||
|
||||
A base do pro-rata e o valor liquido do Plano Controle. A devolucao e
|
||||
alocada nos itens do DANFE vinculados ao mesmo plano.
|
||||
"""
|
||||
raw_planos = _first_value_from_params_or_state(state, params, "planos")
|
||||
planos = [p for p in (raw_planos or []) if isinstance(p, dict)]
|
||||
if len(planos) != 2:
|
||||
return ActionResult.fail(
|
||||
"pro_rata exige exatamente dois planos para calcular devolucao."
|
||||
)
|
||||
|
||||
resolved = _resolve_plano_controle(planos)
|
||||
if resolved is None:
|
||||
return ActionResult.fail(
|
||||
"Nao foi possivel identificar exatamente um Plano Controle."
|
||||
)
|
||||
plano_controle, outro_plano = resolved
|
||||
|
||||
valor_liquido = _resolve_pro_rata_liquid_value(plano_controle)
|
||||
if valor_liquido is None or valor_liquido <= 0:
|
||||
return ActionResult.fail(
|
||||
"Valor liquido do Plano Controle ausente ou invalido."
|
||||
)
|
||||
|
||||
invoice_payload, error = _fetch_invoice_payload_pro_rata(state, params, runtime)
|
||||
if error:
|
||||
return ActionResult.fail(error)
|
||||
|
||||
period_days = _resolve_pro_rata_period_days(
|
||||
outro_plano=outro_plano,
|
||||
invoice_payload=invoice_payload,
|
||||
invoice_period=_first_text_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"invoice_period",
|
||||
"invoicePeriod",
|
||||
"periodo_fatura",
|
||||
"periodoFatura",
|
||||
),
|
||||
invoice_emissao=_first_text_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"invoice_emissao",
|
||||
"invoiceEmissao",
|
||||
"emissao_fatura",
|
||||
"emissaoFatura",
|
||||
),
|
||||
)
|
||||
if period_days is None:
|
||||
return ActionResult.fail(
|
||||
"Periodo da fatura ou dos planos ausente ou invalido para calcular pro-rata."
|
||||
)
|
||||
dias_ciclo, dias_controle = period_days
|
||||
|
||||
valor_liquido = _money(valor_liquido)
|
||||
valor_usado = (valor_liquido / Decimal(dias_ciclo)) * Decimal(dias_controle)
|
||||
valor_devolver = _money(valor_liquido - valor_usado)
|
||||
|
||||
danfe = invoice_payload.get("DANFE-COM")
|
||||
if not isinstance(danfe, dict) or not danfe:
|
||||
return ActionResult.fail("DANFE-COM nao encontrado na fatura recuperada.")
|
||||
|
||||
items, restante = _build_pro_rata_contestation_items(
|
||||
danfe=danfe,
|
||||
plano_controle=plano_controle,
|
||||
valor_devolver=valor_devolver,
|
||||
)
|
||||
if restante > 0:
|
||||
return ActionResult.fail(
|
||||
"Itens do DANFE insuficientes para cobrir devolucao de "
|
||||
f"R$ {_format_amount(restante)}."
|
||||
)
|
||||
|
||||
total_validado = sum(
|
||||
(
|
||||
_decimal_from_any(item.get("validatedAmount")) or Decimal("0")
|
||||
)
|
||||
for item in items
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
logger.info(
|
||||
"definir_devolucao_ajuste_pro_rata: items=%s total_validado=%s",
|
||||
items,
|
||||
_format_amount(_money(total_validado)),
|
||||
)
|
||||
|
||||
total_validado_txt = _normalize_number_text(format(_money(total_validado), "f"))
|
||||
texto_validacao_humana = _build_pro_rata_human_validation_text(
|
||||
plano_controle=plano_controle,
|
||||
valor_liquido=valor_liquido,
|
||||
dias_ciclo=dias_ciclo,
|
||||
dias_controle=dias_controle,
|
||||
valor_usado=valor_usado,
|
||||
valor_devolver=valor_devolver,
|
||||
items=items,
|
||||
)
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"items": items,
|
||||
"invoice_amount_open": total_validado_txt,
|
||||
"invoice_amount": total_validado_txt,
|
||||
"texto_validacao_humana": texto_validacao_humana,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("executar_devolucao_pro_rata")
|
||||
def executar_devolucao_pro_rata(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""A implementar: dispara devolucao do ajuste pro-rata.
|
||||
|
||||
Por enquanto repassa o payload de definir_devolucao para o proximo no.
|
||||
"""
|
||||
devolucao = params.get("devolucao")
|
||||
if not isinstance(devolucao, dict):
|
||||
devolucao = {}
|
||||
items = devolucao.get("items")
|
||||
if not isinstance(items, list):
|
||||
items = []
|
||||
logger.warning(
|
||||
"executar_devolucao_pro_rata: a implementar (mock ativo)"
|
||||
)
|
||||
output = dict(devolucao)
|
||||
output["items"] = items
|
||||
output["mocked"] = True
|
||||
return ActionResult.ok(output)
|
||||
|
||||
|
||||
@workflow_action("orientar_pagamento_pro_rata")
|
||||
def orientar_pagamento_pro_rata(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Orienta o cliente sobre pagamento apos definir o credito."""
|
||||
devolucao = params.get("devolucao")
|
||||
if not isinstance(devolucao, dict):
|
||||
devolucao = {}
|
||||
|
||||
raw_items = devolucao.get("items")
|
||||
items = raw_items if isinstance(raw_items, list) else []
|
||||
valor = Decimal("0")
|
||||
plano_nome = ""
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if not plano_nome:
|
||||
plano_nome = str(
|
||||
item.get("itemName")
|
||||
or item.get("item_name")
|
||||
or ""
|
||||
).strip()
|
||||
item_value = _decimal_from_any(
|
||||
item.get("validatedAmount")
|
||||
if item.get("validatedAmount") is not None
|
||||
else item.get("validated_amount")
|
||||
)
|
||||
if item_value is not None:
|
||||
valor += item_value
|
||||
|
||||
valor_txt = _format_amount(_money(valor))
|
||||
plano_txt = f" {plano_nome}" if plano_nome else ""
|
||||
protocolo_id = str(devolucao.get("protocolo_id", "")).strip()
|
||||
protocolo_txt = (
|
||||
f" Seu numero de protocolo e {vocalize_digits(protocolo_id)}."
|
||||
if protocolo_id
|
||||
else ""
|
||||
)
|
||||
data_credito = str(
|
||||
devolucao.get("data_credito_proxima_fatura", "")
|
||||
).strip()
|
||||
data_credito_txt = (
|
||||
f" na fatura com vencimento em {data_credito}, considerando o seu ciclo de faturamento"
|
||||
if data_credito
|
||||
else " em uma proxima fatura"
|
||||
)
|
||||
sms_codigo_txt = (
|
||||
"com o codigo de barras atualizado"
|
||||
if str(devolucao.get("barcode", "")).strip()
|
||||
else "com as orientacoes para pagamento"
|
||||
)
|
||||
|
||||
mensagem = (
|
||||
(
|
||||
"Realizei a contestacao da fatura considerando o valor "
|
||||
f"proporcional do Plano Controle{plano_txt}. O valor "
|
||||
f"contestado, de R$ {valor_txt}, foi retirado da sua fatura. "
|
||||
f"Enviamos uma mensagem {sms_codigo_txt}, com prazo de 10 dias "
|
||||
f"para pagamento.{protocolo_txt}"
|
||||
)
|
||||
if str(devolucao.get("format_text", "")).strip() == "sms"
|
||||
else (
|
||||
"Realizei a contestacao considerando o valor proporcional do "
|
||||
f"Plano Controle{plano_txt}. O valor contestado, de "
|
||||
f"R$ {valor_txt}, ficou registrado como credito{data_credito_txt}."
|
||||
f"{protocolo_txt}"
|
||||
)
|
||||
).strip()
|
||||
|
||||
return ActionResult.ok({"mensagem": mensagem, "devolucao": devolucao})
|
||||
|
||||
|
||||
@workflow_action("registrar_atendimento_pro_rata")
|
||||
def registrar_atendimento_pro_rata(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Registra protocolo de fechamento do atendimento pro rata."""
|
||||
mensagem_base = str(params.get("mensagem_base", "")).strip()
|
||||
caminho = str(params.get("caminho", "")).strip()
|
||||
devolucao = params.get("devolucao")
|
||||
if not isinstance(devolucao, dict):
|
||||
devolucao = {}
|
||||
msisdn = _first_text_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"msisdn",
|
||||
"Msisdn",
|
||||
"MSISDN",
|
||||
)
|
||||
if not msisdn:
|
||||
return ActionResult.fail("msisdn obrigatorio para registrar_atendimento_pro_rata")
|
||||
|
||||
social_sec_no = re.sub(
|
||||
r"\D",
|
||||
"",
|
||||
_first_text_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"social_sec_no",
|
||||
"socialSecNo",
|
||||
"cpf",
|
||||
"customer_document",
|
||||
"customerDocument",
|
||||
"document",
|
||||
),
|
||||
)
|
||||
caminho_norm = caminho.strip().lower()
|
||||
scenario = (
|
||||
"pro_rata_reclamacao"
|
||||
if caminho_norm == "nao_aceitou"
|
||||
else "pro_rata_mensalidade"
|
||||
)
|
||||
close_triplet = resolve_protocol_triplet(scenario, stage="close")
|
||||
reason1 = close_triplet.reason1 or "Informação"
|
||||
reason2 = close_triplet.reason2 or "Conta"
|
||||
reason3 = close_triplet.reason3 or "Mensalidade"
|
||||
|
||||
request_status = str(params.get("request_status", "Fechado")).strip() or "Fechado"
|
||||
status = str(params.get("status", "CLOSED")).strip() or "CLOSED"
|
||||
ic = str(params.get("ic", "")).strip()
|
||||
message_id = _first_text_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"message_id",
|
||||
"messageId",
|
||||
)
|
||||
|
||||
register_result = runtime.factory.create_protocol_v2(
|
||||
msisdn=msisdn,
|
||||
interaction_protocol="",
|
||||
flag_sms=True,
|
||||
social_sec_no=social_sec_no,
|
||||
source="CHAT",
|
||||
reason1=reason1,
|
||||
reason2=reason2,
|
||||
reason3=reason3,
|
||||
direction_contact="FROM-CLIENT",
|
||||
type="CLIENTE",
|
||||
request_flag=True,
|
||||
request_status=request_status,
|
||||
status=status,
|
||||
service_request_notes="Solicitação via chat",
|
||||
client_id="AIAGENTCR",
|
||||
message_id=message_id,
|
||||
).execute()
|
||||
if _result_failed_or_missing_data(register_result, state=state):
|
||||
return ActionResult.fail(
|
||||
register_result.error or "Falha ao registrar protocolo do pro_rata",
|
||||
**register_result.metadata,
|
||||
)
|
||||
|
||||
payload = _to_dict(register_result.data)
|
||||
response = payload.get("response", {}) if isinstance(payload, dict) else {}
|
||||
protocolo_id = ""
|
||||
if isinstance(response, dict):
|
||||
protocolo_id = str(
|
||||
response.get("interactionProtocol")
|
||||
or response.get("protocolId")
|
||||
or response.get("protocolo_id")
|
||||
or ""
|
||||
).strip()
|
||||
if not protocolo_id:
|
||||
return ActionResult.fail("Protocolo nao retornado para pro_rata")
|
||||
|
||||
if not mensagem_base and caminho_norm == "aceitou":
|
||||
mensagem_base = (
|
||||
f"Sua solicitação foi registrada. "
|
||||
f"Seu número de protocolo é {vocalize_digits(protocolo_id)}."
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"registrar_atendimento_pro_rata caminho=%s msisdn=%s protocolo=%s",
|
||||
caminho_norm or "n/a",
|
||||
_final_msisdn(msisdn),
|
||||
protocolo_id,
|
||||
)
|
||||
result: dict[str, Any] = {
|
||||
"success": True,
|
||||
"mensagem": mensagem_base,
|
||||
"protocolo": protocolo_id,
|
||||
"protocolo_id": protocolo_id,
|
||||
"pro_rata_protocol": protocolo_id,
|
||||
"protocolos_por_linha": [
|
||||
{
|
||||
"msisdn": msisdn,
|
||||
"nome": "pro_rata",
|
||||
"protocolo_id": protocolo_id,
|
||||
}
|
||||
],
|
||||
"tripleta": {
|
||||
"reason1": reason1,
|
||||
"reason2": reason2,
|
||||
"reason3": reason3,
|
||||
},
|
||||
"protocol_closed": True,
|
||||
"devolucao": devolucao,
|
||||
"requires_protocol_in_response": True,
|
||||
"protocols_for_response": [protocolo_id],
|
||||
}
|
||||
if ic:
|
||||
result["ic"] = ic
|
||||
return ActionResult.ok(result)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'formatar_pro_rata',
|
||||
'preparar_pro_rata',
|
||||
'calcular_itens_contestacao_pro_rata',
|
||||
'executar_contestacao_plano_controle',
|
||||
'definir_devolucao_ajuste_pro_rata',
|
||||
'executar_devolucao_pro_rata',
|
||||
'orientar_pagamento_pro_rata',
|
||||
'registrar_atendimento_pro_rata',
|
||||
]
|
||||
793
legacy_reference/workflows/actions/pro_rata/helpers.py
Normal file
793
legacy_reference/workflows/actions/pro_rata/helpers.py
Normal file
@@ -0,0 +1,793 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import unicodedata as ud
|
||||
|
||||
from datetime import date
|
||||
from decimal import (
|
||||
Decimal,
|
||||
InvalidOperation,
|
||||
ROUND_HALF_UP,
|
||||
)
|
||||
from typing import Any
|
||||
from agente_contas_tim.workflows.actions.common.helpers import (
|
||||
_final_msisdn,
|
||||
_first_text_from_params_or_state,
|
||||
_first_value_from_params_or_state,
|
||||
_format_amount,
|
||||
_format_list_pt_br,
|
||||
_normalize_bool,
|
||||
_normalize_number_text,
|
||||
_parse_amount,
|
||||
_result_failed_or_missing_data,
|
||||
)
|
||||
from agente_contas_tim.workflows.actions.contestacao.helpers import _build_contestation_items
|
||||
|
||||
_CENT = Decimal("0.01")
|
||||
|
||||
|
||||
def _format_plano_pro_rata(plano: dict[str, Any]) -> str:
|
||||
desc = str(plano.get("desc", "")).strip()
|
||||
valor_txt = _format_plano_pro_rata_value(plano)
|
||||
final = _final_msisdn(str(plano.get("msisdn", "")))
|
||||
return f"{desc} (valor R$ {valor_txt}, linha final {final})"
|
||||
|
||||
|
||||
def _format_plano_pro_rata_value(plano: dict[str, Any]) -> str:
|
||||
raw_value = (
|
||||
plano.get("valor_final")
|
||||
or plano.get("valor_liquido")
|
||||
or plano.get("value")
|
||||
or plano.get("valor_bruto")
|
||||
)
|
||||
if isinstance(raw_value, bool):
|
||||
valor_parsed = None
|
||||
elif isinstance(raw_value, (int, float, Decimal)):
|
||||
valor_parsed = Decimal(str(raw_value))
|
||||
else:
|
||||
valor_parsed = _parse_amount(str(raw_value or ""))
|
||||
return _format_amount(valor_parsed) or "0,00"
|
||||
|
||||
|
||||
def _format_plano_pro_rata_display_name(
|
||||
plano: dict[str, Any],
|
||||
*,
|
||||
fallback: str,
|
||||
) -> str:
|
||||
desc = str(plano.get("desc", "")).strip()
|
||||
clean = re.sub(r"\s*\([^)]*\)\s*$", "", desc).strip()
|
||||
return clean or fallback
|
||||
|
||||
|
||||
def _format_pro_rata_period_reference(plano: dict[str, Any]) -> str:
|
||||
days = _resolve_days_number(
|
||||
plano.get("days") if plano.get("days") is not None else plano.get("dias")
|
||||
)
|
||||
if days is None:
|
||||
return "referente ao período de uso na fatura"
|
||||
unidade = "dia" if days == 1 else "dias"
|
||||
return f"referente a {days} {unidade} de uso no período"
|
||||
|
||||
|
||||
def _format_planos_pro_rata(planos: list[dict[str, Any]]) -> str:
|
||||
formatted = [
|
||||
_format_plano_pro_rata(p) for p in planos if isinstance(p, dict)
|
||||
]
|
||||
return _format_list_pt_br(formatted)
|
||||
|
||||
|
||||
def _build_pro_rata_controle_msg(planos: list[dict[str, Any]]) -> str:
|
||||
resolved = _resolve_plano_controle(planos)
|
||||
if resolved is None:
|
||||
return (
|
||||
"Houve uma troca de plano e, por isso, na sua fatura "
|
||||
"apareceram duas cobranças.\n\n"
|
||||
"Uma é cobrança proporcional de outro plano, referente ao "
|
||||
"período de uso na fatura.\n\n"
|
||||
"A outra é do plano Controle.\n\n"
|
||||
"No plano Controle funciona assim: sempre que a franquia é "
|
||||
"renovada, o valor do plano é cobrado por completo.\n\n"
|
||||
"Isso acontece porque, a partir da renovação, você já passa a "
|
||||
"ter acesso a todos os benefícios do plano imediatamente, como "
|
||||
"internet, ligações e outras vantagens.\n\n"
|
||||
"Consegui esclarecer sua dúvida?"
|
||||
)
|
||||
|
||||
plano_controle, outro_plano = resolved
|
||||
controle_nome = _format_plano_pro_rata_display_name(
|
||||
plano_controle,
|
||||
fallback="Controle",
|
||||
)
|
||||
outro_nome = _format_plano_pro_rata_display_name(
|
||||
outro_plano,
|
||||
fallback="identificado",
|
||||
)
|
||||
controle_valor = _format_plano_pro_rata_value(plano_controle)
|
||||
outro_valor = _format_plano_pro_rata_value(outro_plano)
|
||||
outro_periodo = _format_pro_rata_period_reference(outro_plano)
|
||||
return (
|
||||
"Houve uma troca de plano e, por isso, na sua fatura apareceram "
|
||||
"duas cobranças.\n\n"
|
||||
f"Uma é cobrança proporcional do plano {outro_nome}, no valor de "
|
||||
f"R$ {outro_valor}, {outro_periodo}.\n\n"
|
||||
f"A outra é do plano {controle_nome}, no valor de R$ "
|
||||
f"{controle_valor}.\n\n"
|
||||
"No plano Controle funciona assim: sempre que a franquia é "
|
||||
"renovada, o valor do plano é cobrado por completo.\n\n"
|
||||
"Isso acontece porque, a partir da renovação, você já passa a ter "
|
||||
"acesso a todos os benefícios do plano imediatamente, como "
|
||||
"internet, ligações e outras vantagens.\n\n"
|
||||
"Consegui esclarecer sua dúvida?"
|
||||
)
|
||||
|
||||
|
||||
def _build_pro_rata_sem_controle_msg(planos: list[dict[str, Any]]) -> str:
|
||||
return (
|
||||
"Identifiquei a cobranca proporcional na sua fatura "
|
||||
f"envolvendo os planos {_format_planos_pro_rata(planos)}. "
|
||||
"Como não envolveu troca para o Plano Controle, segue como "
|
||||
"cobranca proporcional padrao dos dias usados em cada plano."
|
||||
)
|
||||
|
||||
|
||||
def _build_pro_rata_oferta_ajuste_msg() -> str:
|
||||
return (
|
||||
"Para buscarmos a "
|
||||
"melhor solução, posso solicitar o "
|
||||
"ajuste proporcional do plano Controle?"
|
||||
)
|
||||
|
||||
|
||||
def _build_pro_rata_ajuste_recusado_msg() -> str:
|
||||
return (
|
||||
"Entendi. Sem a sua confirmação, a solicitação de ajuste não "
|
||||
"será aberta neste momento."
|
||||
)
|
||||
|
||||
|
||||
def _money(value: Decimal) -> Decimal:
|
||||
return value.quantize(_CENT, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _decimal_from_any(value: Any) -> Decimal | None:
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, Decimal):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return Decimal(str(value))
|
||||
parsed = _parse_amount(str(value or ""))
|
||||
return parsed
|
||||
|
||||
|
||||
def _first_decimal_from_mapping(
|
||||
data: dict[str, Any],
|
||||
*keys: str,
|
||||
) -> Decimal | None:
|
||||
for key in keys:
|
||||
if key not in data:
|
||||
continue
|
||||
value = _decimal_from_any(data.get(key))
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_match_text(value: Any) -> str:
|
||||
text = re.sub(r"\s*\([^)]*\)", "", str(value or "")).strip()
|
||||
text = ud.normalize("NFKD", text)
|
||||
text = "".join(ch for ch in text if not ud.combining(ch))
|
||||
text = text.casefold()
|
||||
text = re.sub(r"[^a-z0-9]+", " ", text)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def _is_same_plan_name(left: Any, right: Any) -> bool:
|
||||
left_key = _normalize_match_text(left)
|
||||
right_key = _normalize_match_text(right)
|
||||
if not left_key or not right_key:
|
||||
return False
|
||||
return left_key == right_key or left_key in right_key or right_key in left_key
|
||||
|
||||
|
||||
def _resolve_plano_controle(
|
||||
planos: list[dict[str, Any]],
|
||||
) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
||||
controles = [
|
||||
plano
|
||||
for plano in planos
|
||||
if isinstance(plano, dict) and bool(plano.get("is_controle"))
|
||||
]
|
||||
if len(controles) != 1:
|
||||
return None
|
||||
controle = controles[0]
|
||||
outro = next((plano for plano in planos if plano is not controle), None)
|
||||
if not isinstance(outro, dict):
|
||||
return None
|
||||
return controle, outro
|
||||
|
||||
|
||||
def _resolve_pro_rata_liquid_value(
|
||||
plano_controle: dict[str, Any],
|
||||
) -> Decimal | None:
|
||||
valor_liquido = _first_decimal_from_mapping(
|
||||
plano_controle,
|
||||
"valor_final",
|
||||
"valorFinal",
|
||||
"valor_liquido",
|
||||
"valorLiquido",
|
||||
"net_value",
|
||||
"netValue",
|
||||
"subtotal",
|
||||
"value_final",
|
||||
)
|
||||
if valor_liquido is None:
|
||||
valor_bruto = _first_decimal_from_mapping(
|
||||
plano_controle,
|
||||
"valor_bruto",
|
||||
"valorBruto",
|
||||
"gross_value",
|
||||
"grossValue",
|
||||
"valor_bruto_plano",
|
||||
"valorBrutoPlano",
|
||||
"preco_unit",
|
||||
)
|
||||
total_descontos = _first_decimal_from_mapping(
|
||||
plano_controle,
|
||||
"total_descontos",
|
||||
"totalDescontos",
|
||||
"discount_total",
|
||||
)
|
||||
if valor_bruto is not None and total_descontos is not None:
|
||||
valor_liquido = valor_bruto + total_descontos
|
||||
return valor_liquido
|
||||
|
||||
|
||||
def _parse_emissao_year(
|
||||
invoice_payload: dict[str, Any],
|
||||
*,
|
||||
invoice_emissao: str = "",
|
||||
) -> int | None:
|
||||
match = re.search(r"\b\d{2}/\d{2}/(?P<year>\d{4})\b", invoice_emissao)
|
||||
if match:
|
||||
return int(match.group("year"))
|
||||
for item in invoice_payload.get("Fatura Resumo", []) or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
emissao = str(item.get("emissao", "") or "").strip()
|
||||
match = re.search(r"\b\d{2}/\d{2}/(?P<year>\d{4})\b", emissao)
|
||||
if match:
|
||||
return int(match.group("year"))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_resumo_period(
|
||||
invoice_payload: dict[str, Any],
|
||||
*,
|
||||
invoice_period: str = "",
|
||||
) -> str:
|
||||
if invoice_period:
|
||||
return invoice_period
|
||||
for item in invoice_payload.get("Fatura Resumo", []) or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
desc = _normalize_match_text(item.get("desc", ""))
|
||||
if desc == "periodo":
|
||||
return str(item.get("period", "") or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_period_range(value: Any, *, emission_year: int) -> tuple[date, date] | None:
|
||||
text = str(value or "").strip()
|
||||
match = re.search(
|
||||
r"(?P<start_day>\d{2})/(?P<start_month>\d{2})\s+a\s+"
|
||||
r"(?P<end_day>\d{2})/(?P<end_month>\d{2})",
|
||||
text,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
start_day = int(match.group("start_day"))
|
||||
start_month = int(match.group("start_month"))
|
||||
end_day = int(match.group("end_day"))
|
||||
end_month = int(match.group("end_month"))
|
||||
start_year = emission_year
|
||||
end_year = emission_year
|
||||
if start_month > end_month:
|
||||
start_year -= 1
|
||||
try:
|
||||
start = date(start_year, start_month, start_day)
|
||||
end = date(end_year, end_month, end_day)
|
||||
except ValueError:
|
||||
return None
|
||||
if start > end:
|
||||
return None
|
||||
return start, end
|
||||
|
||||
|
||||
def _inclusive_days(period: tuple[date, date]) -> int:
|
||||
return (period[1] - period[0]).days + 1
|
||||
|
||||
|
||||
def _resolve_days_number(value: Any) -> int | None:
|
||||
try:
|
||||
days = Decimal(str(value))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
return None
|
||||
if days <= 0:
|
||||
return None
|
||||
return int(days.to_integral_value(rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _resolve_pro_rata_period_days(
|
||||
*,
|
||||
outro_plano: dict[str, Any],
|
||||
invoice_payload: dict[str, Any],
|
||||
invoice_period: str = "",
|
||||
invoice_emissao: str = "",
|
||||
) -> tuple[int, int] | None:
|
||||
emission_year = _parse_emissao_year(
|
||||
invoice_payload,
|
||||
invoice_emissao=invoice_emissao,
|
||||
)
|
||||
resumo_period_text = _extract_resumo_period(
|
||||
invoice_payload,
|
||||
invoice_period=invoice_period,
|
||||
)
|
||||
if emission_year is None or not resumo_period_text:
|
||||
return None
|
||||
|
||||
ciclo_period = _parse_period_range(
|
||||
resumo_period_text,
|
||||
emission_year=emission_year,
|
||||
)
|
||||
if ciclo_period is None:
|
||||
return None
|
||||
dias_ciclo = _inclusive_days(ciclo_period)
|
||||
if dias_ciclo <= 0:
|
||||
return None
|
||||
|
||||
dias_outro = _resolve_days_number(
|
||||
outro_plano.get("days")
|
||||
if outro_plano.get("days") is not None
|
||||
else outro_plano.get("dias")
|
||||
)
|
||||
if dias_outro is None:
|
||||
return None
|
||||
|
||||
if dias_outro >= dias_ciclo:
|
||||
return dias_ciclo, 1
|
||||
dias_controle = dias_ciclo - dias_outro
|
||||
if dias_controle <= 0:
|
||||
dias_controle = 1
|
||||
return dias_ciclo, dias_controle
|
||||
|
||||
|
||||
def _parse_invoice_detail_payload(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, list):
|
||||
return {"_items": value}
|
||||
if not isinstance(value, str):
|
||||
return {}
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
if isinstance(parsed, list):
|
||||
return {"_items": parsed}
|
||||
return {}
|
||||
|
||||
|
||||
def _extract_danfe_from_state_or_params(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
payload = _extract_invoice_payload_from_state_or_params(state, params)
|
||||
danfe = payload.get("DANFE-COM") if isinstance(payload, dict) else None
|
||||
return danfe if isinstance(danfe, dict) else {}
|
||||
|
||||
|
||||
def _extract_invoice_payload_from_state_or_params(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
detail = _first_value_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"invoice_detail",
|
||||
"invoiceDetail",
|
||||
"danfe_detail",
|
||||
"danfeDetail",
|
||||
)
|
||||
return _parse_invoice_detail_payload(detail)
|
||||
|
||||
|
||||
def _fetch_invoice_payload_pro_rata(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
payload = _extract_invoice_payload_from_state_or_params(state, params)
|
||||
if payload.get("DANFE-COM"):
|
||||
return payload, None
|
||||
|
||||
invoice_id = _first_text_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"invoice_id",
|
||||
"invoiceId",
|
||||
"current_invoice_number",
|
||||
"currentInvoiceNumber",
|
||||
)
|
||||
customer_id = _first_text_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"customer_id",
|
||||
"customerId",
|
||||
)
|
||||
msisdn = _first_text_from_params_or_state(state, params, "msisdn")
|
||||
if not invoice_id or not customer_id or not msisdn:
|
||||
return payload, (
|
||||
"Pro-rata exige invoice_id, customer_id e msisdn para buscar "
|
||||
"fatura com DANFE."
|
||||
)
|
||||
|
||||
result = runtime.factory.create_bill_pdf(
|
||||
invoice_id=invoice_id,
|
||||
msisdn=msisdn,
|
||||
customer_id=customer_id,
|
||||
include_danfe=True,
|
||||
).execute()
|
||||
if _result_failed_or_missing_data(result, state=state):
|
||||
return payload, result.error or "Falha ao buscar fatura com DANFE."
|
||||
parsed = result.data.parsed_content or {}
|
||||
if not isinstance(parsed, dict):
|
||||
return payload, "Fatura com DANFE retornou conteudo invalido."
|
||||
return parsed, None
|
||||
|
||||
|
||||
def _fetch_danfe_pro_rata(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
payload, error = _fetch_invoice_payload_pro_rata(state, params, runtime)
|
||||
if error:
|
||||
return {}, error
|
||||
danfe = payload.get("DANFE-COM")
|
||||
if not isinstance(danfe, dict) or not danfe:
|
||||
return {}, "DANFE-COM nao encontrado na fatura recuperada."
|
||||
return danfe, None
|
||||
|
||||
|
||||
def _find_danfe_plan_items(
|
||||
danfe: dict[str, Any],
|
||||
plano_controle: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
planos = danfe.get("Planos")
|
||||
if not isinstance(planos, dict):
|
||||
return []
|
||||
controle_desc = str(plano_controle.get("desc", "")).strip()
|
||||
for plan_name, raw_items in planos.items():
|
||||
if not _is_same_plan_name(plan_name, controle_desc):
|
||||
continue
|
||||
if isinstance(raw_items, list):
|
||||
return [item for item in raw_items if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def _build_pro_rata_contestation_items(
|
||||
*,
|
||||
danfe: dict[str, Any],
|
||||
plano_controle: dict[str, Any],
|
||||
valor_devolver: Decimal,
|
||||
) -> tuple[list[dict[str, float | str]], Decimal]:
|
||||
items = _find_danfe_plan_items(danfe, plano_controle)
|
||||
restante = _money(valor_devolver)
|
||||
contestation_items: list[dict[str, float | str]] = []
|
||||
|
||||
for item in items:
|
||||
desc = str(item.get("desc", "")).strip()
|
||||
if not desc:
|
||||
continue
|
||||
claimed_amount = _first_decimal_from_mapping(
|
||||
item,
|
||||
"valor_final",
|
||||
"valorFinal",
|
||||
)
|
||||
if claimed_amount is None or claimed_amount <= 0:
|
||||
continue
|
||||
|
||||
claimed_amount = _money(claimed_amount)
|
||||
validated_amount = min(restante, claimed_amount)
|
||||
if validated_amount <= 0:
|
||||
continue
|
||||
|
||||
contestation_items.append(
|
||||
{
|
||||
"itemName": desc,
|
||||
"itemType": "PRO_RATA",
|
||||
"claimedAmount": float(claimed_amount),
|
||||
"validatedAmount": float(validated_amount),
|
||||
}
|
||||
)
|
||||
restante = _money(restante - validated_amount)
|
||||
if restante <= 0:
|
||||
break
|
||||
|
||||
return contestation_items, restante
|
||||
|
||||
|
||||
def _build_pro_rata_human_validation_text(
|
||||
*,
|
||||
plano_controle: dict[str, Any],
|
||||
valor_liquido: Decimal,
|
||||
dias_ciclo: int,
|
||||
dias_controle: int,
|
||||
valor_usado: Decimal,
|
||||
valor_devolver: Decimal,
|
||||
items: list[dict[str, float | str]],
|
||||
) -> str:
|
||||
plano_nome = re.sub(
|
||||
r"\s*\([^)]*\)",
|
||||
"",
|
||||
str(plano_controle.get("desc") or "").strip(),
|
||||
).strip()
|
||||
if not plano_nome:
|
||||
plano_nome = "Plano Controle"
|
||||
|
||||
item_lines = []
|
||||
for index, item in enumerate(items, start=1):
|
||||
item_name = str(item.get("itemName") or "").strip()
|
||||
claimed = _decimal_from_any(item.get("claimedAmount")) or Decimal("0")
|
||||
validated = _decimal_from_any(item.get("validatedAmount")) or Decimal("0")
|
||||
item_lines.append(
|
||||
f"{index}. {item_name}: valor DANFE R$ {_format_amount(_money(claimed))}; "
|
||||
f"valor a abater R$ {_format_amount(_money(validated))}"
|
||||
)
|
||||
|
||||
itens_txt = "; ".join(item_lines) if item_lines else "nenhum item gerado"
|
||||
return (
|
||||
"Validacao humana pro-rata:"
|
||||
f"Plano Controle identificado: {plano_nome}. "
|
||||
f"Base liquida do plano: R$ {_format_amount(valor_liquido)}. "
|
||||
f"Calculo: ciclo de {dias_ciclo} dias, uso considerado de "
|
||||
f"{dias_controle} dias; valor usado R$ {_format_amount(_money(valor_usado))}; "
|
||||
f"valor a devolver R$ {_format_amount(valor_devolver)}. "
|
||||
f"Itens selecionados no DANFE, na ordem de abatimento: {itens_txt}. "
|
||||
)
|
||||
|
||||
|
||||
def _extract_pro_rata_contestation_context(
|
||||
state: dict[str, Any], params: dict[str, Any]
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
devolucao = params.get("devolucao")
|
||||
contestation_params = params
|
||||
if isinstance(devolucao, dict):
|
||||
contestation_params = {**devolucao, **params}
|
||||
if "items" not in params:
|
||||
contestation_params["items"] = devolucao.get("items")
|
||||
|
||||
msisdn = _first_text_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"msisdn",
|
||||
"Msisdn",
|
||||
"MSISDN",
|
||||
)
|
||||
if not msisdn:
|
||||
return None, "msisdn obrigatorio para contestacao de pro_rata"
|
||||
|
||||
social_sec_no = re.sub(
|
||||
r"\D",
|
||||
"",
|
||||
_first_text_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"social_sec_no",
|
||||
"socialSecNo",
|
||||
"cpf",
|
||||
"customer_document",
|
||||
"customerDocument",
|
||||
"document",
|
||||
),
|
||||
)
|
||||
customer_id = _first_text_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"customer_id",
|
||||
"customerId",
|
||||
)
|
||||
if not customer_id:
|
||||
return None, "customer_id obrigatorio para contestacao de pro_rata"
|
||||
|
||||
invoice_number = _first_text_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"current_invoice_number",
|
||||
"currentInvoiceNumber",
|
||||
"invoice_id",
|
||||
"invoiceId",
|
||||
"invoice_number",
|
||||
"invoiceNumber",
|
||||
)
|
||||
if not invoice_number:
|
||||
return None, "invoice_number obrigatorio para contestacao de pro_rata"
|
||||
|
||||
items = _build_contestation_items(state, contestation_params)
|
||||
if not items:
|
||||
return None, "items obrigatorio para contestacao de pro_rata"
|
||||
|
||||
fallback_amount = _normalize_number_text(
|
||||
format(
|
||||
sum(
|
||||
(
|
||||
_decimal_from_any(item.get("validated_amount"))
|
||||
or Decimal("0")
|
||||
)
|
||||
for item in items
|
||||
if isinstance(item, dict)
|
||||
),
|
||||
"f",
|
||||
),
|
||||
default="0",
|
||||
)
|
||||
invoice_amount_open = _normalize_number_text(
|
||||
_first_text_from_params_or_state(
|
||||
state,
|
||||
contestation_params,
|
||||
"invoice_amount_open",
|
||||
"invoiceAmountOpen",
|
||||
),
|
||||
default=fallback_amount,
|
||||
)
|
||||
invoice_amount = _normalize_number_text(
|
||||
_first_text_from_params_or_state(
|
||||
state,
|
||||
contestation_params,
|
||||
"invoice_amount",
|
||||
"invoiceAmount",
|
||||
),
|
||||
default=invoice_amount_open,
|
||||
)
|
||||
context = {
|
||||
"msisdn": msisdn,
|
||||
"social_sec_no": social_sec_no,
|
||||
"customer_id": customer_id,
|
||||
"invoice_number": invoice_number,
|
||||
"items": items,
|
||||
"invoice_amount_open": invoice_amount_open,
|
||||
"invoice_amount": invoice_amount,
|
||||
}
|
||||
return context, None
|
||||
|
||||
|
||||
def _build_pro_rata_contestacao_tool_payload(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
*,
|
||||
context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"msisdn": context["msisdn"],
|
||||
"social_sec_no": context["social_sec_no"],
|
||||
"customer_id": context["customer_id"],
|
||||
"current_invoice_number": context["invoice_number"],
|
||||
"invoice_id": context["invoice_number"],
|
||||
"customer_type": (
|
||||
_first_text_from_params_or_state(
|
||||
state, params, "customer_type", "customerType"
|
||||
)
|
||||
or "2"
|
||||
),
|
||||
"customer_status": (
|
||||
_first_text_from_params_or_state(
|
||||
state, params, "customer_status", "customerStatus"
|
||||
)
|
||||
or "1"
|
||||
),
|
||||
"invoice_status": (
|
||||
_first_text_from_params_or_state(
|
||||
state, params, "invoice_status", "invoiceStatus"
|
||||
)
|
||||
or "1"
|
||||
),
|
||||
"invoice_amount_open": context["invoice_amount_open"],
|
||||
"invoice_amount": context["invoice_amount"],
|
||||
"current_invoice_due_date": _first_text_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"current_invoice_due_date",
|
||||
"currentInvoiceDueDate",
|
||||
),
|
||||
"contestation_type": (
|
||||
_first_text_from_params_or_state(
|
||||
state, params, "contestation_type", "contestationType"
|
||||
)
|
||||
or "0"
|
||||
),
|
||||
"adjust_reason": (
|
||||
_first_text_from_params_or_state(
|
||||
state, params, "adjust_reason", "adjustReason"
|
||||
)
|
||||
or "SERVICO_NAO_SOLICITADO"
|
||||
),
|
||||
"observation": _first_text_from_params_or_state(
|
||||
state, params, "observation", "descricao"
|
||||
),
|
||||
"refund_option": (
|
||||
_first_text_from_params_or_state(
|
||||
state, params, "refund_option", "refundOption"
|
||||
)
|
||||
or ""
|
||||
),
|
||||
"manual_conta_certa_indicator": _first_value_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"manual_conta_certa_indicator",
|
||||
"manualContaCertaIndicator",
|
||||
),
|
||||
"double_refund": _normalize_bool(
|
||||
_first_value_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"double_refund",
|
||||
"doubleRefund",
|
||||
),
|
||||
default=False,
|
||||
),
|
||||
"user_id": _first_text_from_params_or_state(
|
||||
state, params, "user_id", "userId"
|
||||
),
|
||||
"message_id": _first_text_from_params_or_state(
|
||||
state, params, "message_id", "messageId"
|
||||
),
|
||||
"client_id": _first_text_from_params_or_state(
|
||||
state, params, "client_id", "clientId"
|
||||
),
|
||||
"tipo_atendimento": "pro_rata",
|
||||
"skip_invoice_item_validation": True,
|
||||
"items": context["items"],
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
'_format_plano_pro_rata',
|
||||
'_format_plano_pro_rata_value',
|
||||
'_format_plano_pro_rata_display_name',
|
||||
'_format_pro_rata_period_reference',
|
||||
'_format_planos_pro_rata',
|
||||
'_build_pro_rata_controle_msg',
|
||||
'_build_pro_rata_sem_controle_msg',
|
||||
'_build_pro_rata_oferta_ajuste_msg',
|
||||
'_build_pro_rata_ajuste_recusado_msg',
|
||||
'_money',
|
||||
'_decimal_from_any',
|
||||
'_first_decimal_from_mapping',
|
||||
'_normalize_match_text',
|
||||
'_is_same_plan_name',
|
||||
'_resolve_plano_controle',
|
||||
'_resolve_pro_rata_liquid_value',
|
||||
'_parse_emissao_year',
|
||||
'_extract_resumo_period',
|
||||
'_parse_period_range',
|
||||
'_inclusive_days',
|
||||
'_resolve_days_number',
|
||||
'_resolve_pro_rata_period_days',
|
||||
'_parse_invoice_detail_payload',
|
||||
'_extract_danfe_from_state_or_params',
|
||||
'_extract_invoice_payload_from_state_or_params',
|
||||
'_fetch_invoice_payload_pro_rata',
|
||||
'_fetch_danfe_pro_rata',
|
||||
'_find_danfe_plan_items',
|
||||
'_build_pro_rata_contestation_items',
|
||||
'_build_pro_rata_human_validation_text',
|
||||
'_extract_pro_rata_contestation_context',
|
||||
'_build_pro_rata_contestacao_tool_payload',
|
||||
]
|
||||
0
legacy_reference/workflows/actions/rag/__init__.py
Normal file
0
legacy_reference/workflows/actions/rag/__init__.py
Normal file
218
legacy_reference/workflows/actions/rag/actions.py
Normal file
218
legacy_reference/workflows/actions/rag/actions.py
Normal file
@@ -0,0 +1,218 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from contextvars import copy_context
|
||||
from typing import Any
|
||||
from agente_contas_tim.workflows.actions.registry import (
|
||||
WorkflowRuntimeContext,
|
||||
workflow_action,
|
||||
)
|
||||
from agente_contas_tim.workflows.runtime_types import ActionResult
|
||||
from agente_contas_tim.workflows.actions.common.helpers import (
|
||||
_result_failed_or_missing_data,
|
||||
_runtime_llm_callbacks,
|
||||
_runtime_llm_metadata,
|
||||
_to_dict,
|
||||
)
|
||||
from agente_contas_tim.workflows.actions.rag.helpers import (
|
||||
_build_rag_answer_item,
|
||||
_extract_rag_documents,
|
||||
_is_selected_rag_document,
|
||||
_normalize_rag_queries,
|
||||
_rag_document_text,
|
||||
_rag_document_title,
|
||||
)
|
||||
|
||||
_RAG_FALLBACK_MESSAGE = (
|
||||
"Não foi possível buscar essa informação no momento. "
|
||||
"Por favor, aguarde na linha para que possamos te ajudar de outra forma."
|
||||
)
|
||||
|
||||
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
|
||||
|
||||
|
||||
@workflow_action("buscar_informacao_rag")
|
||||
def buscar_informacao_rag(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
queries = _normalize_rag_queries(params)
|
||||
if not queries:
|
||||
return ActionResult.fail("Informe uma pergunta para buscar na base.")
|
||||
|
||||
raw_top_k = params.get("top_k")
|
||||
resolved_top_k: int | None = None
|
||||
if raw_top_k is not None and str(raw_top_k).strip():
|
||||
try:
|
||||
resolved_top_k = int(raw_top_k)
|
||||
except (TypeError, ValueError):
|
||||
return ActionResult.fail("top_k invalido: informe um numero inteiro")
|
||||
|
||||
segment = str(params.get("segment", "")).strip()
|
||||
results: list[dict[str, Any]] = []
|
||||
documents: list[dict[str, Any]] = []
|
||||
answer_parts: list[str] = []
|
||||
retrieved_titles: list[str] = []
|
||||
selected_titles: list[str] = []
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
def _execute(query: str) -> Any:
|
||||
return runtime.factory.create_rag_search(
|
||||
query=query,
|
||||
top_k=resolved_top_k,
|
||||
segment=segment,
|
||||
).execute()
|
||||
|
||||
if len(queries) == 1:
|
||||
raw_results = [_execute(queries[0])]
|
||||
else:
|
||||
with ThreadPoolExecutor(
|
||||
max_workers=min(len(queries), 6),
|
||||
thread_name_prefix="rag-search",
|
||||
) as executor:
|
||||
futures = [
|
||||
executor.submit(copy_context().run, _execute, query)
|
||||
for query in queries
|
||||
]
|
||||
raw_results = [future.result() for future in futures]
|
||||
|
||||
for query, result in zip(queries, raw_results):
|
||||
if _result_failed_or_missing_data(result, state=state):
|
||||
return ActionResult.fail(
|
||||
result.error or "Falha na busca RAG",
|
||||
**result.metadata,
|
||||
)
|
||||
|
||||
metadata.update(result.metadata)
|
||||
payload = _to_dict(result.data)
|
||||
query_documents = _extract_rag_documents(payload)
|
||||
query_total = len(query_documents)
|
||||
query_message = (
|
||||
f"Encontrei {query_total} trecho(s) relevante(s) na base."
|
||||
if query_total
|
||||
else "Nao encontrei trechos relevantes para essa busca."
|
||||
)
|
||||
query_answer = _build_rag_answer_item(query, query_documents)
|
||||
results.append(
|
||||
{
|
||||
"query": query,
|
||||
"total": query_total,
|
||||
"documents": query_documents,
|
||||
"message": query_message,
|
||||
"answer": query_answer,
|
||||
}
|
||||
)
|
||||
documents.extend(query_documents)
|
||||
answer_parts.append(query_answer)
|
||||
|
||||
for doc in query_documents:
|
||||
title = _rag_document_title(doc)
|
||||
if not title:
|
||||
continue
|
||||
retrieved_titles.append(title)
|
||||
if _is_selected_rag_document(doc):
|
||||
selected_titles.append(title)
|
||||
|
||||
total = len(documents)
|
||||
message = (
|
||||
f"Encontrei {total} trecho(s) relevante(s) na base."
|
||||
if total
|
||||
else "Nao encontrei trechos relevantes para essa busca."
|
||||
)
|
||||
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"success": True,
|
||||
"query": queries[0],
|
||||
"queries": queries,
|
||||
"total": total,
|
||||
"documents": documents,
|
||||
"results": results,
|
||||
"answer": "\n\n".join(answer_parts),
|
||||
"message": message,
|
||||
"ragRetrievedDocuments": "|".join(retrieved_titles),
|
||||
"ragSelectedDocuments": "|".join(selected_titles),
|
||||
"noMatchRag": total == 0,
|
||||
},
|
||||
**metadata,
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("reescrever_resposta_buscar_informacao")
|
||||
def reescrever_resposta_buscar_informacao(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
raw_queries = params.get("queries")
|
||||
queries: list[str] = []
|
||||
if isinstance(raw_queries, (list, tuple)):
|
||||
for item in raw_queries:
|
||||
text = str(item or "").strip()
|
||||
if text:
|
||||
queries.append(text)
|
||||
elif isinstance(raw_queries, str) and raw_queries.strip():
|
||||
queries.append(raw_queries.strip())
|
||||
|
||||
raw_documents = params.get("documents")
|
||||
documents: list[dict[str, Any]] = []
|
||||
if isinstance(raw_documents, (list, tuple)):
|
||||
for item in raw_documents:
|
||||
if isinstance(item, dict):
|
||||
documents.append(item)
|
||||
|
||||
rag_context_parts: list[str] = []
|
||||
for doc in documents:
|
||||
text = _rag_document_text(doc)
|
||||
if text:
|
||||
rag_context_parts.append(text)
|
||||
rag_context = "\n\n".join(rag_context_parts).strip()
|
||||
if not rag_context:
|
||||
rag_context = str(params.get("answer", "") or "").strip()
|
||||
|
||||
no_match = bool(params.get("noMatchRag", False))
|
||||
|
||||
def _payload(answer: str, *, success: bool = True) -> dict[str, Any]:
|
||||
return {
|
||||
"success": success,
|
||||
"answer": answer,
|
||||
"noMatchRag": no_match,
|
||||
}
|
||||
|
||||
if runtime.llm_gateway is None:
|
||||
return ActionResult.ok(_payload(_RAG_FALLBACK_MESSAGE))
|
||||
|
||||
queries_text = "; ".join(queries) if queries else ""
|
||||
try:
|
||||
llm_result = runtime.llm_gateway.execute(
|
||||
capability_id="fluxo_buscar_informacao_reescrita",
|
||||
variables={
|
||||
"queries": queries_text,
|
||||
"rag_context": rag_context,
|
||||
},
|
||||
user_text=queries_text,
|
||||
callbacks=_runtime_llm_callbacks(runtime),
|
||||
tags=["workflow_action"],
|
||||
metadata=_runtime_llm_metadata(runtime),
|
||||
)
|
||||
answer = str(getattr(llm_result, "content", "") or "").strip()
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"reescrever_resposta_buscar_informacao: falha ao invocar "
|
||||
"capability fluxo_buscar_informacao_reescrita"
|
||||
)
|
||||
return ActionResult.ok(_payload(_RAG_FALLBACK_MESSAGE))
|
||||
|
||||
if not answer:
|
||||
return ActionResult.ok(_payload(_RAG_FALLBACK_MESSAGE))
|
||||
|
||||
return ActionResult.ok(_payload(answer))
|
||||
|
||||
|
||||
__all__ = [
|
||||
'buscar_informacao_rag',
|
||||
'reescrever_resposta_buscar_informacao',
|
||||
]
|
||||
81
legacy_reference/workflows/actions/rag/helpers.py
Normal file
81
legacy_reference/workflows/actions/rag/helpers.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
def _normalize_rag_queries(params: dict[str, Any]) -> list[str]:
|
||||
raw_queries = params.get("queries")
|
||||
queries: list[str] = []
|
||||
if isinstance(raw_queries, (list, tuple)):
|
||||
for item in raw_queries:
|
||||
text = str(item or "").strip()
|
||||
if text:
|
||||
queries.append(text)
|
||||
elif isinstance(raw_queries, str) and raw_queries.strip():
|
||||
queries.append(raw_queries.strip())
|
||||
|
||||
if not queries:
|
||||
query = str(params.get("query", "")).strip()
|
||||
if query:
|
||||
queries.append(query)
|
||||
return queries
|
||||
|
||||
|
||||
def _extract_rag_documents(payload: Any) -> list[dict[str, Any]]:
|
||||
documents: list[dict[str, Any]] = []
|
||||
if not isinstance(payload, dict):
|
||||
return documents
|
||||
raw_documents = payload.get("documents")
|
||||
if isinstance(raw_documents, (list, tuple)):
|
||||
for item in raw_documents:
|
||||
if isinstance(item, dict):
|
||||
documents.append(dict(item))
|
||||
return documents
|
||||
|
||||
|
||||
def _rag_document_title(doc: dict[str, Any]) -> str:
|
||||
return str(
|
||||
doc.get("title_proc")
|
||||
or doc.get("title")
|
||||
or doc.get("chunk_texto")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
|
||||
def _rag_document_text(doc: dict[str, Any]) -> str:
|
||||
return str(
|
||||
doc.get("chunk_texto")
|
||||
or doc.get("text")
|
||||
or doc.get("title_proc")
|
||||
or doc.get("title")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
|
||||
def _is_selected_rag_document(doc: dict[str, Any]) -> bool:
|
||||
try:
|
||||
return float(doc.get("distance", 1.0)) <= 0.6
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _build_rag_answer_item(query: str, documents: list[dict[str, Any]]) -> str:
|
||||
if not documents:
|
||||
return f"{query}: Nao encontrei trechos relevantes para essa busca."
|
||||
snippets = []
|
||||
for doc in documents:
|
||||
text = _rag_document_text(doc)
|
||||
if text:
|
||||
snippets.append(text)
|
||||
if not snippets:
|
||||
return f"{query}: Encontrei trecho(s), mas sem texto disponivel."
|
||||
return f"{query}: {' '.join(snippets)}"
|
||||
|
||||
|
||||
__all__ = [
|
||||
'_normalize_rag_queries',
|
||||
'_extract_rag_documents',
|
||||
'_rag_document_title',
|
||||
'_rag_document_text',
|
||||
'_is_selected_rag_document',
|
||||
'_build_rag_answer_item',
|
||||
]
|
||||
83
legacy_reference/workflows/actions/registry.py
Normal file
83
legacy_reference/workflows/actions/registry.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from threading import Lock
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from agente_contas_tim.factory import CommandFactory
|
||||
from agente_contas_tim.workflows.runtime_types import ActionResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agente_contas_tim.agent.llm_gateway import LLMCapabilityGateway
|
||||
from agente_contas_tim.workflows.runtime_types import WorkflowRunResponse
|
||||
|
||||
RuntimeState = dict[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkflowRuntimeContext:
|
||||
factory: CommandFactory
|
||||
llm_gateway: "LLMCapabilityGateway | None" = None
|
||||
workflow_runner: (
|
||||
Callable[[str, dict[str, Any], str | None, int | None], "WorkflowRunResponse"]
|
||||
| None
|
||||
) = None
|
||||
llm_callbacks: tuple[Any, ...] = ()
|
||||
llm_metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
ActionHandler = Callable[
|
||||
[RuntimeState, dict[str, Any], WorkflowRuntimeContext],
|
||||
ActionResult,
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ActionSpec:
|
||||
name: str
|
||||
handler: ActionHandler
|
||||
source: str
|
||||
|
||||
|
||||
class ActionRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._actions: dict[str, ActionSpec] = {}
|
||||
self._lock = Lock()
|
||||
|
||||
def register(self, name: str, handler: ActionHandler, *, source: str) -> None:
|
||||
with self._lock:
|
||||
existing = self._actions.get(name)
|
||||
if existing is not None:
|
||||
if existing.handler is handler:
|
||||
return
|
||||
raise ValueError(
|
||||
f"Action {name!r} já registrada por {existing.source}; "
|
||||
f"tentativa atual: {source}"
|
||||
)
|
||||
|
||||
self._actions[name] = ActionSpec(
|
||||
name=name,
|
||||
handler=handler,
|
||||
source=source,
|
||||
)
|
||||
|
||||
def get(self, name: str) -> ActionHandler:
|
||||
spec = self._actions.get(name)
|
||||
if spec is None:
|
||||
raise ValueError(f"Action não registrada: {name}")
|
||||
return spec.handler
|
||||
|
||||
def list_names(self) -> list[str]:
|
||||
return sorted(self._actions.keys())
|
||||
|
||||
|
||||
DEFAULT_ACTION_REGISTRY = ActionRegistry()
|
||||
|
||||
|
||||
def workflow_action(name: str):
|
||||
def decorator(fn: ActionHandler) -> ActionHandler:
|
||||
source = f"{fn.__module__}.{fn.__name__}"
|
||||
DEFAULT_ACTION_REGISTRY.register(name, fn, source=source)
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
72
legacy_reference/workflows/actions/tim_actions.py
Normal file
72
legacy_reference/workflows/actions/tim_actions.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Compatibility facade for legacy workflow action imports.
|
||||
|
||||
The action implementations live in workflow-specific packages under
|
||||
``agente_contas_tim.workflows.actions``. This module keeps the previous
|
||||
``tim_actions`` import path stable for tests and any external callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import ModuleType
|
||||
|
||||
from agente_contas_tim.integrations import agent_framework_bridge
|
||||
from agente_contas_tim.workflows.actions.common import (
|
||||
actions as _common_actions,
|
||||
helpers as _common_helpers,
|
||||
)
|
||||
from agente_contas_tim.workflows.actions.contestacao import (
|
||||
actions as _contestacao_actions,
|
||||
helpers as _contestacao_helpers,
|
||||
)
|
||||
from agente_contas_tim.workflows.actions.invoice_explanation import (
|
||||
actions as _invoice_explanation_actions,
|
||||
helpers as _invoice_explanation_helpers,
|
||||
)
|
||||
from agente_contas_tim.workflows.actions.pro_rata import (
|
||||
actions as _pro_rata_actions,
|
||||
helpers as _pro_rata_helpers,
|
||||
)
|
||||
from agente_contas_tim.workflows.actions.rag import (
|
||||
actions as _rag_actions,
|
||||
helpers as _rag_helpers,
|
||||
)
|
||||
from agente_contas_tim.workflows.actions.vas_avulso import (
|
||||
actions as _vas_avulso_actions,
|
||||
helpers as _vas_avulso_helpers,
|
||||
)
|
||||
from agente_contas_tim.workflows.actions.vas_estrategico import (
|
||||
actions as _vas_estrategico_actions,
|
||||
helpers as _vas_estrategico_helpers,
|
||||
)
|
||||
|
||||
_EXPORT_MODULES: tuple[ModuleType, ...] = (
|
||||
_common_helpers,
|
||||
_contestacao_helpers,
|
||||
_invoice_explanation_helpers,
|
||||
_pro_rata_helpers,
|
||||
_rag_helpers,
|
||||
_vas_avulso_helpers,
|
||||
_vas_estrategico_helpers,
|
||||
_common_actions,
|
||||
_contestacao_actions,
|
||||
_invoice_explanation_actions,
|
||||
_pro_rata_actions,
|
||||
_rag_actions,
|
||||
_vas_avulso_actions,
|
||||
_vas_estrategico_actions,
|
||||
)
|
||||
|
||||
__all__ = ["agent_framework_bridge"]
|
||||
|
||||
|
||||
def _export_from(module: ModuleType) -> None:
|
||||
for name in getattr(module, "__all__", ()):
|
||||
globals()[name] = getattr(module, name)
|
||||
__all__.append(name)
|
||||
|
||||
|
||||
for _module in _EXPORT_MODULES:
|
||||
_export_from(_module)
|
||||
|
||||
|
||||
del ModuleType, _EXPORT_MODULES, _export_from, _module
|
||||
851
legacy_reference/workflows/actions/vas_avulso/actions.py
Normal file
851
legacy_reference/workflows/actions/vas_avulso/actions.py
Normal file
@@ -0,0 +1,851 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from agente_contas_tim.constants.ic_tags_enum import VAATag
|
||||
from agente_contas_tim.integrations.rct_policy import RCTOperation
|
||||
from agente_contas_tim.observability import get_session_id
|
||||
from agente_contas_tim.protocol_triplets import resolve_protocol_triplet
|
||||
from agente_contas_tim.workflows.actions.inputs import parse_cancel_vas_items
|
||||
from agente_contas_tim.workflows.actions.registry import (
|
||||
WorkflowRuntimeContext,
|
||||
workflow_action,
|
||||
)
|
||||
from agente_contas_tim.workflows.runtime_types import ActionResult
|
||||
from agente_contas_tim.workflows.actions.common.helpers import (
|
||||
_build_ic_context,
|
||||
_emit_vaa,
|
||||
_find_matching_service,
|
||||
_first_text_from_params_or_state,
|
||||
_format_amount,
|
||||
_idempotency_get,
|
||||
_idempotency_set,
|
||||
_parse_amount,
|
||||
_resolve_service_csp_id,
|
||||
_result_failed_or_missing_data,
|
||||
_service_from_dict,
|
||||
_to_dict,
|
||||
)
|
||||
from agente_contas_tim.workflows.actions.vas_avulso.helpers import (
|
||||
_build_active_services,
|
||||
_empty_cancel_result,
|
||||
_service_can_be_canceled,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
|
||||
|
||||
|
||||
@workflow_action("consulta_vas")
|
||||
def query_vas(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
app_id = str(input_state.get("app_id", ""))
|
||||
service_context = input_state.get("service_context")
|
||||
if isinstance(service_context, dict):
|
||||
context_app_id = str(service_context.get("app_id", "")).strip()
|
||||
if not app_id or context_app_id == app_id:
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"services": [service_context],
|
||||
"service_found": True,
|
||||
"service_status": str(service_context.get("status", "")),
|
||||
"service": service_context,
|
||||
"consulta_metadata": {"source": "service_context"},
|
||||
}
|
||||
)
|
||||
|
||||
result = runtime.factory.create_query_vas(
|
||||
msisdn=str(params["msisdn"])
|
||||
).execute()
|
||||
if _result_failed_or_missing_data(result, state=state):
|
||||
return ActionResult.fail(
|
||||
result.error or "Falha na consulta",
|
||||
**result.metadata,
|
||||
)
|
||||
|
||||
payload = _to_dict(result.data)
|
||||
services = payload.get("services", [])
|
||||
target = next(
|
||||
(service for service in services if str(service.get("app_id", "")) == app_id),
|
||||
None,
|
||||
)
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"services": services,
|
||||
"service_found": target is not None,
|
||||
"service_status": str((target or {}).get("status", "")),
|
||||
"service": target,
|
||||
"consulta_metadata": dict(result.metadata),
|
||||
},
|
||||
**result.metadata,
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("cancelamento_vas_avulso_batch")
|
||||
def cancelamento_vas_avulso_batch(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
csp_id = str(params.get("csp_id", "740")).strip() or "740"
|
||||
channel = "AIAGENTCR"
|
||||
request_status = str(params.get("request_status", "Fechado")).strip() or "Fechado"
|
||||
status = str(params.get("status", "CLOSED")).strip() or "CLOSED"
|
||||
message_id = _first_text_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"message_id",
|
||||
"messageId",
|
||||
)
|
||||
idempotency_key = str(params.get("idempotency_key", "")).strip()
|
||||
social_sec_no = re.sub(
|
||||
r"\D",
|
||||
"",
|
||||
_first_text_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"social_sec_no",
|
||||
"socialSecNo",
|
||||
"customer_document",
|
||||
"customerDocument",
|
||||
),
|
||||
)
|
||||
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
_ic_base: dict[str, Any] = {
|
||||
"sessionId": str(get_session_id() or ""),
|
||||
"gsm": "",
|
||||
"ani": str((input_state.get("ani") if isinstance(input_state, dict) else "") or "").strip(),
|
||||
"uraCallId": str((input_state.get("ura_call_id") if isinstance(input_state, dict) else "") or "").strip(),
|
||||
"agentId": "contas",
|
||||
"channelId": str((input_state.get("channel_id") if isinstance(input_state, dict) else "URA") or "URA").strip(),
|
||||
}
|
||||
|
||||
|
||||
parsed_items = parse_cancel_vas_items(params.get("items"))
|
||||
|
||||
if not parsed_items:
|
||||
return ActionResult.ok(
|
||||
_empty_cancel_result(
|
||||
"", mensagem="Nenhum serviço informado para contestação."
|
||||
)
|
||||
)
|
||||
|
||||
normalized_items: list[tuple[str, str, float]] = [
|
||||
(item.msisdn, item.service, item.value) for item in parsed_items
|
||||
]
|
||||
_emit_vaa(VAATag.INICIO_FLUXO, _ic_base, gsm=normalized_items[0][0])
|
||||
|
||||
msisdns_in_order: list[str] = []
|
||||
seen_msisdns: set[str] = set()
|
||||
for item_msisdn, _, _ in normalized_items:
|
||||
if item_msisdn not in seen_msisdns:
|
||||
seen_msisdns.add(item_msisdn)
|
||||
msisdns_in_order.append(item_msisdn)
|
||||
|
||||
protocol_entries: list[dict[str, str]] = []
|
||||
|
||||
active_by_msisdn: dict[str, dict[str, dict[str, Any]]] = {}
|
||||
query_failures: set[str] = set()
|
||||
for line_msisdn in msisdns_in_order:
|
||||
query_result = runtime.factory.create_query_vas(
|
||||
msisdn=line_msisdn
|
||||
).execute()
|
||||
if _result_failed_or_missing_data(query_result, state=state):
|
||||
query_failures.add(line_msisdn)
|
||||
active_by_msisdn[line_msisdn] = {}
|
||||
continue
|
||||
active_by_msisdn[line_msisdn] = _build_active_services(
|
||||
getattr(query_result.data, "services", [])
|
||||
)
|
||||
|
||||
canceled_items: list[dict[str, Any]] = []
|
||||
errors: list[dict[str, str]] = []
|
||||
not_found_services: list[dict[str, str]] = []
|
||||
contestation_candidates: list[dict[str, Any]] = []
|
||||
technical_error_in_iu = False
|
||||
|
||||
def _to_display_amount(value: Any) -> str:
|
||||
candidates: list[Any] = [value]
|
||||
|
||||
def _collect_from_any(raw: Any) -> list[Any]:
|
||||
collected: list[Any] = []
|
||||
if raw is None:
|
||||
return collected
|
||||
|
||||
if isinstance(raw, (str, int, float, Decimal)):
|
||||
collected.append(raw)
|
||||
return collected
|
||||
|
||||
if isinstance(raw, dict):
|
||||
for key in (
|
||||
"valor",
|
||||
"price",
|
||||
"amount",
|
||||
"cost",
|
||||
"value",
|
||||
"valor_numeric",
|
||||
"valor_float",
|
||||
"price_numeric",
|
||||
"amount_float",
|
||||
):
|
||||
if key in raw:
|
||||
candidate = raw.get(key)
|
||||
if candidate not in (None, ""):
|
||||
collected.append(candidate)
|
||||
details = raw.get("details")
|
||||
if isinstance(details, dict):
|
||||
collected.extend(_collect_from_any(details))
|
||||
extra = raw.get("extra")
|
||||
if isinstance(extra, dict):
|
||||
collected.extend(_collect_from_any(extra))
|
||||
|
||||
if isinstance(raw, (list, tuple, set)):
|
||||
for item in raw:
|
||||
if item not in (None, ""):
|
||||
collected.extend(_collect_from_any(item))
|
||||
|
||||
return collected
|
||||
|
||||
for candidate in _collect_from_any(value):
|
||||
parsed = _parse_amount(candidate)
|
||||
if parsed is None:
|
||||
continue
|
||||
try:
|
||||
return _format_amount(parsed)
|
||||
except Exception:
|
||||
continue
|
||||
return ""
|
||||
|
||||
def _resolve_item_amount(
|
||||
requested: Any,
|
||||
matched_service: dict[str, Any],
|
||||
) -> str:
|
||||
service_context = matched_service.get("service_context")
|
||||
context = service_context if isinstance(service_context, dict) else {}
|
||||
return (
|
||||
_to_display_amount(requested)
|
||||
or _to_display_amount(
|
||||
{
|
||||
"valor": matched_service.get("valor"),
|
||||
"price": matched_service.get("price"),
|
||||
"amount": matched_service.get("amount"),
|
||||
"cost": matched_service.get("cost"),
|
||||
"value": matched_service.get("value"),
|
||||
"amount_numeric": matched_service.get("amount_numeric"),
|
||||
"valor_numeric": matched_service.get("valor_numeric"),
|
||||
"valor_float": matched_service.get("valor_float"),
|
||||
"price_numeric": matched_service.get("price_numeric"),
|
||||
"service_context": context,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
def _parse_amount_from_item(item: dict[str, Any]) -> Decimal | None:
|
||||
for key in ("valor", "value", "amount", "price", "servico_valor"):
|
||||
raw_value = item.get(key)
|
||||
if raw_value is None:
|
||||
continue
|
||||
parsed = _parse_amount(raw_value)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
return None
|
||||
|
||||
def _build_contestation_candidate(
|
||||
*,
|
||||
msisdn: str,
|
||||
service_name: str,
|
||||
amount: Any,
|
||||
app_id: str = "",
|
||||
codigo_boleto: str = "",
|
||||
iu_reason: str = "",
|
||||
) -> dict[str, Any]:
|
||||
resolved_amount = _to_display_amount(amount) or "0"
|
||||
return {
|
||||
"msisdn": msisdn,
|
||||
"servico": str(service_name or "").strip(),
|
||||
"service": str(service_name or "").strip(),
|
||||
"app_id": str(app_id or "").strip(),
|
||||
"valor": resolved_amount,
|
||||
"value": resolved_amount,
|
||||
"amount": resolved_amount,
|
||||
"price": resolved_amount,
|
||||
"codigo_boleto": str(codigo_boleto or "").strip(),
|
||||
"iu_reason": str(iu_reason or "").strip(),
|
||||
}
|
||||
|
||||
for item_msisdn, requested_service, requested_value in normalized_items:
|
||||
if item_msisdn in query_failures:
|
||||
technical_error_in_iu = True
|
||||
errors.append(
|
||||
{
|
||||
"msisdn": item_msisdn,
|
||||
"servico": requested_service,
|
||||
"erro": (
|
||||
"Falha ao consultar a linha de final "
|
||||
f"{item_msisdn[-2:]}."
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
active_services = active_by_msisdn.get(item_msisdn, {})
|
||||
matched_service = _find_matching_service(
|
||||
requested_service, active_services,
|
||||
)
|
||||
if not matched_service:
|
||||
logger.info(
|
||||
"cancelamento_vas_avulso_batch.skip reason=service_not_found msisdn=%s requested_service=%s available_services=%s",
|
||||
item_msisdn,
|
||||
requested_service,
|
||||
list(active_services.keys()),
|
||||
)
|
||||
not_found_services.append(
|
||||
{"msisdn": item_msisdn, "servico": requested_service}
|
||||
)
|
||||
contestation_candidates.append(
|
||||
_build_contestation_candidate(
|
||||
msisdn=item_msisdn,
|
||||
service_name=requested_service,
|
||||
amount=requested_value,
|
||||
iu_reason="service_not_found",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if not _service_can_be_canceled(matched_service):
|
||||
logger.info(
|
||||
"cancelamento_vas_avulso_batch.skip reason=can_cancel_false msisdn=%s requested_service=%s matched_service=%s",
|
||||
item_msisdn,
|
||||
requested_service,
|
||||
str(matched_service.get("service_name", "")).strip(),
|
||||
)
|
||||
errors.append(
|
||||
{
|
||||
"msisdn": item_msisdn,
|
||||
"servico": requested_service,
|
||||
"erro": (
|
||||
"Serviço já se encontra cancelado, não sendo possível "
|
||||
"realizar recancelamento."
|
||||
),
|
||||
}
|
||||
)
|
||||
contestation_candidates.append(
|
||||
_build_contestation_candidate(
|
||||
msisdn=item_msisdn,
|
||||
service_name=str(
|
||||
matched_service.get("service_name", "")
|
||||
).strip()
|
||||
or requested_service,
|
||||
amount=requested_value,
|
||||
app_id=str(matched_service.get("app_id", "")).strip(),
|
||||
codigo_boleto=str(
|
||||
matched_service.get("codigo_boleto", "")
|
||||
).strip(),
|
||||
iu_reason="already_inactive_or_blocked",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
service_name = str(matched_service.get("service_name", ""))
|
||||
app_id = str(matched_service.get("app_id", ""))
|
||||
valor = _resolve_item_amount(requested_value, matched_service)
|
||||
codigo_boleto = str(matched_service.get("codigo_boleto", "")).strip()
|
||||
|
||||
cache_key = ""
|
||||
if idempotency_key and service_name:
|
||||
cache_key = (
|
||||
f"{idempotency_key}:{item_msisdn}:"
|
||||
f"{service_name.lower()}:{app_id}"
|
||||
)
|
||||
cached_item = _idempotency_get(cache_key)
|
||||
if cached_item is not None:
|
||||
merged_item = dict(cached_item)
|
||||
if not str(
|
||||
merged_item.get("valor", "")
|
||||
or merged_item.get("value", "")
|
||||
or merged_item.get("amount", "")
|
||||
).strip():
|
||||
merged_item.update(
|
||||
{
|
||||
"valor": valor,
|
||||
"value": valor,
|
||||
"amount": valor,
|
||||
"price": valor,
|
||||
}
|
||||
)
|
||||
if cache_key:
|
||||
_idempotency_set(cache_key, merged_item)
|
||||
canceled_items.append(merged_item)
|
||||
continue
|
||||
|
||||
resolved_csp_id = _resolve_service_csp_id(
|
||||
matched_service,
|
||||
fallback_csp_id=csp_id,
|
||||
)
|
||||
|
||||
_emit_vaa(VAATag.BLOQUEIO_INICIO, _ic_base, gsm=item_msisdn)
|
||||
block_result = runtime.factory.create_block_vas(
|
||||
msisdn=item_msisdn,
|
||||
app_id=app_id,
|
||||
csp_id=resolved_csp_id,
|
||||
service=_service_from_dict(
|
||||
matched_service.get("service_context")
|
||||
),
|
||||
ic_context={**_ic_base, "gsm": item_msisdn},
|
||||
).execute()
|
||||
if not block_result.success:
|
||||
technical_error_in_iu = True
|
||||
_emit_vaa(VAATag.ITEM_CANCELADO_FAIL, _ic_base, gsm=item_msisdn)
|
||||
errors.append(
|
||||
{
|
||||
"msisdn": item_msisdn,
|
||||
"servico": service_name,
|
||||
"erro": block_result.error or "Falha no bloqueio",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
cancel_result = runtime.factory.create_cancellation_vas(
|
||||
msisdn=item_msisdn,
|
||||
app_id=app_id,
|
||||
csp_id=resolved_csp_id,
|
||||
channel=channel,
|
||||
interaction_protocol="",
|
||||
service=_service_from_dict(
|
||||
matched_service.get("service_context")
|
||||
),
|
||||
).execute()
|
||||
if not cancel_result.success:
|
||||
technical_error_in_iu = True
|
||||
_emit_vaa(VAATag.ITEM_CANCELADO_FAIL, _ic_base, gsm=item_msisdn)
|
||||
errors.append(
|
||||
{
|
||||
"msisdn": item_msisdn,
|
||||
"servico": service_name,
|
||||
"erro": cancel_result.error or "Falha no cancelamento",
|
||||
}
|
||||
)
|
||||
continue
|
||||
_emit_vaa(VAATag.ITEM_CANCELADO_OK, _ic_base, gsm=item_msisdn)
|
||||
cancel_payload = _to_dict(cancel_result.data)
|
||||
cancelamento = (
|
||||
cancel_payload.get("cancelamento", {})
|
||||
if isinstance(cancel_payload, dict)
|
||||
else {}
|
||||
)
|
||||
cancellation_protocol = ""
|
||||
if isinstance(cancelamento, dict):
|
||||
cancellation_protocol = str(
|
||||
cancelamento.get("interactionProtocol")
|
||||
or cancelamento.get("protocolId")
|
||||
or cancelamento.get("protocolo_id")
|
||||
or ""
|
||||
).strip()
|
||||
if not cancellation_protocol:
|
||||
body_payload = cancelamento.get("body", {})
|
||||
if isinstance(body_payload, dict):
|
||||
cancellation_protocol = str(
|
||||
body_payload.get("interactionProtocol")
|
||||
or body_payload.get("protocolId")
|
||||
or body_payload.get("protocolo_id")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
canceled_item: dict[str, Any] = {
|
||||
"msisdn": item_msisdn,
|
||||
"servico": service_name,
|
||||
"app_id": app_id,
|
||||
"valor": valor,
|
||||
"value": valor,
|
||||
"amount": valor,
|
||||
"price": valor,
|
||||
"codigo_boleto": codigo_boleto,
|
||||
}
|
||||
canceled_items.append(canceled_item)
|
||||
contestation_candidates.append(dict(canceled_item))
|
||||
if cache_key:
|
||||
_idempotency_set(cache_key, canceled_item)
|
||||
|
||||
_emit_vaa(VAATag.PROTOCOLO_INICIO, _ic_base, gsm=item_msisdn)
|
||||
close_triplet = resolve_protocol_triplet(
|
||||
"cancelamento_vas_avulso",
|
||||
stage="close",
|
||||
context={"servico": service_name},
|
||||
)
|
||||
protocol_result = runtime.factory.create_protocol_v2(
|
||||
msisdn=item_msisdn,
|
||||
interaction_protocol=cancellation_protocol,
|
||||
flag_sms=True,
|
||||
social_sec_no=social_sec_no,
|
||||
source="CHAT",
|
||||
reason1=close_triplet.reason1 or "Informação",
|
||||
reason2=close_triplet.reason2 or "Conta",
|
||||
reason3=close_triplet.reason3 or "Valor",
|
||||
direction_contact="FROM-CLIENT",
|
||||
type="CLIENTE",
|
||||
request_flag=True,
|
||||
request_status=request_status,
|
||||
status=status,
|
||||
service_request_notes=f"Serviço cancelado: {service_name}",
|
||||
client_id="AIAGENTCR",
|
||||
ic_context={**_ic_base, "gsm": str(item_msisdn)},
|
||||
rct_operation=RCTOperation.REG_ATEND_VAS_AVULSO,
|
||||
message_id=message_id,
|
||||
).execute()
|
||||
if _result_failed_or_missing_data(protocol_result, state=state):
|
||||
errors.append(
|
||||
{
|
||||
"msisdn": item_msisdn,
|
||||
"servico": service_name,
|
||||
"erro": (
|
||||
protocol_result.error
|
||||
or "Falha ao registrar protocolo após cancelamento"
|
||||
),
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
_emit_vaa(VAATag.PROTOCOLO_OK, _ic_base, gsm=item_msisdn)
|
||||
protocol_payload = _to_dict(protocol_result.data)
|
||||
protocol_response = (
|
||||
protocol_payload.get("response", {})
|
||||
if isinstance(protocol_payload, dict)
|
||||
else {}
|
||||
)
|
||||
if not isinstance(protocol_response, dict):
|
||||
protocol_response = {}
|
||||
protocolo_id = str(
|
||||
cancellation_protocol
|
||||
or protocol_response.get("interactionProtocol")
|
||||
or protocol_response.get("protocolId")
|
||||
or protocol_response.get("protocolo_id")
|
||||
or ""
|
||||
).strip()
|
||||
if not protocolo_id:
|
||||
errors.append(
|
||||
{
|
||||
"msisdn": item_msisdn,
|
||||
"servico": service_name,
|
||||
"erro": "Protocolo não retornado no registro de atendimento",
|
||||
}
|
||||
)
|
||||
continue
|
||||
protocol_entries.append(
|
||||
{"msisdn": item_msisdn, "protocolo_id": protocolo_id}
|
||||
)
|
||||
|
||||
summary_parts: list[str] = []
|
||||
if canceled_items:
|
||||
by_line: dict[str, list[str]] = {}
|
||||
for item in canceled_items:
|
||||
by_line.setdefault(
|
||||
str(item.get("msisdn", "")), []
|
||||
).append(str(item.get("servico", "")))
|
||||
for line, names in by_line.items():
|
||||
summary_parts.append(
|
||||
"Cancelados com sucesso na linha de final "
|
||||
f"{line[-2:]}: {', '.join(names)}."
|
||||
)
|
||||
|
||||
for error in errors:
|
||||
summary_parts.append(
|
||||
f"Erro ao cancelar {error['servico']} "
|
||||
f"(linha {error['msisdn'][-2:]}): {error['erro']}."
|
||||
)
|
||||
|
||||
if not_found_services:
|
||||
joined = ", ".join(
|
||||
f"{nf['servico']} (linha {nf['msisdn'][-2:]})"
|
||||
for nf in not_found_services
|
||||
)
|
||||
summary_parts.append(f"Não encontrados: {joined}.")
|
||||
|
||||
total_valor = Decimal("0")
|
||||
total_tem_valor = False
|
||||
cancelados_descritivo: list[str] = []
|
||||
for item in canceled_items:
|
||||
servico = str(item.get("servico", "")).strip()
|
||||
valor = str(item.get("valor", "")).strip()
|
||||
parsed = _parse_amount_from_item(item)
|
||||
if parsed is not None:
|
||||
total_valor += parsed
|
||||
total_tem_valor = True
|
||||
if servico:
|
||||
valor_txt = f" (valor: R$ {valor})" if valor else ""
|
||||
cancelados_descritivo.append(f"{servico}{valor_txt}")
|
||||
|
||||
total_valor_txt = (
|
||||
f"R$ {_format_amount(total_valor)}"
|
||||
if total_tem_valor
|
||||
else "R$ 0,00"
|
||||
)
|
||||
|
||||
if technical_error_in_iu:
|
||||
contestation_candidates = []
|
||||
|
||||
primary_msisdn = msisdns_in_order[0] if msisdns_in_order else ""
|
||||
resumo_agente = (
|
||||
"Cancelamento VAS avulso concluido. "
|
||||
f"Telefone: {primary_msisdn}. "
|
||||
"Servicos cancelados: "
|
||||
f"{', '.join(cancelados_descritivo) if cancelados_descritivo else 'nenhum'}. "
|
||||
f"Total dos servicos cancelados: {total_valor_txt}."
|
||||
)
|
||||
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"success": len(canceled_items) > 0,
|
||||
"mensagem": " ".join(summary_parts),
|
||||
"resumo_agente": resumo_agente,
|
||||
"msisdn": primary_msisdn,
|
||||
"cancelados": canceled_items,
|
||||
"erros": errors,
|
||||
"nao_encontrados": not_found_services,
|
||||
"itens_para_contestacao": contestation_candidates,
|
||||
"protocolos_por_linha": protocol_entries,
|
||||
"protocol_closed": bool(protocol_entries),
|
||||
"prompt_signals": {
|
||||
"servicos_cancelados": [
|
||||
item.get("servico") for item in canceled_items
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("avaliar_proxima_acao")
|
||||
def evaluate_next_action(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Action exemplo: decide se deve aguardar confirmação do usuário."""
|
||||
services = []
|
||||
previous_query = state.get("vars", {}).get("node1")
|
||||
if isinstance(previous_query, dict):
|
||||
previous_services = previous_query.get("services", [])
|
||||
if isinstance(previous_services, list):
|
||||
services = previous_services
|
||||
|
||||
metadata: dict[str, Any] = {}
|
||||
if not services:
|
||||
result = runtime.factory.create_query_vas(
|
||||
msisdn=str(params["msisdn"])
|
||||
).execute()
|
||||
if _result_failed_or_missing_data(result, state=state):
|
||||
return ActionResult.fail(
|
||||
result.error or "Falha na consulta para avaliação",
|
||||
**result.metadata,
|
||||
)
|
||||
services = _to_dict(result.data).get("services", [])
|
||||
metadata = dict(result.metadata)
|
||||
|
||||
tim_music = next(
|
||||
(
|
||||
s
|
||||
for s in services
|
||||
if str(s.get("service_name", "")).strip().lower() == "tim music"
|
||||
and str(s.get("status", "")).upper() == "ACTIVE"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if tim_music:
|
||||
client_payload = {
|
||||
"type": "question",
|
||||
"text": (
|
||||
"O motivo do aumento da sua fatura é o serviço TIM Music, que está ativo em sua linha. "
|
||||
"Deseja cancelar o TIM Music? Se sim, responda SIM para que eu possa prosseguir com o bloqueio e cancelamento."
|
||||
),
|
||||
"options": ["SIM", "NAO"],
|
||||
"service": {
|
||||
"app_id": tim_music.get("app_id", ""),
|
||||
"service_name": tim_music.get("service_name", ""),
|
||||
"status": tim_music.get("status", ""),
|
||||
},
|
||||
}
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"await_user_input": True,
|
||||
"client_payload": client_payload,
|
||||
"service_context": tim_music,
|
||||
},
|
||||
**metadata,
|
||||
)
|
||||
|
||||
# Caso não haja TIM Music ativo, segue fluxo normal
|
||||
app_id = str(params.get("app_id", state.get("input", {}).get("app_id", "")))
|
||||
target = next(
|
||||
(service for service in services if str(service.get("app_id", "")) == app_id),
|
||||
None,
|
||||
)
|
||||
if target is None:
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"await_user_input": False,
|
||||
"client_payload": {
|
||||
"type": "info",
|
||||
"text": f"Serviço appId={app_id} não localizado para confirmação.",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
status = str(target.get("status", "")).upper()
|
||||
await_user_input = status == "ACTIVE"
|
||||
|
||||
if await_user_input:
|
||||
client_payload = {
|
||||
"type": "question",
|
||||
"text": "Deseja continuar com a operação deste serviço?",
|
||||
"options": ["SIM", "NAO"],
|
||||
"service": {
|
||||
"app_id": target.get("app_id", ""),
|
||||
"service_name": target.get("service_name", ""),
|
||||
"status": target.get("status", ""),
|
||||
},
|
||||
}
|
||||
else:
|
||||
client_payload = {
|
||||
"type": "info",
|
||||
"text": "Serviço não requer confirmação adicional.",
|
||||
"service": {
|
||||
"app_id": target.get("app_id", ""),
|
||||
"service_name": target.get("service_name", ""),
|
||||
"status": target.get("status", ""),
|
||||
},
|
||||
}
|
||||
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"await_user_input": await_user_input,
|
||||
"client_payload": client_payload,
|
||||
"service_context": target,
|
||||
},
|
||||
**metadata,
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("bloquear_vas")
|
||||
def bloquear_vas(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
service_context = state.get("vars", {}).get("node2", {}).get("service_context")
|
||||
msisdn = str(params["msisdn"])
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
result = runtime.factory.create_block_vas(
|
||||
msisdn=msisdn,
|
||||
app_id=str(params["app_id"]),
|
||||
csp_id=str(params.get("csp_id", "740")),
|
||||
service=_service_from_dict(service_context),
|
||||
ic_context=_build_ic_context(input_state, gsm=msisdn),
|
||||
query_metadata=state.get("vars", {})
|
||||
.get("node1", {})
|
||||
.get("consulta_metadata", {}),
|
||||
).execute()
|
||||
if _result_failed_or_missing_data(result, state=state):
|
||||
return ActionResult.fail(
|
||||
result.error or "Falha no bloqueio",
|
||||
**result.metadata,
|
||||
)
|
||||
return ActionResult.ok(_to_dict(result.data), **result.metadata)
|
||||
|
||||
|
||||
@workflow_action("cancelar_vas")
|
||||
def cancelar_vas(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
service_context = state.get("vars", {}).get("node2", {}).get("service_context")
|
||||
result = runtime.factory.create_cancellation_vas(
|
||||
msisdn=str(params["msisdn"]),
|
||||
app_id=str(params["app_id"]),
|
||||
csp_id=str(params.get("csp_id", "740")),
|
||||
interaction_protocol=str(params.get("interaction_protocol", "")),
|
||||
channel="AIAGENTCR",
|
||||
service=_service_from_dict(service_context),
|
||||
query_metadata=state.get("vars", {})
|
||||
.get("node1", {})
|
||||
.get("consulta_metadata", {}),
|
||||
).execute()
|
||||
if _result_failed_or_missing_data(result, state=state):
|
||||
return ActionResult.fail(
|
||||
result.error or "Falha no cancelamento",
|
||||
**result.metadata,
|
||||
)
|
||||
return ActionResult.ok(_to_dict(result.data), **result.metadata)
|
||||
|
||||
|
||||
@workflow_action("bloquear_vas_single")
|
||||
def bloquear_vas_single(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Bloqueia um serviço VAS usando msisdn + app_id dos params."""
|
||||
service_context = (
|
||||
state.get("vars", {})
|
||||
.get("consulta_vas", {})
|
||||
.get("service")
|
||||
)
|
||||
msisdn = str(params["msisdn"])
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
result = runtime.factory.create_block_vas(
|
||||
msisdn=msisdn,
|
||||
app_id=str(params["app_id"]),
|
||||
csp_id=str(params.get("csp_id", "740")),
|
||||
service=_service_from_dict(service_context),
|
||||
ic_context=_build_ic_context(input_state, gsm=msisdn),
|
||||
).execute()
|
||||
if _result_failed_or_missing_data(result, state=state):
|
||||
return ActionResult.fail(
|
||||
result.error or "Falha no bloqueio",
|
||||
**result.metadata,
|
||||
)
|
||||
return ActionResult.ok(_to_dict(result.data), **result.metadata)
|
||||
|
||||
|
||||
@workflow_action("cancelar_vas_single")
|
||||
def cancelar_vas_single(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Cancela um serviço VAS usando msisdn + app_id dos params."""
|
||||
service_context = (
|
||||
state.get("vars", {})
|
||||
.get("consulta_vas", {})
|
||||
.get("service")
|
||||
)
|
||||
result = runtime.factory.create_cancellation_vas(
|
||||
msisdn=str(params["msisdn"]),
|
||||
app_id=str(params["app_id"]),
|
||||
csp_id=str(params.get("csp_id", "740")),
|
||||
channel="AIAGENTCR",
|
||||
interaction_protocol=str(params.get("interaction_protocol", "")),
|
||||
service=_service_from_dict(service_context),
|
||||
).execute()
|
||||
if _result_failed_or_missing_data(result, state=state):
|
||||
return ActionResult.fail(
|
||||
result.error or "Falha no cancelamento",
|
||||
**result.metadata,
|
||||
)
|
||||
return ActionResult.ok(_to_dict(result.data), **result.metadata)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'query_vas',
|
||||
'cancelamento_vas_avulso_batch',
|
||||
'evaluate_next_action',
|
||||
'bloquear_vas',
|
||||
'cancelar_vas',
|
||||
'bloquear_vas_single',
|
||||
'cancelar_vas_single',
|
||||
]
|
||||
78
legacy_reference/workflows/actions/vas_avulso/helpers.py
Normal file
78
legacy_reference/workflows/actions/vas_avulso/helpers.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from agente_contas_tim.workflows.actions.common.helpers import (
|
||||
_extract_amount,
|
||||
_extract_boleto_code,
|
||||
_to_dict,
|
||||
)
|
||||
|
||||
def _empty_cancel_result(
|
||||
msisdn: str,
|
||||
*,
|
||||
mensagem: str,
|
||||
nao_encontrados: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"success": False,
|
||||
"mensagem": mensagem,
|
||||
"resumo_agente": (
|
||||
"Contestacao VAS avulso concluida. "
|
||||
f"Telefone: {msisdn}. "
|
||||
"SMS enviado: nao. "
|
||||
"Servicos cancelados: nenhum. "
|
||||
"Total dos servicos cancelados: R$ 0,00."
|
||||
),
|
||||
"msisdn": msisdn,
|
||||
"cancelados": [],
|
||||
"erros": [],
|
||||
"nao_encontrados": nao_encontrados or [],
|
||||
"prompt_signals": {
|
||||
"sms_enviado": False,
|
||||
"servicos_com_sms": [],
|
||||
"servicos_com_credito_fatura": [],
|
||||
"tipos_fatura_por_servico": {},
|
||||
"boletos_por_servico": {},
|
||||
"data_credito_por_servico": {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_active_services(raw_services: Any) -> dict[str, dict[str, Any]]:
|
||||
active: dict[str, dict[str, Any]] = {}
|
||||
if not isinstance(raw_services, (list, tuple)):
|
||||
return active
|
||||
for raw_service in raw_services:
|
||||
service_dict = _to_dict(raw_service)
|
||||
service_name = str(service_dict.get("service_name", "") or "")
|
||||
app_id = str(service_dict.get("app_id", "") or "")
|
||||
extra = service_dict.get("extra", {}) or {}
|
||||
if service_name and app_id:
|
||||
active[service_name.lower()] = {
|
||||
"service_name": service_name,
|
||||
"app_id": app_id,
|
||||
"valor": _extract_amount(extra),
|
||||
"codigo_boleto": _extract_boleto_code(extra),
|
||||
"service_context": service_dict,
|
||||
}
|
||||
return active
|
||||
|
||||
|
||||
def _service_can_be_canceled(matched_service: dict[str, Any]) -> bool:
|
||||
context = matched_service.get("service_context", {})
|
||||
if not isinstance(context, dict):
|
||||
return False
|
||||
extra = context.get("extra", {})
|
||||
if not isinstance(extra, dict):
|
||||
return False
|
||||
can = extra.get("can", {})
|
||||
if not isinstance(can, dict):
|
||||
return False
|
||||
return can.get("cancel") is True
|
||||
|
||||
|
||||
__all__ = [
|
||||
'_empty_cancel_result',
|
||||
'_build_active_services',
|
||||
'_service_can_be_canceled',
|
||||
]
|
||||
408
legacy_reference/workflows/actions/vas_estrategico/actions.py
Normal file
408
legacy_reference/workflows/actions/vas_estrategico/actions.py
Normal file
@@ -0,0 +1,408 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
from typing import Any
|
||||
from agente_contas_tim.integrations.rct_policy import RCTOperation
|
||||
from agente_contas_tim.observability import get_session_id
|
||||
from agente_contas_tim.protocol_triplets import resolve_protocol_triplet
|
||||
from agente_contas_tim.workflows.actions.registry import (
|
||||
WorkflowRuntimeContext,
|
||||
workflow_action,
|
||||
)
|
||||
from agente_contas_tim.workflows.runtime_types import ActionResult
|
||||
from agente_contas_tim.workflows.actions.common.helpers import (
|
||||
_final_msisdn,
|
||||
_first_text_from_params_or_state,
|
||||
_result_failed_or_missing_data,
|
||||
_runtime_llm_callbacks,
|
||||
_runtime_llm_metadata,
|
||||
_to_dict,
|
||||
)
|
||||
from agente_contas_tim.workflows.actions.vas_estrategico.helpers import (
|
||||
_build_vas_accept_message,
|
||||
_build_vas_bundle_close_message,
|
||||
_build_vas_initial_message,
|
||||
_build_vas_lines_from_items,
|
||||
_emit_veb_005_cancelamento_info,
|
||||
_format_protocolo_suffix_vas,
|
||||
_is_vas_cancelamento_info_path,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions")
|
||||
|
||||
|
||||
@workflow_action("montar_resposta_texto")
|
||||
def montar_resposta_text(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Monta resposta de texto para o cliente (sem SMS)."""
|
||||
dados = params.get("dados", {})
|
||||
texto = str(dados.get("texto_usuario", ""))
|
||||
return ActionResult.ok({
|
||||
"success": True,
|
||||
"mensagem": texto,
|
||||
})
|
||||
|
||||
|
||||
@workflow_action("preparar_vas_estrategico")
|
||||
def preparar_vas_estrategico(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
lines = params.get("linhas", [])
|
||||
if not isinstance(lines, list) or not lines:
|
||||
items = params.get("items", [])
|
||||
if not isinstance(items, list):
|
||||
items = []
|
||||
lines = _build_vas_lines_from_items(
|
||||
[item for item in items if isinstance(item, dict)]
|
||||
)
|
||||
|
||||
normalized_lines = []
|
||||
for line in lines:
|
||||
if not isinstance(line, dict):
|
||||
continue
|
||||
msisdn = str(line.get("msisdn", "")).strip()
|
||||
if not msisdn:
|
||||
continue
|
||||
raw_items = line.get("items", [])
|
||||
line_items = (
|
||||
[item for item in raw_items if isinstance(item, dict)]
|
||||
if isinstance(raw_items, list)
|
||||
else []
|
||||
)
|
||||
if not line_items:
|
||||
line_items = []
|
||||
for bundle_name in line.get("bundle_names", []):
|
||||
name = str(bundle_name).strip()
|
||||
if name:
|
||||
line_items.append(
|
||||
{"type": "bundle", "msisdn": msisdn, "name": name}
|
||||
)
|
||||
for estrategico_name in line.get("estrategico_names", []):
|
||||
name = str(estrategico_name).strip()
|
||||
if name:
|
||||
line_items.append(
|
||||
{"type": "estrategico", "msisdn": msisdn, "name": name}
|
||||
)
|
||||
rebuilt = _build_vas_lines_from_items(line_items)
|
||||
if rebuilt:
|
||||
normalized_lines.extend(rebuilt)
|
||||
|
||||
if not normalized_lines:
|
||||
return ActionResult.fail(
|
||||
"Nenhum item valido para o fluxo de VAS estrategico."
|
||||
)
|
||||
|
||||
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
|
||||
source_node = str(params.get("source_node", "resolve")).strip() or "resolve"
|
||||
payload = vars_state.get(source_node, {})
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
instrucoes = str(payload.get("content", "")).strip()
|
||||
|
||||
has_estrategico_items = any(
|
||||
bool(
|
||||
isinstance(line, dict)
|
||||
and isinstance(line.get("estrategico_names", []), list)
|
||||
and any(str(name).strip() for name in line.get("estrategico_names", []))
|
||||
)
|
||||
for line in normalized_lines
|
||||
)
|
||||
has_bundle_items = any(
|
||||
bool(
|
||||
isinstance(line, dict)
|
||||
and isinstance(line.get("bundle_names", []), list)
|
||||
and any(str(name).strip() for name in line.get("bundle_names", []))
|
||||
)
|
||||
for line in normalized_lines
|
||||
)
|
||||
|
||||
return ActionResult.ok(
|
||||
{
|
||||
"linhas": normalized_lines,
|
||||
"instrucoes": instrucoes,
|
||||
"mensagem": _build_vas_initial_message(normalized_lines),
|
||||
"mensagem_pos_aceite": _build_vas_accept_message(normalized_lines),
|
||||
"mensagem_bundle_fechamento": _build_vas_bundle_close_message(
|
||||
normalized_lines
|
||||
),
|
||||
# Bundle também pausa em "sanei sua dúvida?": o agente não cancela,
|
||||
# só explica que é incluso. A finalização ocorre DEPOIS da resposta
|
||||
# do cliente, não no turno da explicação. (incidente CY0010)
|
||||
"await_user_input": has_estrategico_items or has_bundle_items,
|
||||
"has_estrategico_items": has_estrategico_items,
|
||||
"has_bundle_items": has_bundle_items,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@workflow_action("montar_explicacao_cancelamento_vas_estrategico")
|
||||
def montar_explicacao_cancelamento_vas_estrategico(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
lines = params.get("linhas", [])
|
||||
orientacoes = params.get("orientacoes_cancelamento", [])
|
||||
orientacoes_map: dict[tuple[str, str], str] = {}
|
||||
if isinstance(orientacoes, list):
|
||||
for item in orientacoes:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
msisdn = str(item.get("msisdn", "")).strip()
|
||||
name = str(item.get("name", "")).strip().lower()
|
||||
orientacao = str(item.get("orientacao", "")).strip()
|
||||
if msisdn and name and orientacao:
|
||||
orientacoes_map[(msisdn, name)] = orientacao
|
||||
|
||||
context_lines: list[str] = []
|
||||
fallback_sections: list[str] = []
|
||||
if isinstance(lines, list):
|
||||
for line in lines:
|
||||
if not isinstance(line, dict):
|
||||
continue
|
||||
msisdn = str(line.get("msisdn", "")).strip()
|
||||
if not msisdn:
|
||||
continue
|
||||
final_line = _final_msisdn(msisdn)
|
||||
estrategico_names = [
|
||||
str(name).strip()
|
||||
for name in line.get("estrategico_names", [])
|
||||
if str(name).strip()
|
||||
]
|
||||
for service_name in estrategico_names:
|
||||
orientacao = orientacoes_map.get(
|
||||
(msisdn, service_name.lower()),
|
||||
(
|
||||
"O cancelamento deve ser feito no app ou site oficial "
|
||||
"do parceiro que fornece o serviço."
|
||||
),
|
||||
)
|
||||
raw = str(orientacao).replace("\\n", "\n")
|
||||
parts = [
|
||||
re.sub(r"\s+", " ", p).strip()
|
||||
for p in raw.split("\n---\n")
|
||||
]
|
||||
cleaned_orientation = " || ".join(p for p in parts if p)
|
||||
context_lines.append(
|
||||
f"Serviço: {service_name} | Linha final: {final_line} | "
|
||||
f"Orientação bruta: {cleaned_orientation}"
|
||||
)
|
||||
fallback_sections.append(
|
||||
f"Para cancelar {service_name} na linha final {final_line}: "
|
||||
"acesse o app ou site oficial do parceiro e solicite "
|
||||
"o cancelamento da assinatura."
|
||||
)
|
||||
|
||||
if not context_lines:
|
||||
fallback_sections.append(
|
||||
"Não há serviços estratégicos com procedimento adicional de "
|
||||
"cancelamento para este atendimento."
|
||||
)
|
||||
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
next_subject = str(
|
||||
(input_state.get("next_subject") if isinstance(input_state, dict) else "")
|
||||
or params.get("next_subject", "")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
if runtime.llm_gateway is not None and context_lines:
|
||||
try:
|
||||
cancelamento_context = "\n".join(context_lines)
|
||||
llm_result = runtime.llm_gateway.execute(
|
||||
capability_id="fluxo_vas_estrategico_cancelamento_resumo",
|
||||
variables={
|
||||
"cancelamento_context": cancelamento_context,
|
||||
"next_subject": next_subject,
|
||||
},
|
||||
user_text=cancelamento_context,
|
||||
callbacks=_runtime_llm_callbacks(runtime),
|
||||
tags=["workflow_action"],
|
||||
metadata=_runtime_llm_metadata(runtime),
|
||||
)
|
||||
llm_message = str(llm_result.content or "").strip()
|
||||
if llm_message:
|
||||
return ActionResult.ok({"mensagem": llm_message})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
fallback_msg = "\n".join(fallback_sections[:4])
|
||||
if next_subject:
|
||||
transition = f" Podemos seguir agora com o tratamento de {next_subject}?"
|
||||
if transition.strip().lower() not in fallback_msg.lower():
|
||||
fallback_msg = f"{fallback_msg.rstrip()}{transition}"
|
||||
return ActionResult.ok({"mensagem": fallback_msg})
|
||||
|
||||
|
||||
@workflow_action("registrar_atendimento_vas_estrategico")
|
||||
def registrar_atendimento_vas_estrategico(
|
||||
state: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
runtime: WorkflowRuntimeContext,
|
||||
) -> ActionResult:
|
||||
"""Registra protocolo por linha para atendimento de VAS estratégico."""
|
||||
lines = params.get("linhas", [])
|
||||
if not isinstance(lines, list):
|
||||
lines = []
|
||||
mensagem_base = str(params.get("mensagem_base", "")).strip()
|
||||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||||
social_sec_no = re.sub(
|
||||
r"\D",
|
||||
"",
|
||||
_first_text_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"social_sec_no",
|
||||
"socialSecNo",
|
||||
"cpf",
|
||||
"customer_document",
|
||||
"customerDocument",
|
||||
"document",
|
||||
),
|
||||
)
|
||||
open_triplet = resolve_protocol_triplet("vas_estrategico", stage="open")
|
||||
request_status = str(params.get("request_status", "Fechado")).strip() or "Fechado"
|
||||
status = str(params.get("status", "CLOSED")).strip() or "CLOSED"
|
||||
message_id = _first_text_from_params_or_state(
|
||||
state,
|
||||
params,
|
||||
"message_id",
|
||||
"messageId",
|
||||
)
|
||||
should_emit_veb_005 = _is_vas_cancelamento_info_path(
|
||||
state,
|
||||
mensagem_base=mensagem_base,
|
||||
)
|
||||
protocolos_por_linha: list[dict[str, Any]] = []
|
||||
erros: list[dict[str, Any]] = []
|
||||
_ic_emitted = False
|
||||
_veb_005_emitted = False
|
||||
|
||||
for line in lines:
|
||||
if not isinstance(line, dict):
|
||||
continue
|
||||
msisdn = str(line.get("msisdn", "")).strip()
|
||||
if not msisdn:
|
||||
continue
|
||||
bundle_names = line.get("bundle_names", [])
|
||||
estrategico_names = line.get("estrategico_names", [])
|
||||
if not isinstance(bundle_names, list):
|
||||
bundle_names = []
|
||||
if not isinstance(estrategico_names, list):
|
||||
estrategico_names = []
|
||||
reason3 = open_triplet.reason3 or "Valor"
|
||||
_ic_base: dict[str, Any] = {
|
||||
"sessionId": str(get_session_id() or ""),
|
||||
"gsm": str(msisdn),
|
||||
"ani": str((input_state.get("ani") if isinstance(input_state, dict) else "") or "").strip(),
|
||||
"uraCallId": str((input_state.get("ura_call_id") if isinstance(input_state, dict) else "") or "").strip(),
|
||||
"agentId": "contas",
|
||||
"channelId": str((input_state.get("channel_id") if isinstance(input_state, dict) else "URA") or "URA").strip(),
|
||||
}
|
||||
if should_emit_veb_005 and not _veb_005_emitted:
|
||||
_emit_veb_005_cancelamento_info(
|
||||
input_state=input_state if isinstance(input_state, dict) else {},
|
||||
msisdn=msisdn,
|
||||
mensagem_base=mensagem_base,
|
||||
)
|
||||
_veb_005_emitted = True
|
||||
register_result = runtime.factory.create_protocol_v2(
|
||||
msisdn=msisdn,
|
||||
interaction_protocol="",
|
||||
social_sec_no=social_sec_no,
|
||||
source="CHAT",
|
||||
reason1=open_triplet.reason1 or "Informação",
|
||||
reason2=open_triplet.reason2 or "Conta",
|
||||
reason3=reason3,
|
||||
direction_contact="FROM-CLIENT",
|
||||
type="CLIENTE",
|
||||
request_flag=True,
|
||||
request_status=request_status,
|
||||
status=status,
|
||||
service_request_notes="Solicitação via chat",
|
||||
client_id="AIAGENTCR",
|
||||
ic_context=_ic_base,
|
||||
rct_operation=RCTOperation.REG_ATEND_VAS_ESTRAT,
|
||||
message_id=message_id,
|
||||
).execute()
|
||||
if register_result.success:
|
||||
_ic_emitted = True
|
||||
if _result_failed_or_missing_data(register_result, state=state):
|
||||
erros.append(
|
||||
{
|
||||
"msisdn": msisdn,
|
||||
"stage": "register_protocol",
|
||||
"erro": register_result.error or "Falha ao registrar protocolo",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
payload = _to_dict(register_result.data)
|
||||
response = payload.get("response", {}) if isinstance(payload, dict) else {}
|
||||
protocolo_id = ""
|
||||
if isinstance(response, dict):
|
||||
protocolo_id = str(
|
||||
response.get("interactionProtocol")
|
||||
or response.get("protocolId")
|
||||
or response.get("protocolo_id")
|
||||
or ""
|
||||
).strip()
|
||||
if not protocolo_id:
|
||||
erros.append(
|
||||
{
|
||||
"msisdn": msisdn,
|
||||
"stage": "register_protocol",
|
||||
"erro": "Protocolo não retornado pelo serviço",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
protocolos_por_linha.append(
|
||||
{
|
||||
"msisdn": msisdn,
|
||||
"protocolo_id": protocolo_id,
|
||||
}
|
||||
)
|
||||
|
||||
suffix = _format_protocolo_suffix_vas(protocolos_por_linha)
|
||||
if not mensagem_base:
|
||||
mensagem_final = "Manteremos o serviço."
|
||||
if suffix:
|
||||
mensagem_final = f"{mensagem_final} {suffix}"
|
||||
else:
|
||||
mensagem_final = (
|
||||
f"{mensagem_base} {suffix}".strip() if suffix else mensagem_base
|
||||
)
|
||||
|
||||
protocolos_raw = [
|
||||
str(p.get("protocolo_id", "")).strip()
|
||||
for p in protocolos_por_linha
|
||||
if isinstance(p, dict) and str(p.get("protocolo_id", "")).strip()
|
||||
]
|
||||
output: dict[str, Any] = {
|
||||
"success": len(protocolos_por_linha) > 0,
|
||||
"mensagem": mensagem_final,
|
||||
"protocolos_por_linha": protocolos_por_linha,
|
||||
"erros": erros,
|
||||
"protocol_closed": len(protocolos_por_linha) > 0,
|
||||
"mocked": False,
|
||||
}
|
||||
if protocolos_raw:
|
||||
output["requires_protocol_in_response"] = True
|
||||
output["protocols_for_response"] = protocolos_raw
|
||||
return ActionResult.ok(output)
|
||||
|
||||
|
||||
__all__ = [
|
||||
'montar_resposta_text',
|
||||
'preparar_vas_estrategico',
|
||||
'montar_explicacao_cancelamento_vas_estrategico',
|
||||
'registrar_atendimento_vas_estrategico',
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user