Projeto do Agent Contas ORACLE

This commit is contained in:
2026-08-19 09:35:50 -03:00
commit 950a2bcd33
1366 changed files with 177217 additions and 0 deletions

View File

@@ -0,0 +1,221 @@
services:
mongo:
image: mongo:8.0
restart: always
environment:
MONGO_INITDB_ROOT_USERNAME: mongo
MONGO_INITDB_ROOT_PASSWORD: mongopassword
MONGO_INITDB_DATABASE: agent_memory
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
langfuse-worker:
image: docker.io/langfuse/langfuse-worker:3
restart: always
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
redis:
condition: service_healthy
clickhouse:
condition: service_healthy
environment:
NEXTAUTH_URL: http://localhost:3005
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
SALT: devsalt
ENCRYPTION_KEY: b127cbb367ba27ddf3851750686b88a984acd818c0b8444e9370d11fb75fb7df
NEXTAUTH_SECRET: devsecret
TELEMETRY_ENABLED: "false"
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: "true"
CLICKHOUSE_MIGRATION_URL: clickhouse://clickhouse:9000
CLICKHOUSE_URL: http://clickhouse:8123
CLICKHOUSE_USER: clickhouse
CLICKHOUSE_PASSWORD: clickhouse
CLICKHOUSE_CLUSTER_ENABLED: "false"
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_AUTH: devredis
REDIS_TLS_ENABLED: "false"
LANGFUSE_USE_AZURE_BLOB: "false"
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: langfuse
LANGFUSE_S3_EVENT_UPLOAD_REGION: auto
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: minio
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: miniosecret
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://minio:9000
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true"
LANGFUSE_S3_EVENT_UPLOAD_PREFIX: events/
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: langfuse
LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: minio
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: miniosecret
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: http://minio:9000
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true"
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: media/
LANGFUSE_S3_BATCH_EXPORT_ENABLED: "false"
LANGFUSE_S3_BATCH_EXPORT_BUCKET: langfuse
LANGFUSE_S3_BATCH_EXPORT_PREFIX: exports/
LANGFUSE_S3_BATCH_EXPORT_REGION: auto
LANGFUSE_S3_BATCH_EXPORT_ENDPOINT: http://minio:9000
LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT: http://localhost:9090
LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID: minio
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY: miniosecret
LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE: "true"
EMAIL_FROM_ADDRESS:
SMTP_CONNECTION_URL:
langfuse-web:
image: docker.io/langfuse/langfuse:3
restart: always
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_healthy
redis:
condition: service_healthy
clickhouse:
condition: service_healthy
ports:
- 3005:3000
environment:
NEXTAUTH_URL: http://localhost:3005
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
SALT: devsalt
ENCRYPTION_KEY: b127cbb367ba27ddf3851750686b88a984acd818c0b8444e9370d11fb75fb7df
NEXTAUTH_SECRET: devsecret
TELEMETRY_ENABLED: "false"
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: "true"
CLICKHOUSE_MIGRATION_URL: clickhouse://clickhouse:9000
CLICKHOUSE_URL: http://clickhouse:8123
CLICKHOUSE_USER: clickhouse
CLICKHOUSE_PASSWORD: clickhouse
CLICKHOUSE_CLUSTER_ENABLED: "false"
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_AUTH: devredis
REDIS_TLS_ENABLED: "false"
LANGFUSE_USE_AZURE_BLOB: "false"
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: langfuse
LANGFUSE_S3_EVENT_UPLOAD_REGION: auto
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: minio
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: miniosecret
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://minio:9000
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true"
LANGFUSE_S3_EVENT_UPLOAD_PREFIX: events/
LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: langfuse
LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto
LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: minio
LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: miniosecret
LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: http://minio:9000
LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true"
LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: media/
LANGFUSE_S3_BATCH_EXPORT_ENABLED: "false"
LANGFUSE_S3_BATCH_EXPORT_BUCKET: langfuse
LANGFUSE_S3_BATCH_EXPORT_PREFIX: exports/
LANGFUSE_S3_BATCH_EXPORT_REGION: auto
LANGFUSE_S3_BATCH_EXPORT_ENDPOINT: http://minio:9000
LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT: http://localhost:9090
LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID: minio
LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY: miniosecret
LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE: "true"
LANGFUSE_INIT_ORG_ID:
LANGFUSE_INIT_ORG_NAME:
LANGFUSE_INIT_PROJECT_ID:
LANGFUSE_INIT_PROJECT_NAME:
LANGFUSE_INIT_PROJECT_PUBLIC_KEY:
LANGFUSE_INIT_PROJECT_SECRET_KEY:
LANGFUSE_INIT_USER_EMAIL:
LANGFUSE_INIT_USER_NAME:
LANGFUSE_INIT_USER_PASSWORD:
clickhouse:
image: docker.io/clickhouse/clickhouse-server
restart: always
user: "101:101"
environment:
CLICKHOUSE_DB: default
CLICKHOUSE_USER: clickhouse
CLICKHOUSE_PASSWORD: clickhouse
volumes:
- langfuse_clickhouse_data:/var/lib/clickhouse
- langfuse_clickhouse_logs:/var/log/clickhouse-server
ports:
- 127.0.0.1:8124:8123
- 127.0.0.1:9002:9000
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1
interval: 5s
timeout: 5s
retries: 20
start_period: 5s
minio:
# image: cgr.dev/chainguard/minio
image: minio/minio
restart: always
entrypoint: sh
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
environment:
MINIO_ROOT_USER: minio
MINIO_ROOT_PASSWORD: miniosecret
ports:
- 9090:9000
- 127.0.0.1:9091:9001
volumes:
- langfuse_minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 2s
timeout: 5s
retries: 10
start_period: 5s
redis:
image: docker.io/redis:7
restart: always
command: >
--requirepass devredis
--maxmemory-policy noeviction
ports:
- 127.0.0.1:6379:6379
healthcheck:
test: ["CMD", "redis-cli", "-a", "devredis", "ping"]
interval: 3s
timeout: 10s
retries: 20
postgres:
image: docker.io/postgres:17
restart: always
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 3s
timeout: 3s
retries: 20
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
TZ: UTC
PGTZ: UTC
ports:
- 127.0.0.1:5433:5432
volumes:
- langfuse_postgres_data:/var/lib/postgresql/data
volumes:
mongo_data:
langfuse_postgres_data:
langfuse_clickhouse_data:
langfuse_clickhouse_logs:
langfuse_minio_data:

View File

@@ -0,0 +1,16 @@
# Fonte da verdade para quais guardrails rodam.
# Se este arquivo existir, somente os rails habilitados aqui serão instanciados.
# Se este arquivo não existir, o framework usa o bundle default legado.
input:
- code: MSK
enabled: true
- code: VLOOP
enabled: true
output:
- code: REVPREC
enabled: true
retrieval: []
tool: []

View File

@@ -0,0 +1,28 @@
# Source of truth for the judge stage.
# The simple schema remains valid. In this adapted version, these names use
# calibrated LLM prompts by default:
# - response_quality -> RQLT calibrated judge
# - groundedness -> ALUC calibrated judge
# The model/provider comes from llm_profiles.yaml profile `judge`.
# Calibrated LLM judges fail-closed by default. Set fail_closed: false only if you intentionally want fail-open.
# To force old heuristic behavior, add `type: deterministic` to an entry.
judges:
- name: response_quality
enabled: true
threshold: 0.7
- name: groundedness
enabled: true
threshold: 0.6
# Optional calibrated judges:
# - name: tone
# enabled: true
# threshold: 0.0
# - name: sentiment
# enabled: true
# fail_on_negative: false
# - name: llm_judge
# type: llm
# enabled: true
# profile: judge
# fail_closed: true

View File

@@ -0,0 +1,100 @@
# Optional file. If this file is absent, the backend keeps using .env exactly as before.
# If present, each inference point can override provider/model/params.
# Put this files in the same .env file folder
profiles:
default:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
max_tokens: 2048
# Workflow/routing
supervisor:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
# Lightweight semantic continuity classifier. Choose the smallest/fastest
# model approved for the environment. The framework references this profile
# through ROUTE_STICKINESS_LLM_PROFILE.
route_continuity:
provider: oci_openai
model: openai.gpt-4.1-mini
temperature: 0
max_tokens: 80
timeout_seconds: 5
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
# Safety / evaluation
guardrail:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 600
grl:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
judge:
provider: oci_openai
model: xopenai.gpt-4.1
temperature: 0
max_tokens: 800
# RAG
rag_rewriter:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 300
rag_compressor:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 1200
rag_generation:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.1
max_tokens: 1800
# Memory / operations
summary_memory:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.1
max_tokens: 1200
noc:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
# Agent-specific overrides
billing_agent:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
product_agent:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
backoffice_agent:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2

View File

@@ -0,0 +1,94 @@
mcp_parameter_mapping:
defaults:
use_mock: false
tools:
consultar_fatura:
map:
customer_key: msisdn
contract_key: invoice_id
interaction_key: ura_call_id
session_key: session_id
extract:
parametro_externo:
from: message
type: string
strategy: llm
description: 'Extraia da mensagem do usuário o valor necessário para o parâmetro
parametro_externo. Retorne null quando a informação não estiver presente
no texto.
'
consultar_pagamentos:
map:
customer_key: msisdn
interaction_key: ura_call_id
session_key: session_id
consultar_plano:
map:
customer_key: msisdn
resource_key: asset_id
contract_key: asset_id
session_key: session_id
listar_servicos:
map:
customer_key: msisdn
session_key: session_id
consultar_pedido:
map:
customer_key: customer_id
session_key: session_id
extract:
order_id:
from: message
type: string
strategy: llm
description: Extraia somente o identificador do pedido informado explicitamente
pelo usuário. Retorne null quando não houver identificador de pedido na
mensagem.
consultar_entrega:
map:
session_key: session_id
extract:
order_id:
from: message
type: string
strategy: llm
description: Extraia somente o identificador do pedido informado explicitamente
pelo usuário. Retorne null quando não houver identificador de pedido na
mensagem.
solicitar_troca:
map:
session_key: session_id
defaults:
reason: Solicitação aberta pelo atendimento conversacional.
extract:
order_id:
from: message
type: string
strategy: llm
description: Extraia somente o identificador do pedido informado explicitamente
pelo usuário. Retorne null quando não houver identificador de pedido na
mensagem.
solicitar_devolucao:
map:
session_key: session_id
defaults:
reason: Solicitação aberta pelo atendimento conversacional.
extract:
order_id:
from: message
type: string
strategy: llm
description: Extraia somente o identificador do pedido informado explicitamente
pelo usuário. Retorne null quando não houver identificador de pedido na
mensagem.
consultar_titulo_financeiro:
map:
customer_key: customer_id
contract_key: contract_id
interaction_key: interaction_id
session_key: session_id
consultar_pagamentos_financeiro:
map:
customer_key: customer_id
session_key: session_id

View File

@@ -0,0 +1,67 @@
mcp_parameter_mapping:
defaults:
use_mock: false
tools:
consultar_fatura:
map:
customer_key: msisdn
contract_key: invoice_id
interaction_key: ura_call_id
session_key: session_id
extract:
parametro_externo:
from: message
type: string
strategy: llm
description: >
Extraia da mensagem do usuário o valor necessário para o parâmetro
parametro_externo. Retorne null quando a informação não estiver
presente no texto.
consultar_pagamentos:
map:
customer_key: msisdn
interaction_key: ura_call_id
session_key: session_id
consultar_plano:
map:
customer_key: msisdn
resource_key: asset_id
contract_key: asset_id
session_key: session_id
listar_servicos:
map:
customer_key: msisdn
session_key: session_id
consultar_pedido:
map:
customer_key: customer_id
contract_key: order_id
session_key: session_id
consultar_entrega:
map:
contract_key: order_id
session_key: session_id
solicitar_troca:
map:
contract_key: order_id
session_key: session_id
defaults:
reason: Solicitação aberta pelo atendimento conversacional.
solicitar_devolucao:
map:
contract_key: order_id
session_key: session_id
defaults:
reason: Solicitação aberta pelo atendimento conversacional.
consultar_titulo_financeiro:
map:
customer_key: customer_id
contract_key: contract_id
interaction_key: interaction_id
session_key: session_id
consultar_pagamentos_financeiro:
map:
customer_key: customer_id
session_key: session_id

View File

@@ -0,0 +1,16 @@
# Logical MCP server registry used by the framework for tool ownership.
# With MCP_GATEWAY_ENABLED=true, the framework does NOT call these endpoints directly;
# it calls MCP_GATEWAY_URL and the dedicated gateway routes to the final servers.
# Keep these logical names aligned with config/tools.yaml mcp_server values.
servers:
telecom:
enabled: true
transport: http
endpoint: http://localhost:8100/mcp
description: Logical Telecom MCP server. Direct endpoint is kept only for fallback when MCP_GATEWAY_ENABLED=false.
retail:
enabled: true
transport: http
endpoint: http://localhost:8200/mcp
description: Logical Retail MCP server. Direct endpoint is kept only for fallback when MCP_GATEWAY_ENABLED=false.

View File

@@ -0,0 +1,90 @@
tools:
consultar_fatura:
description: Consulta dados resumidos de fatura por msisdn/invoice_id.
mcp_server: telecom
enabled: true
cache:
enabled: true
ttl_seconds: 600
args_schema:
msisdn: string
invoice_id: string
consultar_pagamentos:
description: Consulta histórico de pagamentos do cliente.
mcp_server: telecom
enabled: true
cache:
enabled: true
ttl_seconds: 300
args_schema:
msisdn: string
consultar_plano:
description: Consulta plano ativo e atributos comerciais.
mcp_server: telecom
enabled: true
cache:
enabled: true
ttl_seconds: 300
args_schema:
msisdn: string
asset_id: string
listar_servicos:
description: Lista serviços ativos e adicionais VAS.
mcp_server: telecom
enabled: true
cache:
enabled: true
ttl_seconds: 300
args_schema:
msisdn: string
consultar_pedido:
description: Consulta pedido de varejo por order_id/customer_id.
mcp_server: retail
enabled: true
cache:
enabled: true
ttl_seconds: 300
args_schema:
order_id: string
customer_id: string
consultar_entrega:
description: Consulta entrega e rastreamento do pedido.
mcp_server: retail
enabled: true
cache:
enabled: true
ttl_seconds: 300
args_schema:
order_id: string
solicitar_troca:
description: Simula abertura de solicitação de troca.
mcp_server: retail
enabled: true
tool_type: action
requires: [order_id, reason]
confirmation_required: true
cache:
enabled: false
args_schema:
order_id: string
reason: string
solicitar_devolucao:
description: Simula abertura de solicitação de devolução.
mcp_server: retail
enabled: true
tool_type: action
requires: [order_id, reason]
confirmation_required: true
cache:
enabled: false
args_schema:
order_id: string
reason: string

View File

@@ -0,0 +1,122 @@
# ConversationSummaryMemory
Este módulo adiciona compressão de contexto conversacional ao framework sem substituir a memória bruta existente.
## Objetivo
O framework passa a trabalhar com dois níveis de memória:
1. **Histórico bruto**: mensagens completas persistidas por `ConversationMemory`.
2. **Resumo incremental**: contexto antigo compactado por `ConversationSummaryMemory`.
O prompt final do agente pode receber:
```text
Resumo da conversa até agora:
{summary}
Últimas mensagens completas da conversa:
{recent_messages}
Mensagem do usuário:
{current_user_message}
```
## 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
```
Estratégias disponíveis:
- `none`: não injeta memória conversacional no prompt.
- `window`: injeta apenas as últimas mensagens.
- `summary`: mantém resumo acumulado das mensagens antigas e últimas mensagens completas.
## Pontos implementados
Arquivos adicionados:
```text
src/agent_framework/memory/summary_memory.py
src/agent_framework/memory/summary_store.py
```
Arquivos alterados:
```text
src/agent_framework/memory/__init__.py
src/agent_framework/config/settings.py
src/agent_framework/runtime/agent_runtime.py
src/agent_framework/persistence/sqlite_store.py
src/agent_framework/persistence/oracle_store.py
```
## Como usar no agente
Antes de chamar `build_messages()`, prepare a memória:
```python
await self.prepare_memory_context(state)
messages = self.build_messages(
state,
system_prompt=system_prompt,
user_text=state.get("sanitized_input"),
)
```
O método `prepare_memory_context()` salva o resultado em:
```python
state["memory_context"]
state["memory_context_metadata"]
```
O método `build_messages()` injeta automaticamente esse contexto quando ele existe.
## Eventos de observabilidade
O runtime emite eventos IC quando a memória é carregada ou comprimida:
```text
IC.MEMORY_CONTEXT_LOADED
IC.MEMORY_COMPRESSION_TRIGGERED
IC.MEMORY_SUMMARY_UPDATED
```
## Persistência
SQLite:
```text
agent_memory_summaries
```
Oracle:
```text
<ADB_TABLE_PREFIX>_MEMORY_SUMMARY
```
MongoDB:
```text
memory_summaries
```
## Observação importante
`ConversationSummaryMemory` não é o mesmo que `Checkpoint Compaction`.
- Checkpoint compaction reduz checkpoints técnicos do LangGraph.
- ConversationSummaryMemory reduz o contexto semântico da conversa para o LLM.

View File

@@ -0,0 +1,54 @@
# Dynamic LLM Profiles
`llm_profiles.yaml` is optional.
If the file does not exist, the backend keeps the current behavior and uses `.env`:
```env
LLM_PROVIDER=oci_openai
OCI_GENAI_MODEL=openai.gpt-4.1
LLM_TEMPERATURE=0.2
LLM_MAX_TOKENS=2048
```
If `llm_profiles.yaml` exists, each inference point resolves parameters in this order:
```text
specific profile -> default profile -> .env
```
Supported inference points:
| Profile | Used by |
|---|---|
| `default` | global fallback when YAML exists |
| `supervisor` | global supervisor / LLM supervisor |
| `router` | EnterpriseRouter LLM classification |
| `guardrail` | optional LLM guardrail rail |
| `grl` | optional output supervisor / GRL LLM rail and GRL advisor |
| `judge` | LLM judge when enabled in `config/judges.yaml` |
| `rag_rewriter` | RAG query rewriting |
| `rag_compressor` | RAG context compression |
| `rag_generation` | direct RAG answer generation |
| `summary_memory` | ConversationSummaryMemory |
| `noc` | optional NOC reasoning advisor |
| `<agent_name>` | agent runtime, for example `billing_agent` |
Optional LLM inference points are disabled by default to preserve current behavior:
```env
ENABLE_LLM_GUARDRAIL=false
ENABLE_LLM_GRL=false
ENABLE_RAG_QUERY_REWRITE=false
ENABLE_RAG_CONTEXT_COMPRESSION=false
ENABLE_RAG_GENERATION=false
```
To enable guardrails/GRL, inject the same backend LLM object into the corresponding component and set the flags above as needed. For judges, do not use an extra LLM flag: enable or disable the LLM judge in `config/judges.yaml`.
## Provider per profile
Recommendation: declare `provider` explicitly in every profile. The resolver can inherit it from `default`, but explicit provider avoids ambiguity and makes tests with invalid models/providers deterministic.
Judge activation is controlled by `config/judges.yaml`; `llm_profiles.yaml` only chooses the `judge` model/provider/params.

View File

@@ -0,0 +1,84 @@
# Guardrails calibrados adaptados ao framework
## Objetivo
Este pacote mantém a arquitetura atual do `agent_framework` e substitui a calibração interna dos rails pela lógica do pacote `guardrails.zip` anexado.
Foram preservados:
- `GuardrailPipeline`
- execução paralela/fail-fast via `ParallelRailExecutor`
- emissão de eventos GRL e eventos nomeados por rail
- `OutputSupervisor`
- perfis dinâmicos de LLM (`guardrail` e `grl`)
- gravação do modelo no Langfuse pelo provider do próprio framework
## O que mudou
A lógica calibrada foi adicionada em:
```text
src/agent_framework/guardrails/calibrated/
```
A ponte com o LLM do framework foi adicionada em:
```text
src/agent_framework/guardrails/framework_llm_client.py
```
As classes públicas foram preservadas em:
```text
src/agent_framework/guardrails/rails.py
```
## Rails calibrados integrados
Input:
- `INPUT_SIZE`
- `MSK`
- `TOX`
- `PINJ`
- `VLOOP`
- `DLEX_IN`
- `OOS` opcional via `GUARDRAIL_OOS_ENABLED=true`
Output:
- `MSK`
- `TOXOUT`
- `CMP`
- `AOFERTA`
- `REVPREC`
- `DLEX_OUT`
- `GND`
- `ALUC_RISK`
Retrieval:
- `RET_REL`
- `RAGSEC`
- `MSK`
## LLM e Langfuse
Os rails LLM não criam outro cliente fora do framework. Eles usam o `llm` passado ao `GuardrailPipeline`, chamando:
```python
llm.ainvoke(..., profile_name="guardrail" ou "grl", component_name="guardrail.<code>")
```
Assim, o modelo usado por `PINJ`, `OOS`, `AOFERTA`, `REVPREC`, `RAGSEC`, etc. continua aparecendo corretamente no Langfuse conforme a instrumentação atual do framework.
## Modo mock
Quando `USE_MOCK_LLM=true`, os rails LLM usam heurísticas locais calibradas para desenvolvimento/teste rápido.
Para validar com LLM real:
```bash
export USE_MOCK_LLM=false
```

View File

@@ -0,0 +1,43 @@
# Guardrails and llm_profiles.yaml enforcement
This fix ensures calibrated guardrails respect `llm_profiles.yaml` for the `guardrail` and `grl` profiles.
## Problem
Some boot paths instantiated `GuardrailPipeline` without an explicit framework LLM. In that case the adapter treated `llm is None` as local mock mode and returned local fallback decisions. Also, `USE_MOCK_LLM=true` could hide the model configured in `profiles.guardrail` or `profiles.grl`.
That meant intentionally invalid models such as `xopenai.gpt-4.1` did not fail, because the guardrail never reached the configured provider.
## Fix
`framework_llm_client.py` now:
- resolves the selected profile before deciding mock vs real;
- creates the framework LLM from `Settings` when the pipeline did not receive one;
- gives precedence to an explicit non-mock `guardrail`/`grl` profile over `USE_MOCK_LLM`;
- no longer overrides `temperature` and `max_tokens` at call time, so YAML profile values are honored.
`custom_rails.py` now allows passing `llm` and `observer` into the generated `GuardrailPipeline`.
## Expected validation
With this YAML:
```yaml
profiles:
default:
provider: oci_openai
model: openai.gpt-4.1
guardrail:
model: xopenai.gpt-4.1
temperature: 0
max_tokens: 600
grl:
model: xopenai.gpt-4.1
temperature: 0
max_tokens: 700
```
LLM-based guardrails such as `PINJ` fallback, `AOFERTA`, `REVPREC`, `DLEX_OUT`, `RAGSEC`, and enabled LLM checks must attempt to use `xopenai.gpt-4.1` and surface the provider/model error instead of silently returning local mock results.
Deterministic short-circuit rails may still block before calling an LLM. To validate profile usage, use a case that reaches the LLM rail or inspect Langfuse generation metadata for `profile_name`, `model`, and `profile_source`.

View File

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

View File

@@ -0,0 +1,22 @@
# Correção: `reason` real nos guardrails calibrados
Esta versão corrige o fallback local dos guardrails calibrados para não emitir razões genéricas como `mock PINJ calibrado`.
Mesmo quando `USE_MOCK_LLM=true`, os rails agora retornam uma razão operacional baseada no marcador ou padrão que disparou a decisão.
Exemplos:
- `PINJ`: informa o padrão determinístico de prompt injection/jailbreak detectado.
- `REVPREC`: informa o marcador de verbalização prematura encontrado.
- `AOFERTA`: informa o marcador de oferta proativa detectado.
- `TOX`: informa o padrão determinístico de toxicidade detectado.
- `OOS`: informa o marcador fora de escopo encontrado.
- `RAGSEC`, `DLEX_IN` e `DLEX_OUT`: informam o padrão local de risco quando o fallback local estiver ativo.
A arquitetura atual foi preservada:
- `GuardrailPipeline`
- `ParallelRailExecutor`
- emissão GRL
- execução paralela/fail-fast
- uso de `llm_profiles.yaml` via LLM do framework quando `USE_MOCK_LLM=false`

View File

@@ -0,0 +1,72 @@
# guardrails.yaml como fonte da verdade
## Problema corrigido
O framework estava instanciando o bundle default de guardrails diretamente dentro de `GuardrailPipeline` e `CustomRails`.
Na prática, isso fazia com que todos os guardrails disponíveis fossem executados mesmo quando `config/guardrails.yaml` declarava apenas alguns rails habilitados.
## Regra atual
Agora a regra é:
```text
Se config/guardrails.yaml existir:
somente os rails listados e enabled=true serão executados.
Se config/guardrails.yaml não existir:
o framework mantém o comportamento legado e carrega o bundle default.
```
## Exemplo
```yaml
input:
- code: MSK
enabled: true
- code: VLOOP
enabled: true
output:
- code: REVPREC
enabled: true
```
Com esse arquivo, o input executa apenas `MSK` e `VLOOP`, e o output executa apenas `REVPREC`.
Guardrails como `PINJ`, `TOX`, `DLEX_IN`, `AOFERTA`, `DLEX_OUT`, `CMP` e `RAGSEC` não são instanciados se não estiverem no YAML.
## Guardrail LLM
Quando um rail LLM está habilitado no YAML, ele usa o profile adequado do `llm_profiles.yaml`:
```text
PINJ, TOX, OOS, DLEX_IN, RAGSEC -> profile guardrail
REVPREC, AOFERTA, DLEX_OUT -> profile grl
```
Se o modelo do profile estiver errado, o erro não deve ser escondido por fallback silencioso.
## Rails conhecidos
Principais códigos aceitos:
```text
INPUT_SIZE
MSK
TOX
PINJ
VLOOP
DLEX_IN
OOS
TOXOUT
CMP
AOFERTA
REVPREC
DLEX_OUT
GND
ALUC_RISK
RET_REL
RAGSEC
TOOL_VAL
```
Também existem aliases de compatibilidade, como `JAILBREAK`, `GROUNDEDNESS`, `HALLUCINATION_RISK`, `TOX_OUT`, `MSK_OUT` e `TOOL_VALIDATION`.

View File

@@ -0,0 +1,71 @@
# IC/NOC/GRL nativo no Langfuse
Esta versão do framework remove a necessidade de um `ics_collector.py` dentro de cada agente.
Agora o próprio framework publica eventos `IC.*`, `AGA.*`, `NOC.*` e `GRL.*` no Langfuse por meio do `AgentObserver` e do `LangfuseAnalyticsPublisher`.
## Configuração
Para publicar IC/NOC/GRL no Langfuse:
```env
ENABLE_LANGFUSE=true
LANGFUSE_PUBLIC_KEY=pk-lf-...
LANGFUSE_SECRET_KEY=sk-lf-...
LANGFUSE_HOST=http://localhost:3005
# Opcional. Se não informar, ENABLE_LANGFUSE=true já inclui langfuse no observer.
ENABLE_ANALYTICS=true
ANALYTICS_PROVIDERS=langfuse,oci_streaming
```
Para manter compatibilidade com projetos antigos:
```python
from agent_framework.observer import configure
configure({"publisher": {"type": "langfuse"}})
```
## Emissão em agentes nativos
```python
from agent_framework.observer import event, ic, noc, grl
# Mantém o código exatamente como o backoffice original mostrava no Langfuse.
event("AGA.001", data={"sessionId": session_id, "agentId": "backoffice"})
# Também pode usar os atalhos.
ic("AGA.018", data={"missingFields": ["gsm"]})
noc("NOC.001", data={"sessionId": session_id})
grl("GRL.004", data={"rail": "PINJ", "blocked": True})
```
## Comportamento esperado no Langfuse
Cada evento vira uma observation/span com `name` igual ao código:
- `AGA.001`
- `AGA.018`
- `NOC.001`
- `GRL.004`
No modo `LANGFUSE_TRACE_MODE=compact`, eventos `IC.*`, `AGA.*` e `NOC.*`
continuam visíveis como spans filhos do span raiz. Eles não são substituídos por
tags da trace. Eventos técnicos de baixo nível continuam sujeitos à compactação.
A metadata recebe automaticamente:
- `tag`
- `ic=true` para `IC.*` e `AGA.*`
- `noc=true` para `NOC.*`
- `grl=true` para `GRL.*`
- `sessionId`, `messageId`, `agentId`, `channelId` quando existirem no payload
## Compatibilidade TIM/FIRST
`ic("AGA.001")` não vira `IC.AGA.001`. O framework preserva `AGA.001`, porque no backoffice original esse era o contrato exibido no Langfuse.
`noc("001")` vira `NOC.001`.
`grl("004")` vira `GRL.004`.

View File

@@ -0,0 +1,90 @@
# Calibrated Judges Adaptation
This project now carries the calibrated judge package inside the framework while preserving the existing architecture.
## What changed
The calibrated judge prompts were added under:
```text
src/agent_framework/judges/calibrated/
```
The main integration point remains:
```text
src/agent_framework/judges/judge.py
```
The framework still uses:
- `JudgePipeline`
- `config/judges.yaml` as the source of truth for which judges run
- `llm_profiles.yaml` profile `judge` for provider/model/temperature/max tokens
- the existing framework LLM provider, Langfuse instrumentation and token accounting
- `.env` fallback when `llm_profiles.yaml` is absent
There is intentionally no `ENABLE_LLM_JUDGE` gate.
## Current mapping
With this YAML:
```yaml
judges:
- name: response_quality
enabled: true
threshold: 0.7
- name: groundedness
enabled: true
threshold: 0.6
```
The framework runs:
| YAML name | Calibrated task | Purpose |
| --- | --- | --- |
| `response_quality` | `RQLT` | response quality |
| `groundedness` | `ALUC` | hallucination / unsupported factual claims |
Both use the `judge` LLM profile unless another profile is set on the YAML item.
## Testing model enforcement
If this is configured:
```yaml
profiles:
judge:
provider: oci_openai
model: xopenai.gpt-4.1
temperature: 0
max_tokens: 800
```
Then `response_quality` and `groundedness` will try to use `xopenai.gpt-4.1`. If that model does not exist, the calibrated judge call should fail according to the entry/global `fail_closed` behavior.
## Keeping the old heuristic judges
To force the old deterministic behavior for a specific judge:
```yaml
judges:
- name: response_quality
type: deterministic
enabled: true
threshold: 0.7
```
## Optional calibrated judges
```yaml
judges:
- name: tone
enabled: true
- name: sentiment
enabled: true
fail_on_negative: false
```
These map to the calibrated `VCTN` and `CSI` prompts.

View File

@@ -0,0 +1,33 @@
# judges.yaml simple schema
The framework accepts the simple judge configuration format:
```yaml
judges:
- name: response_quality
enabled: true
threshold: 0.7
- name: groundedness
enabled: true
threshold: 0.6
```
In this format, `type` is optional. The framework infers deterministic judges from `name`:
- `response_quality` -> deterministic response quality judge
- `groundedness` -> deterministic groundedness judge
The `threshold` field is now applied to the deterministic judge pass/fail calculation and is also emitted in the judge result metadata.
No LLM is called by this YAML. The `llm_profiles.yaml` profile named `judge` is only used if a LLM judge is explicitly declared, for example:
```yaml
judges:
- name: llm_judge
type: llm
enabled: true
profile: judge
fail_closed: true
```
There is no `ENABLE_LLM_JUDGE` gate. The YAML is the source of truth.

View File

@@ -0,0 +1,40 @@
# Judges YAML as the source of truth
This version removes the extra `ENABLE_LLM_JUDGE` activation gate.
The judge stage now follows this rule:
1. `ENABLE_JUDGES=false` disables the whole judge stage.
2. `config/judges.yaml` decides which judges are active.
3. If a judge has `type: llm` and `enabled: true`, the framework calls the LLM.
4. `llm_profiles.yaml` decides which model/provider that LLM judge uses through the configured profile, usually `judge`.
5. If `llm_profiles.yaml` is absent, the LLM judge falls back to the global `.env` LLM configuration.
Example:
```yaml
judges:
- code: llm_judge
type: llm
enabled: true
profile: judge
fail_closed: true
```
And in `llm_profiles.yaml`:
```yaml
profiles:
judge:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 800
```
If you intentionally configure a nonexistent model for `judge`, the LLM judge will try to use it. The final behavior depends on `fail_closed`:
- `fail_closed: true` blocks/fails the judge result.
- `fail_closed: false` reports the LLM judge as unavailable and follows fail-open.
`ENABLE_LLM_JUDGE` is intentionally not used anymore.

View File

@@ -0,0 +1,60 @@
# Judge model/profile error handling
The calibrated judges (`response_quality`, `groundedness`, `sentiment`, `tone`, `llm_judge`) are LLM-based unless an entry explicitly declares `type: deterministic`.
Because they depend on the model configured in `llm_profiles.yaml`, the default behavior is now **fail-closed**:
```yaml
judges:
- name: response_quality
enabled: true
threshold: 0.7
- name: groundedness
enabled: true
threshold: 0.6
```
With this configuration, if the profile `judge` points to an invalid model, for example:
```yaml
profiles:
judge:
provider: oci_openai
model: xopenai.gpt-4.1
```
then the judge result is returned as `passed=false`, with `score=0.0`, the exception metadata, and a reason similar to:
```text
Falha no judge calibrado RQLT: ...
```
To intentionally keep the old fail-open behavior, configure it explicitly in `judges.yaml`:
```yaml
fail_closed: false
judges:
- name: response_quality
enabled: true
threshold: 0.7
```
Or per judge:
```yaml
judges:
- name: response_quality
enabled: true
threshold: 0.7
fail_closed: false
```
Deterministic judges do not call the LLM profile:
```yaml
judges:
- name: response_quality
type: deterministic
enabled: true
threshold: 0.7
```

View File

@@ -0,0 +1,44 @@
# Langfuse analytics context correlation fix
This patch fixes a remaining trace-splitting issue in the Langfuse analytics publisher.
## Problem
After the framework trace-id normalization fix, the main HTTP/workflow trace was being created correctly, but some IC/NOC/GRL events could still appear as separate root traces in Langfuse, especially events such as:
- `IC.BACKOFFICE_WORKFLOW_COMPLETED`
- `IC.BACKOFFICE_NODE_COMPLETED`
- `NOC.*`
This happened when those analytics events carried only business identifiers such as `transaction_id` or `sessionId`, while the HTTP trace was correlated by `request_id`.
## Fix
`src/agent_framework/analytics/providers/langfuse.py` now merges the current `ObservabilityContext` into analytics event payloads before computing the Langfuse trace context.
Correlation priority is now:
1. Current `trace_id` / `request_id` from `ObservabilityContext`
2. Payload `trace_id` / `request_id`
3. Business fallback: `transaction_id`, `session_id`, `sessionId`
This keeps business IDs in metadata, but ensures Langfuse observations are attached to the active request trace whenever a workflow is running.
## Expected result
In Langfuse `Tracing > Traces`, a single backoffice request should appear as one main trace, such as:
- `http.request.completed`
- or the configured request/workflow root name
Inside that trace, the internal observations should include:
- `backoffice.channel.normalized`
- `backoffice.workflow.dispatch`
- `langgraph.node.*`
- `mcp.tool_call.*`
- `IC.BACKOFFICE_*`
- `NOC.*`
- guardrails and judges
IC/NOC/GRL events should no longer create a separate trace just because they only carried `transaction_id` or `sessionId`.

View File

@@ -0,0 +1,81 @@
# Langfuse Internal Event Root Trace Fix
## Problema
Alguns eventos internos IC/NOC/GRL estavam aparecendo na tela **Tracing → Traces** como traces raiz separados, mesmo quando pertenciam à mesma execução REST/workflow.
Exemplo observado:
```text
Name: http.request.completed
Input: {"eventType": "NOC.006", ...}
Output: {"published": true}
```
Esse registro não representa a execução real do agente. Ele representa apenas a publicação de um evento interno via analytics, e por isso não deve aparecer como trace raiz.
## Correção
O arquivo abaixo foi ajustado:
```text
src/agent_framework/analytics/providers/langfuse.py
```
A nova regra é:
```text
1 request/workflow = 1 trace raiz
IC/NOC/GRL = observations/spans dentro do trace corrente
Eventos internos embrulhados em http.request/gateway/telemetry não criam trace raiz
```
## Regras aplicadas
O publisher agora:
1. Detecta envelopes internos como `IC.*`, `NOC.*`, `GRL.*` e `AGA.*`.
2. Suprime eventos técnicos do tipo `http.request.completed` cujo input real é um envelope interno como `NOC.006`.
3. Prioriza correlação por `ObservabilityContext`:
```text
trace_id/request_id do contexto atual
> trace_id/request_id do payload
> transaction_id/session_id apenas como fallback
```
4. Evita fallback para `langfuse.trace(...)` ou `langfuse.span(...)` para eventos internos/técnicos quando a observation correlacionada falha.
5. Mantém a flag abaixo para debug isolado:
```bash
export LANGFUSE_ALLOW_STANDALONE_INTERNAL_EVENTS=true
```
Por padrão, essa flag deve ficar desligada.
## Resultado esperado
Na tela **Tracing → Traces**, uma execução nova deve aparecer como uma linha principal, por exemplo:
```text
http.request.completed
```
ou:
```text
backoffice.process-and-stream
```
Ao abrir o trace, devem aparecer internamente:
```text
IC.BACKOFFICE_WORKFLOW_COMPLETED
NOC.006
langgraph.node.*
mcp.tool_call.*
guardrail.*
judge.*
```
O trace solto com `Input: {"eventType": "NOC.006"}` e `Output: {"published": true}` deve desaparecer.

View File

@@ -0,0 +1,62 @@
# Langfuse native `sessionId` fix
## Problema
O Agent Framework já carregava `session_id` no contexto e no metadata de spans,
porém traces criados com Langfuse Python SDK v4 podiam aparecer com
`trace.sessionId = null`. Como consequência, `/api/public/sessions` não retornava
as conversas, embora `metadata.session_id` estivesse presente nas observations.
## Causa
No Langfuse Python SDK v4, atributos correlacionais como `session_id`, `user_id`,
tags e metadata devem ser aplicados por `propagate_attributes`, que é uma função
**de nível de módulo** (`from langfuse import propagate_attributes`).
O framework tinha duas tentativas:
1. `observation.update_trace(session_id=...)` — mantido por compatibilidade, mas
descontinuado no SDK v4;
2. `self.langfuse.propagate_attributes(...)` — formato incompatível com o SDK v4,
pois `propagate_attributes` não é método do client.
## Correção
`Telemetry` agora importa e mantém o callable de módulo do Langfuse v4 e o usa
imediatamente dentro do root span:
```text
agent.gateway_message root span
-> propagate_attributes(
session_id=<agent_session_id>,
user_id=<user_id>,
metadata=<correlation metadata>,
tags=<root tags>,
trace_name=<root span name>
)
-> workflow / child observations
```
O fallback por método do client permanece para compatibilidade com SDKs ou
wrappers anteriores.
## Resultado esperado
Para uma sessão de negócio:
```text
default:telecom_contas:f2a6e957-2c74-49ba-882e-ad14131cf1cc
```
o trace retornado pelo Langfuse deve apresentar:
```json
{
"sessionId": "default:telecom_contas:f2a6e957-2c74-49ba-882e-ad14131cf1cc"
}
```
e `/api/public/sessions` deve materializar/agrupar a sessão.
O `session_id` continua também no metadata do framework para diagnóstico e
retrocompatibilidade.

View File

@@ -0,0 +1,56 @@
# Langfuse Span Hierarchy Fix
## Problem
After trace correlation was fixed, a full request no longer exploded into many independent Langfuse traces. However, observations inside the trace could appear flattened at the same level.
This happened because the framework was propagating only the Langfuse `trace_id`, but not the current parent observation/span id.
In Langfuse, a tree needs both:
- `trace_id`: identifies the root execution trace;
- `parent_span_id`: identifies which observation/span should be the parent of the new observation.
Without `parent_span_id`, all observations are correlated to the same trace but may appear as direct children of the trace root.
## Fix
The framework now keeps the current Langfuse observation id in the async observability context.
Updated files:
- `src/agent_framework/observability/context.py`
- `src/agent_framework/observability/telemetry.py`
- `src/agent_framework/analytics/providers/langfuse.py`
## Behavior
When a span starts:
1. The framework creates the Langfuse observation.
2. It extracts the observation id from the SDK object.
3. It stores that id in a ContextVar as the current parent observation.
4. Nested spans, generations and analytics events pass it as `trace_context.parent_span_id`.
5. When the span exits, the previous parent observation id is restored.
## Expected Langfuse Structure
A backoffice request should appear as one trace, with nested observations such as:
```text
http.request / backoffice.process-and-stream
└── backoffice.workflow.dispatch
├── langgraph.node.framework_input_guardrails
├── langgraph.node.fetch_ticket
├── langgraph.node.validation
├── langgraph.node.imdb_enrichment
│ └── mcp.tool_call.consultar_imdb_cliente
├── langgraph.node.knowledge_base_enrichment
│ └── mcp.tool_call.consultar_tais_kb
├── langgraph.node.treatment_decision
└── langgraph.node.siebel_sr_opening
```
## Notes
This fix complements the previous trace correlation fixes. Those fixes solved root trace duplication. This fix solves parent-child hierarchy inside the trace.

View File

@@ -0,0 +1,107 @@
# Langfuse trace correlation fix
## Problem
The Langfuse **Tracing > Traces** list was showing one row per internal framework event, for example:
- `langgraph.node.started`
- `langgraph.node.fetch_ticket`
- `OpenAI-generation`
- `TaisKbClient.search_documents`
- `NOC.001`
That is not the intended observability model.
The intended model is:
```text
1 REST/SSE/workflow request = 1 Langfuse trace
internal steps = observations/spans/generations inside that trace
```
## Root causes
1. `Telemetry.event(...)` used raw `langfuse.event(...)` when available. Depending on the SDK/context, this creates a new top-level trace for every event.
2. `Telemetry.generation(...)` preferred raw `langfuse.generation(...)`, which can also create top-level traces when no current Langfuse observation is active.
3. `LangfuseAnalyticsPublisher` for IC/NOC/GRL also created standalone observations without a deterministic trace context.
4. The LLM provider used `langfuse.openai.AsyncOpenAI` whenever Langfuse was enabled. This auto-instrumentation can create separate `OpenAI-generation` traces. The framework already emits correlated LLM generations through `Telemetry.generation(...)`, so the wrapper caused noisy duplicate top-level traces.
## What was changed
### `agent_framework/observability/telemetry.py`
- Added deterministic Langfuse trace correlation using `trace_id` / `request_id` / `session_id`.
- Injects `trace_context={"trace_id": ...}` when starting Langfuse observations/generations, with backward-compatible TypeError fallback.
- `Telemetry.event(...)` no longer calls raw `langfuse.event(...)` first.
- `Telemetry.generation(...)` no longer prefers raw `langfuse.generation(...)`; it prefers correlated current generation/observation APIs.
- When a span has no explicit `trace_id`, it uses the request id as the trace id and stores it in the observability context.
### `agent_framework/analytics/providers/langfuse.py`
- Added deterministic trace correlation for IC/NOC/GRL analytics events.
- Injects `trace_context` into `start_as_current_observation(...)`.
- Avoids raw `langfuse.event(...)` fallback.
- For legacy SDKs, attempts to create/reuse a deterministic trace with `langfuse.trace(id=...)` and attach spans to it.
### `agent_framework/llm/providers.py`
- Langfuse OpenAI auto-instrumentation is now opt-in.
- Default behavior uses the standard `openai.AsyncOpenAI` client and relies on the framework's own `Telemetry.generation(...)` to create correlated Langfuse generations.
- To re-enable wrapper-based auto-instrumentation, set:
```env
ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION=true
```
For this framework, the recommended default is to keep it disabled.
## Expected result
In Langfuse **Tracing > Traces**, you should see one row per business execution, for example:
```text
POST /agent/process-and-stream | man-da8657ac
POST /agent/process-ticket | man-fec67d60
```
When opening a trace, you should see internal observations such as:
```text
http.request
channel_gateway
backoffice.workflow.dispatch
langgraph.node.framework_input_guardrails
langgraph.node.fetch_ticket
langgraph.node.validation
langgraph.node.imdb_enrichment
mcp.tool_call.consultar_imdb_cliente
langgraph.node.treatment_decision
langgraph.node.siebel_sr_opening
framework_output_guardrails
framework_judges
```
## Validation
Run the backend and execute one request. Then verify:
1. The `Traces` screen has one trace row for the request, not one row per node.
2. `OpenAI-generation` no longer appears as a separate top-level trace unless `ENABLE_LANGFUSE_OPENAI_AUTO_INSTRUMENTATION=true`.
3. LangGraph node events and IC/NOC/GRL events appear under the same request trace.
## Fix adicional: formato do trace_id no Langfuse SDK v3
O Langfuse SDK v3 exige que `trace_context.trace_id` seja exatamente um valor hexadecimal minúsculo com 32 caracteres.
Como o framework usa IDs de negócio como UUID com hífens (`d411b925-a096-...`) ou session ids (`man-bcbe3e05`), esses valores agora são normalizados antes de serem enviados ao Langfuse:
- UUID com hífens: remove hífens e reaproveita o hexadecimal de 32 caracteres;
- qualquer outro identificador: gera um hash MD5 determinístico de 32 caracteres;
- o valor original continua preservado em metadata como `framework_trace_id`;
- o valor aceito pelo Langfuse fica em `langfuse_trace_id`.
Isso evita erros como:
```text
ValueError: invalid literal for int() with base 16: 'd411b925-a096-465c-adf2-186623b82c19'
```

View File

@@ -0,0 +1,16 @@
# Remoção do LEGACY_OUTPUT_GUARDRAIL
`LEGACY_OUTPUT_GUARDRAIL` era um sinal de compatibilidade associado ao guardrail LLM genérico/catch-all do pipeline antigo.
Na arquitetura atual, os rails calibrados já executam suas próprias decisões e emitem GRL com códigos de negócio específicos, por exemplo `PINJ`, `TOX`, `REVPREC`, `AOFERTA`, `DLEX_OUT` e `RAGSEC`.
Por isso, o emit legado foi removido/suprimido para evitar ruído e duplicidade no Langfuse.
## Regra atual
- Mantém `GRL.001` a `GRL.009` para ciclo e resultado do pipeline.
- Mantém eventos nomeados dos rails calibrados, como `GRL.REVPREC` e `guardrail.output.REVPREC.completed`.
- Suprime eventos genéricos/legados: `LEGACY_OUTPUT_GUARDRAIL`, `LLM_GUARDRAIL` e `LLM_GRL`.
- Remove o auto-append do guardrail LLM genérico controlado por `ENABLE_LLM_GUARDRAIL`.
O uso de LLM permanece nos rails calibrados que precisam dele, usando `llm_profiles.yaml` com os profiles `guardrail` e `grl`.

View File

@@ -0,0 +1,43 @@
# Explicit provider in llm_profiles.yaml
Each LLM profile should declare `provider` explicitly. The resolver can still inherit
missing keys from `profiles.default` and then from `.env`, but explicit `provider`
per profile is safer because each inference point clearly states which client must
be used.
Rules:
- If `llm_profiles.yaml` exists, the selected profile overrides `.env`.
- Missing keys in a selected profile fall back to `profiles.default`.
- Missing keys in `profiles.default` fall back to `.env`.
- `judges.yaml` decides whether an LLM judge exists. There is no
`ENABLE_LLM_JUDGE` gate and no `LLM_JUDGE_FAIL_CLOSED` setting. Judge
fail-open/fail-closed behavior belongs in `judges.yaml`.
- `llm_profiles.yaml` only chooses provider/model/params for the judge profile.
Example test:
```yaml
profiles:
judge:
provider: oci_openai
model: xopenai.gpt-4.1
temperature: 0
max_tokens: 800
```
And enable the LLM judge in `config/judges.yaml`:
```yaml
enabled: true
fail_closed: true
judges:
- code: llm_judge
type: llm
enabled: true
profile: judge
fail_closed: true
```
With that setup, the invalid model must be used by the judge profile and the
judge must fail closed.

View File

@@ -0,0 +1,11 @@
# Long-Term Memory
Capacidade nativa do `agent_framework`, isolada por `tenant_id + agent_id + customer_key`.
O runtime carrega e injeta as memórias automaticamente. Os dois templates persistem os fatos após `supervisor_review`. Os agentes individuais não precisam de alteração.
## Teste
```bash
PYTHONPATH=libs/agent_framework/src python templates/agent_template_backend/scripts/test_long_term_memory.py
```

View File

@@ -0,0 +1,63 @@
### Long-Term Memory on Oracle Autonomous Database
### Activation
```env
ENABLE_LONG_TERM_MEMORY=true
LONG_TERM_MEMORY_PROVIDER=autonomous
ADB_USER=ADMIN
ADB_PASSWORD=<password>
ADB_DSN=<autonomous_service_name>
ADB_WALLET_LOCATION=/path/to/wallet
ADB_WALLET_PASSWORD=<wallet_password_if_applicable>
ADB_TABLE_PREFIX=AGENTFW
# Optional. Default: ${ADB_TABLE_PREFIX}_LONG_TERM_MEMORY.
LONG_TERM_MEMORY_ORACLE_TABLE=AGENTFW_LONG_TERM_MEMORY
```
`LONG_TERM_MEMORY_PROVIDER=oracle` is also accepted.
### Dependency
```bash
pip install oracledb
```
### Schema initialization
On the first operation, the provider automatically creates the table and index. The user configured in `ADB_USER` needs permission to create tables and indexes. If a DBA provisions the schema beforehand, initialization accepts the existing objects.
### Identity and isolation
The logical key consists of:
```text
tenant_id + agent_id + subject_key + category + memory_key
```
In the current integration, `subject_key` is derived from `customer_key`.
### Test
1. Start the backend with the `autonomous` provider.
2. Store facts in session A.
3. Open session B with the same `customer_key`.
4. Verify retrieval.
5. Restart the backend and repeat the query.
6. Query `AGENTFW_LONG_TERM_MEMORY` in Autonomous Database.
```sql
SELECT TENANT_ID, AGENT_ID, SUBJECT_KEY, CATEGORY, MEMORY_KEY,
MEMORY_VALUE, CONFIDENCE, UPDATED_AT
FROM AGENTFW_LONG_TERM_MEMORY
ORDER BY UPDATED_AT DESC;
```
### Notes
- The provider uses `python-oracledb` in thin mode.
- Synchronous operations run through `asyncio.to_thread`.
- A wallet is optional when walletless TLS is configured.
- SQLite and InMemory remain available for development and testing.

View File

@@ -0,0 +1,63 @@
### Long-Term Memory no Oracle Autonomous Database
### Ativação
```env
ENABLE_LONG_TERM_MEMORY=true
LONG_TERM_MEMORY_PROVIDER=autonomous
ADB_USER=ADMIN
ADB_PASSWORD=<senha>
ADB_DSN=<service_name_do_autonomous>
ADB_WALLET_LOCATION=/caminho/para/wallet
ADB_WALLET_PASSWORD=<senha_wallet_se_aplicavel>
ADB_TABLE_PREFIX=AGENTFW
# Opcional. O padrão é ${ADB_TABLE_PREFIX}_LONG_TERM_MEMORY.
LONG_TERM_MEMORY_ORACLE_TABLE=AGENTFW_LONG_TERM_MEMORY
```
Também é aceito `LONG_TERM_MEMORY_PROVIDER=oracle`.
### Dependência
```bash
pip install oracledb
```
### Inicialização do schema
Na primeira operação, o provider cria automaticamente a tabela e o índice. O usuário configurado em `ADB_USER` precisa de permissão para criar tabela e índice. Se o schema for provisionado previamente por DBA, a inicialização reconhece os objetos existentes.
### Identidade e isolamento
A chave lógica é composta por:
```text
tenant_id + agent_id + subject_key + category + memory_key
```
Na integração atual, `subject_key` é derivado do `customer_key`.
### Teste
1. Inicie o backend com provider `autonomous`.
2. Grave fatos na sessão A.
3. Abra a sessão B com o mesmo `customer_key`.
4. Confirme a recuperação.
5. Reinicie o backend e repita a consulta.
6. Consulte a tabela `AGENTFW_LONG_TERM_MEMORY` no Autonomous Database.
```sql
SELECT TENANT_ID, AGENT_ID, SUBJECT_KEY, CATEGORY, MEMORY_KEY,
MEMORY_VALUE, CONFIDENCE, UPDATED_AT
FROM AGENTFW_LONG_TERM_MEMORY
ORDER BY UPDATED_AT DESC;
```
### Observações
- O provider usa `python-oracledb` em thin mode.
- As operações síncronas são executadas com `asyncio.to_thread`.
- Wallet é opcional quando a conexão TLS sem wallet estiver configurada.
- SQLite e InMemory continuam disponíveis para desenvolvimento e testes.

View File

@@ -0,0 +1,207 @@
# MCP Cache
O cache MCP é configurado diretamente no `config/tools.yaml`, dentro da própria tool.
Não existe regra chumbada por nome, idioma ou prefixo. Uma tool só usa cache quando declarar explicitamente:
```yaml
tools:
consultar_fatura:
description: Consulta dados resumidos de fatura por msisdn/invoice_id.
mcp_server: telecom
enabled: true
cache:
enabled: true
ttl_seconds: 600
args_schema:
msisdn: string
invoice_id: string
```
Tools sem bloco `cache` não usam cache por padrão:
```yaml
tools:
consultar_titulo_financeiro:
description: Consulta um título financeiro por cliente e contrato.
mcp_server: telecom
enabled: true
args_schema:
customer_id: string
contract_id: string
```
Tools de ação devem ficar sem cache ou com cache explicitamente desabilitado:
```yaml
tools:
solicitar_troca:
description: Simula abertura de solicitação de troca.
mcp_server: retail
enabled: true
tool_type: action
requires: [order_id, reason]
confirmation_required: false
cache:
enabled: false
args_schema:
order_id: string
reason: string
```
## Como a `cache_key` é montada
A chave de cache MCP precisa ser determinística. Ela não pode depender de valores que mudam a cada turno, como `session_id`, `request_id`, `trace_id`, `timestamp`, `intent`, `agent_id` ou `business_context` completo.
A regra implementada é:
```text
mesma tool + mesmos campos declarados no args_schema + mesmos valores = mesma cache_key
```
Exemplo:
```yaml
tools:
consultar_fatura:
cache:
enabled: true
ttl_seconds: 600
```
Com isso, estas duas chamadas geram a mesma chave:
```json
{
"msisdn": "11999999999",
"invoice_id": "12345"
}
```
```json
{
"invoice_id": "12345",
"msisdn": "11999999999",
"session_id": "valor-que-muda",
"trace_id": "valor-que-muda"
}
```
A chave considera automaticamente apenas `msisdn` e `invoice_id` porque eles estão declarados em `args_schema`. Atributos auxiliares fora do contrato da tool são ignorados.
Não é necessário declarar `key_fields` no YAML. A fonte da verdade para a chave é o próprio `args_schema` da tool.
## Configurações globais
```env
ENABLE_MCP_CACHE=true
MCP_CACHE_TTL_SECONDS=300
TOOLS_CONFIG_PATH=./config/tools.yaml
```
`MCP_CACHE_TTL_SECONDS` é apenas fallback. O TTL preferencial vem de cada tool.
## Fluxo
```text
AgentRuntimeMixin._call_mcp_tool()
Lê política cache da tool em tools.yaml
Monta cache_key por tool_name + campos declarados no args_schema
cache ausente/false → IC.MCP_CACHE_BYPASS → chama MCP normalmente
cache.enabled=true → tenta cache
cache hit → IC.MCP_CACHE_HIT → retorna resultado salvo
cache miss → IC.MCP_CACHE_MISS → chama MCP Router
ok=true → IC.MCP_CACHE_SET → salva no cache com TTL da tool
ok=false → IC.MCP_CACHE_NOT_STORED → não salva
```
## Evidências operacionais
O runtime grava logs:
```text
MCP cache bypass
MCP cache hit
MCP cache miss
MCP cache set
MCP cache not stored
```
O runtime também emite eventos IC. Nos eventos `HIT`, `MISS`, `SET` e `NOT_STORED`, o payload inclui `cache_key` e `cache_key_payload` para auditoria:
```text
IC.MCP_CACHE_BYPASS
IC.MCP_CACHE_HIT
IC.MCP_CACHE_MISS
IC.MCP_CACHE_SET
IC.MCP_CACHE_NOT_STORED
```
E eventos de telemetria:
```text
cache.mcp.hit
cache.mcp.miss
cache.mcp.set
```
## Onde está implementado
- `src/agent_framework/runtime/agent_runtime.py`
- `src/agent_framework/mcp/models.py`
- `src/agent_framework/mcp/registry.py`
- `config/tools.yaml`
## Regra de segurança
O default é `cache.enabled=false`. Isso evita cache acidental em tools mutáveis, como abertura de chamado, troca, devolução, cancelamento ou alteração cadastral.
## Exemplo de evidência esperada
Primeira chamada:
```text
IC.MCP_CACHE_MISS tool=consultar_fatura
IC.MCP_CACHE_SET tool=consultar_fatura ttl_seconds=600
```
Segunda chamada com os mesmos `msisdn` e `invoice_id`:
```text
IC.MCP_CACHE_HIT tool=consultar_fatura
```
Se a segunda chamada gerar `MISS`, confira o `cache_key_payload`. Ele deve conter os mesmos argumentos efetivos enviados ao MCP.
## Ordem correta dos eventos
Na primeira chamada cacheável:
```text
IC.MCP_TOOL_REQUESTED
IC.MCP_CACHE_MISS
IC.MCP_TOOL_EXECUTING
IC.MCP_TOOL_EXECUTED
IC.MCP_CACHE_SET
IC.TOOL_CALLED cached=false
```
Na segunda chamada com os mesmos campos de `args_schema`:
```text
IC.MCP_TOOL_REQUESTED
IC.MCP_CACHE_HIT
IC.TOOL_CALLED cached=true
```
Quando houver `IC.MCP_CACHE_HIT`, não deve aparecer `IC.MCP_TOOL_EXECUTING` nem `IC.MCP_TOOL_EXECUTED`, porque o MCP Server não foi chamado.
# Relação com políticas de execução
Cache e política operacional são independentes. Classifique consultas e transações no arquivo opcional `config/tool_policies.yaml` do backend; mantenha `cache` no catálogo `tools.yaml`. Operações transacionais não devem ser cacheadas. Se o arquivo novo não existir, os campos legados de execução em `tools.yaml` continuam válidos.

View File

@@ -0,0 +1,211 @@
# MCP Parameter Extraction (`extract`)
## Objetivo
O recurso `extract` permite que o framework extraia parâmetros adicionais da mensagem do usuário antes da chamada do MCP Server.
Esses parâmetros não fazem parte do Business Context (`customer_key`, `contract_key`, `interaction_key`, `account_key`, `resource_key`, `session_key`). Eles representam dados específicos de uma tool MCP.
Exemplos genéricos:
- período solicitado;
- quantidade solicitada;
- código citado pelo usuário;
- identificador informado na mensagem;
- data textual mencionada;
- qualquer entidade de negócio necessária para a tool.
## Princípio arquitetural
O mecanismo é declarativo e genérico.
O framework não deve conhecer nomes de campos específicos, regras de domínio ou valores possíveis. A semântica de cada campo vem exclusivamente da configuração da tool no `mcp_parameter_mapping.yaml`.
```text
identity.yaml
→ resolve identidade e chaves canônicas
mcp_parameter_mapping.yaml
→ mapeia parâmetros MCP e declara extrações específicas de tool
```
## Quando usar
Use `extract` quando:
1. a informação está presente na mensagem em linguagem natural;
2. a informação não é uma chave canônica do Business Context;
3. a informação só é necessária para uma tool específica;
4. o MCP Server deve receber o valor já estruturado em `args`.
## Quando não usar
Não use `extract` para resolver:
- `customer_key`;
- `contract_key`;
- `interaction_key`;
- `account_key`;
- `resource_key`;
- `session_key`.
Essas chaves pertencem ao mecanismo de identidade e devem ser resolvidas por `identity.yaml`.
## Exemplo de configuração
```yaml
mcp_parameter_mapping:
tools:
minha_tool:
map:
customer_key: customer_id
contract_key: contract_id
session_key: session_id
extract:
parametro_externo:
from: message
type: string
strategy: llm
description: >
Extraia da mensagem do usuário o valor necessário para preencher
parametro_externo. Retorne null quando a informação não estiver
presente no texto.
```
## Fluxo de execução
```text
Mensagem do usuário
Router identifica intent
Framework escolhe a tool MCP
MCPParameterMapper aplica map/defaults
MCPToolRouter verifica extract da tool escolhida
Executor genérico chama LLM para cada extractor declarado
Campos extraídos são injetados em args
MCP Server é chamado
```
## Exemplo conceitual
Mensagem do usuário:
```text
Texto em linguagem natural contendo uma informação necessária para a tool.
```
Resultado esperado da extração:
```json
{
"parametro_externo": "valor_extraido"
}
```
Payload enviado ao MCP:
```json
{
"customer_id": "123",
"contract_id": "ABC",
"session_id": "S1",
"parametro_externo": "valor_extraido"
}
```
No MCP Server:
```python
valor = args.get("parametro_externo")
```
## Logs esperados
Quando a extração é executada com sucesso:
```text
mcp.parameter.llm_extracted tool=minha_tool field=parametro_externo value=valor_extraido
```
Quando o valor não é encontrado:
```text
mcp.parameter.llm_extracted_null tool=minha_tool field=parametro_externo
```
Quando o LLM não está disponível ou ocorre erro:
```text
mcp.parameter.llm_extract_failed tool=minha_tool field=parametro_externo error=...
```
## Boas práticas
- Mantenha `identity.yaml` apenas para identidade e chaves canônicas.
- Declare parâmetros adicionais no `mcp_parameter_mapping.yaml`.
- Não coloque regras de domínio hardcoded no framework.
- Não coloque parsing de linguagem natural dentro do MCP Server.
- Nomeie os parâmetros extraídos de forma estável.
- Use `description` para orientar o LLM sobre o que deve ser extraído.
## Regra principal
O framework deve executar extração somente quando:
```text
1. a tool MCP já foi escolhida;
2. essa tool possui extract configurado;
3. o extractor usa uma estratégia suportada, como strategy: llm.
```
Sem `extract` declarado, nada é extraído.
## Precedência dos valores
A partir desta correção, a precedência efetiva é:
```text
1. argumento explícito já presente na tool call;
2. valor extraído da mensagem pelo bloco extract;
3. valor proveniente do Business Context via map;
4. defaults globais ou específicos da tool.
```
O Business Context nunca sobrescreve um argumento explícito ou extraído. Para
identificadores que obrigatoriamente vêm da mensagem, como `order_id`, não
configure `contract_key: order_id`.
Exemplo recomendado:
```yaml
consultar_pedido:
map:
customer_key: customer_id
session_key: session_id
extract:
order_id:
from: message
type: string
strategy: llm
description: >
Extraia somente o identificador do pedido informado explicitamente pelo
usuário. Retorne null quando não houver identificador.
```
A extração usa a generation `llm.mcp_parameter_extraction` e o profile
`mcp_parameter_extraction`. Identificadores devem preferencialmente usar
`type: string` para preservar zeros à esquerda, hífens e prefixos.

View File

@@ -0,0 +1,75 @@
# OCI Instance Principal and FastMCP support
This version adds two framework capabilities:
1. OCI SDK authentication with `OCI_AUTH_MODE=instance_principal`.
2. Official MCP/FastMCP client transport in addition to the legacy HTTP mock contract.
## OCI authentication
Supported values:
```env
OCI_AUTH_MODE=config_file # local ~/.oci/config profile, default
OCI_AUTH_MODE=instance_principal # OCI Compute / OKE workload identity via instance principal
OCI_AUTH_MODE=resource_principal # OCI Functions / resource principal contexts
```
For local development, keep:
```env
LLM_PROVIDER=oci_openai
OCI_GENAI_API_KEY=...
```
For OCI runtimes without API keys, use the SDK provider:
```env
LLM_PROVIDER=oci_sdk
OCI_AUTH_MODE=instance_principal
OCI_COMPARTMENT_ID=ocid1.compartment.oc1...
OCI_REGION=sa-saopaulo-1
OCI_GENAI_MODEL=cohere.command-r-plus
```
The same `OCI_AUTH_MODE` is also used by the OCI embedding provider:
```env
EMBEDDING_PROVIDER=oci
OCI_AUTH_MODE=instance_principal
OCI_COMPARTMENT_ID=ocid1.compartment.oc1...
OCI_EMBEDDING_MODEL=cohere.embed-multilingual-v3.0
```
> Note: `oci_openai` continues to use the OpenAI-compatible endpoint and API key. Instance principal is implemented through `oci_sdk` because it needs OCI request signing.
## FastMCP transport
The previous framework MCP client remains available:
```yaml
servers:
telecom:
transport: http
endpoint: http://localhost:8001/mcp
```
For FastMCP / official MCP Streamable HTTP:
```yaml
servers:
telecom:
transport: fastmcp
endpoint: http://localhost:8001/mcp
```
For MCP SSE:
```yaml
servers:
telecom:
transport: sse
endpoint: http://localhost:8001/sse
```
Tools still use `tools.yaml` and `mcp_parameter_mapping.yaml`. Only the server transport changes.

View File

@@ -0,0 +1,184 @@
# Correção TIM Observer Payload / NOC OTel
Esta versão corrige dois gaps da migração do `agent_framework_oci`:
1. **Pub/Sub flat**: eventos IC/GRL/analytics passam a ser publicados no contrato flat combinado com Data/TIM, sem envelope `{type, payload}` e sem `payload.payload`.
2. **NOC em OpenTelemetry Logs**: eventos NOC passam a ter caminho dedicado para OTel Logs, separado de traces/spans.
3. **Sequence automático**: eventos Pub/Sub flat passam a receber `sequence` incremental por `agentId/sessionId`, preservando valor explícito quando já vier no evento.
## Arquivos alterados/adicionados
- `src/agent_framework/analytics/tim_payload_mapper.py`
- Novo mapper canônico para converter o envelope interno do framework para o payload TIM flat.
- Mantém campos canônicos na raiz.
- Mantém apenas `agentSpecificData` como objeto aninhado.
- `src/agent_framework/analytics/providers/pubsub.py`
- Publica flat por padrão.
- Mantém modo legado por configuração.
- Exclui `NOC.*` do Pub/Sub por padrão, seguindo a lib antiga.
- Permite excluir tipos de evento específicos do Pub/Sub por configuração.
- Injeta `sequence` automaticamente no payload flat antes do publish.
- `src/agent_framework/analytics/tim_sequence.py`
- Novo gerador de sequence por `agentId/sessionId`.
- Suporta Redis `INCR` como contador atômico cross-worker/cross-pod.
- Suporta MongoDB com `find_one_and_update` + `$inc`, mantendo paridade com a lib antiga.
- Usa fallback em memória quando o backend compartilhado não estiver disponível, sem quebrar o fluxo de observabilidade.
- Preserva `sequence` explícito quando o chamador já informou o campo.
- `src/agent_framework/observability/noc_otel.py`
- Novo exportador dedicado de NOC para OpenTelemetry Logs.
- Usa `OTLPLogExporter` e `LoggingHandler`.
- Aplica DE/PARA flat com `keep_none=True`.
- Achata dict/list para string JSON antes de enviar ao OTel.
- `src/agent_framework/observability/observer.py`
- `emit_noc()` agora dispara o canal dedicado de OTel Logs antes da publicação analytics.
- `src/agent_framework/config/settings.py`
- Novas variáveis de configuração.
## Novas variáveis
```env
# Pub/Sub: padrão corrigido para TIM/Data
PUBSUB_PAYLOAD_MODE=flat
PUBSUB_EXCLUDE_NOC=true
# Lista opcional, separada por vírgulas, de tipos de evento não publicados no Pub/Sub.
# Os eventos continuam disponíveis para os demais destinos de observabilidade.
PUBSUB_EXCLUDED_EVENT_TYPES=GRL.NATIVE_OUTPUT_GUARDRAILS
# Sequence automático por sessão no payload Pub/Sub flat
PUBSUB_SEQUENCE_ENABLED=true
# auto = Redis se configurado; senão MongoDB se configurado; senão fallback em memória.
# Para o BO sem OCI Cache, usar mongodb para manter paridade com a lib antiga.
PUBSUB_SEQUENCE_PROVIDER=mongodb
# Opção Redis, quando existir cache disponível
# PUBSUB_SEQUENCE_REDIS_URL=redis://localhost:6379/0
# Opção MongoDB, equivalente ao comportamento antigo via find_one_and_update + $inc
PUBSUB_SEQUENCE_MONGODB_URI=mongodb://localhost:27017
PUBSUB_SEQUENCE_MONGODB_DATABASE=agent_platform
PUBSUB_SEQUENCE_MONGODB_COLLECTION=${AGENT_NAME}_event_counters
PUBSUB_SEQUENCE_TTL_SECONDS=86400
PUBSUB_SEQUENCE_MEMORY_FALLBACK=true
PUBSUB_SEQUENCE_KEY_PREFIX=observer:sequence
# Para voltar temporariamente ao formato antigo envelopado
# PUBSUB_PAYLOAD_MODE=legacy
# NOC via OpenTelemetry Logs
ENABLE_NOC_OTEL_LOGS=true
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://10.153.35.23/v1/logs
OTEL_EXPORTER_OTLP_HOST_HEADER=tim-ai-atend-agnt-opentelemetry
OTEL_SERVICE_NAME=ai-agent-template
```
## Exemplo de Pub/Sub corrigido
```json
{
"eventType": "IC.FATURA_CONSULTADA",
"version": "1.0",
"eventDate": "2026-06-19T12:00:00+00:00",
"sessionId": "sess-789",
"channelId": "whatsapp",
"agentId": "billing-agent",
"tag": "IC.FATURA_CONSULTADA",
"sequence": 12,
"agentSpecificData": {
"invoiceId": "INV-001"
}
}
```
## Observação
O envelope interno retornado pelo `observer.emit(...)` foi mantido para não quebrar EventBus, Langfuse ou consumidores internos. A correção ocorre no provider Pub/Sub e no novo canal NOC OTel.
## Sequence automático
No modo flat, o provider Pub/Sub chama `ensure_sequence(message)` antes de publicar.
Com `sessionId` presente e sem `sequence` explícito, o framework gera:
```text
observer:sequence:<agentId>:<sessionId> -> INCR
```
Exemplo:
```json
{ "eventType": "IC.001", "sessionId": "sess-1", "agentId": "billing", "sequence": 1 }
{ "eventType": "IC.002", "sessionId": "sess-1", "agentId": "billing", "sequence": 2 }
{ "eventType": "IC.003", "sessionId": "sess-1", "agentId": "billing", "sequence": 3 }
```
Regras:
- Se `sequence` já vier no metadata/payload, ele é preservado.
- Se `sessionId` não existir, o campo não é gerado.
- MongoDB é suportado para cenários sem OCI Cache/Redis e usa operação atômica `find_one_and_update` com `$inc`, como na lib antiga.
- Redis continua suportado quando houver cache disponível, pois `INCR` é atômico entre workers/pods.
- O fallback em memória é apenas best-effort local para ambientes de desenvolvimento ou contingência.
### Sequence com MongoDB
Para ambientes do BO onde não existe OCI Cache/Redis dimensionado, configure:
```env
PUBSUB_SEQUENCE_ENABLED=true
PUBSUB_SEQUENCE_PROVIDER=mongodb
PUBSUB_SEQUENCE_MONGODB_URI=mongodb://<host>:27017
PUBSUB_SEQUENCE_MONGODB_DATABASE=agent_platform
PUBSUB_SEQUENCE_MONGODB_COLLECTION=${AGENT_NAME}_event_counters
PUBSUB_SEQUENCE_TTL_SECONDS=86400
PUBSUB_SEQUENCE_MEMORY_FALLBACK=true
```
O documento no Mongo usa `_id` igual à chave lógica:
```text
observer:sequence:<agentId>:<sessionId>
```
A atualização é atômica:
```python
find_one_and_update(
{"_id": key},
{"$inc": {"sequence": 1}},
upsert=True,
return_document=AFTER,
)
```
Também é criado, em best-effort, um índice TTL sobre `expiresAt`. Se o usuário Mongo não tiver permissão para criar índice, a geração de sequence continua funcionando; apenas a limpeza automática pode depender de rotina externa.
### Collection Mongo compatível com legado
Quando `PUBSUB_SEQUENCE_PROVIDER=mongodb`, a collection dos contadores pode ser informada explicitamente:
```env
PUBSUB_SEQUENCE_MONGODB_COLLECTION=telecom_contas_event_counters
```
Se essa variável não for definida, o framework usa o padrão legado:
```text
{AGENT_NAME}_event_counters
```
Também são aceitos, por compatibilidade operacional:
```env
MONGODB_EVENT_COUNTERS_COLLECTION=telecom_contas_event_counters
EVENT_COUNTERS_COLLECTION=telecom_contas_event_counters
```

View File

@@ -0,0 +1,84 @@
# Workflows transacionais determinísticos
## Objetivo
O framework passa a oferecer um executor genérico de transações multi-etapas usando LangGraph como detalhe interno. O LLM permanece responsável por interpretação, roteamento, clarification e preparação da confirmação. Depois da confirmação explícita, passos críticos podem ser executados por um grafo determinístico, auditável e versionado.
## Separação de responsabilidades
O framework fornece carregamento, validação, compilação, cache, execução, retry por nó e integração com `tool_policies.yaml`. O projeto do agente mantém os YAMLs do domínio e as actions que chamam APIs ou MCPs.
```text
LLM/router -> clarification -> transactional confirmation
-> WorkflowToolExecutor -> WorkflowRuntime/LangGraph
-> actions de domínio -> APIs/MCP
```
## Política
```yaml
tool_policies:
solicitar_devolucao:
operation_type: transactional
require_confirmation: true
requires: [order_id, reason]
execution:
mode: workflow
workflow: devolucao_pedido
version: active
```
`direct_tool` é o padrão e mantém compatibilidade. `workflow` ativa o executor determinístico. `agent` fica reservado para orquestrações não determinísticas explicitamente autorizadas.
## Arquivos e versionamento
```text
workflows/devolucao_pedido.active.yaml # version: 1
workflows/devolucao_pedido.v1.yaml # definição imutável
```
Uma execução resolve a versão ativa no início. Para reprodutibilidade, integrações persistentes devem guardar `workflow_name`, `workflow_version` e `execution_id`.
## Actions
```python
from agent_framework.workflows import workflow_action
@workflow_action("registrar_devolucao")
async def registrar_devolucao(params: dict, state: dict) -> dict:
return {"protocol": "...", "status": "REQUESTED"}
```
As actions devem ser idempotentes quando causarem efeitos externos. O framework aceita `retry` por nó, mas retry seguro depende de chave idempotente no serviço de destino.
## Condições suportadas
Cada edge aceita `path` JSON-like (`$.input...` ou `$.nodes...`) e um operador: `equals`, `not_equals`, `exists` ou `in`. Transições críticas não são escolhidas por LLM.
## Uso programático
```python
from agent_framework.workflows import FileWorkflowRepository, WorkflowRuntime
runtime = WorkflowRuntime(FileWorkflowRepository(settings.WORKFLOWS_PATH))
result = await runtime.arun("devolucao_pedido", payload)
```
Para integração com policy:
```python
from agent_framework.workflows import WorkflowToolExecutor
executor = WorkflowToolExecutor(runtime)
result = await executor.execute_from_policy(
tool_name=tool_name,
arguments=arguments,
policy=resolved_policy,
)
```
Quando o retorno for `None`, a aplicação continua pelo caminho legado `direct_tool`.
## Produção
Antes de habilitar em produção, configure checkpointer persistente, idempotência nas actions, autorização, timeout na camada de integração e telemetria com `transaction_id`, `workflow_execution_id`, versão, nó e tentativa. O runtime não transforma automaticamente uma API não idempotente em uma operação segura.

View File

@@ -0,0 +1,89 @@
# Optional file. If this file is absent, the backend keeps using .env exactly as before.
# If present, each inference point can override provider/model/params.
# Recommendation: set provider explicitly in every profile to avoid ambiguity.
profiles:
default:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
max_tokens: 2048
# Workflow/routing
supervisor:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
router:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 500
# Safety / evaluation
guardrail:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 600
grl:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
judge:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 800
# RAG
rag_rewriter:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 300
rag_compressor:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 1200
rag_generation:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.1
max_tokens: 1800
# Memory / operations
summary_memory:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.1
max_tokens: 1200
noc:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0
max_tokens: 700
# Agent-specific overrides
billing_agent:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
product_agent:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2
backoffice_agent:
provider: oci_openai
model: openai.gpt-4.1
temperature: 0.2

View File

@@ -0,0 +1,35 @@
[project]
name = "agent-framework"
version = "0.1.0"
description = "Framework de agentes LangGraph + OCI"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115.0",
"pydantic>=2.8.0",
"pydantic-settings>=2.4.0",
"python-dotenv>=1.0.1",
"httpx>=0.27.0",
"openai>=1.60.0",
"langfuse>=3.0.0",
"langgraph>=0.2.60",
"langchain-core>=0.3.0",
"oracledb>=2.4.0",
"pymongo>=4.8.0",
"redis>=5.0.0",
"oci>=2.130.0",
"opentelemetry-api>=1.25.0",
"opentelemetry-sdk>=1.25.0",
"PyYAML>=6.0.2",
"aiohttp>=3.9.0",
"motor>=3.6.0",
"google-cloud-pubsub>=2.28.0",
"mcp>=1.9.0",
"PyJWT[crypto]>=2.9.0"
]
[tool.setuptools.packages.find]
where = ["src"]
[build-system]
requires = ["setuptools>=80", "wheel>=0.45"]
build-backend = "setuptools.build_meta"

View File

@@ -0,0 +1,26 @@
Metadata-Version: 2.4
Name: agent-framework
Version: 0.1.0
Summary: Framework de agentes LangGraph + OCI
Requires-Python: >=3.11
Requires-Dist: fastapi>=0.115.0
Requires-Dist: pydantic>=2.8.0
Requires-Dist: pydantic-settings>=2.4.0
Requires-Dist: python-dotenv>=1.0.1
Requires-Dist: httpx>=0.27.0
Requires-Dist: openai>=1.60.0
Requires-Dist: langfuse>=3.0.0
Requires-Dist: langgraph>=0.2.60
Requires-Dist: langchain-core>=0.3.0
Requires-Dist: oracledb>=2.4.0
Requires-Dist: pymongo>=4.8.0
Requires-Dist: redis>=5.0.0
Requires-Dist: oci>=2.130.0
Requires-Dist: opentelemetry-api>=1.25.0
Requires-Dist: opentelemetry-sdk>=1.25.0
Requires-Dist: PyYAML>=6.0.2
Requires-Dist: aiohttp>=3.9.0
Requires-Dist: motor>=3.6.0
Requires-Dist: google-cloud-pubsub>=2.28.0
Requires-Dist: mcp>=1.9.0
Requires-Dist: PyJWT[crypto]>=2.9.0

View File

@@ -0,0 +1,207 @@
pyproject.toml
src/agent_framework/__init__.py
src/agent_framework/gateway_policy_context.py
src/agent_framework/observer.py
src/agent_framework/runtime_mcp_gateway_adapter.py
src/agent_framework.egg-info/PKG-INFO
src/agent_framework.egg-info/SOURCES.txt
src/agent_framework.egg-info/dependency_links.txt
src/agent_framework.egg-info/requires.txt
src/agent_framework.egg-info/top_level.txt
src/agent_framework/analytics/__init__.py
src/agent_framework/analytics/composite_publisher.py
src/agent_framework/analytics/event_builder.py
src/agent_framework/analytics/factory.py
src/agent_framework/analytics/publisher.py
src/agent_framework/analytics/tim_payload_mapper.py
src/agent_framework/analytics/tim_sequence.py
src/agent_framework/analytics/providers/__init__.py
src/agent_framework/analytics/providers/kafka.py
src/agent_framework/analytics/providers/langfuse.py
src/agent_framework/analytics/providers/oci_streaming.py
src/agent_framework/analytics/providers/pubsub.py
src/agent_framework/billing/__init__.py
src/agent_framework/billing/usage_repository.py
src/agent_framework/cache/__init__.py
src/agent_framework/cache/cache.py
src/agent_framework/channels/__init__.py
src/agent_framework/channels/adapters.py
src/agent_framework/channels/base.py
src/agent_framework/channels/gateway.py
src/agent_framework/checkpoints/__init__.py
src/agent_framework/checkpoints/checkpoint_repository.py
src/agent_framework/checkpoints/langgraph_saver.py
src/agent_framework/config/__init__.py
src/agent_framework/config/agent_registry.py
src/agent_framework/config/settings.py
src/agent_framework/events/__init__.py
src/agent_framework/events/oci_streaming.py
src/agent_framework/gateways/__init__.py
src/agent_framework/gateways/mcp_gateway_client.py
src/agent_framework/global_supervisor/__init__.py
src/agent_framework/global_supervisor/client.py
src/agent_framework/global_supervisor/config.py
src/agent_framework/global_supervisor/models.py
src/agent_framework/global_supervisor/router.py
src/agent_framework/global_supervisor/session_store.py
src/agent_framework/guardrails/__init__.py
src/agent_framework/guardrails/base.py
src/agent_framework/guardrails/config_loader.py
src/agent_framework/guardrails/custom_rails.py
src/agent_framework/guardrails/executor.py
src/agent_framework/guardrails/framework_llm_client.py
src/agent_framework/guardrails/langgraph_adapters.py
src/agent_framework/guardrails/llm_rails.py
src/agent_framework/guardrails/output_supervisor.py
src/agent_framework/guardrails/parallel_executor.py
src/agent_framework/guardrails/pipeline.py
src/agent_framework/guardrails/rail_action.py
src/agent_framework/guardrails/rail_decision.py
src/agent_framework/guardrails/rail_result.py
src/agent_framework/guardrails/rails.py
src/agent_framework/guardrails/calibrated/__init__.py
src/agent_framework/guardrails/calibrated/_compat.py
src/agent_framework/guardrails/calibrated/config.py
src/agent_framework/guardrails/calibrated/contestation_validation.py
src/agent_framework/guardrails/calibrated/contracts.py
src/agent_framework/guardrails/calibrated/input_size.py
src/agent_framework/guardrails/calibrated/llm_adapter.py
src/agent_framework/guardrails/calibrated/llm_client.py
src/agent_framework/guardrails/calibrated/llm_rails.py
src/agent_framework/guardrails/calibrated/output_sanitization.py
src/agent_framework/guardrails/calibrated/pipeline.py
src/agent_framework/guardrails/calibrated/prompts/__init__.py
src/agent_framework/guardrails/calibrated/prompts/_context.py
src/agent_framework/guardrails/calibrated/prompts/ausencia_oferta_proativa.py
src/agent_framework/guardrails/calibrated/prompts/dlex_in.py
src/agent_framework/guardrails/calibrated/prompts/dlex_out.py
src/agent_framework/guardrails/calibrated/prompts/fallback.py
src/agent_framework/guardrails/calibrated/prompts/out_of_scope.py
src/agent_framework/guardrails/calibrated/prompts/pinj.py
src/agent_framework/guardrails/calibrated/prompts/ragsec.py
src/agent_framework/guardrails/calibrated/prompts/revprec.py
src/agent_framework/guardrails/calibrated/prompts/safe_out.py
src/agent_framework/guardrails/calibrated/prompts/tox.py
src/agent_framework/guardrails/calibrated/prompts/toxicidade_output.py
src/agent_framework/guardrails/calibrated/prompts/shared/__init__.py
src/agent_framework/guardrails/calibrated/prompts/shared/supervision_template.py
src/agent_framework/guardrails/calibrated/prompts/shared/tts_rules.py
src/agent_framework/guardrails/calibrated/rails/__init__.py
src/agent_framework/guardrails/calibrated/rails/alcada.py
src/agent_framework/guardrails/calibrated/rails/anatel.py
src/agent_framework/guardrails/calibrated/rails/confirmation.py
src/agent_framework/guardrails/calibrated/rails/dlex_in.py
src/agent_framework/guardrails/calibrated/rails/dlex_out.py
src/agent_framework/guardrails/calibrated/rails/ragsec.py
src/agent_framework/guardrails/calibrated/rails/revprec.py
src/agent_framework/guardrails/calibrated/rails/tox.py
src/agent_framework/guardrails/calibrated/rails/supervision/__init__.py
src/agent_framework/guardrails/calibrated/rails/supervision/correspondencia_item.py
src/agent_framework/guardrails/calibrated/rails/supervision/groundedness.py
src/agent_framework/guardrails/calibrated/rails/supervision/intencao_cancelar.py
src/agent_framework/guardrails/calibrated/rails/supervision/quantidade_coerente.py
src/agent_framework/guardrails/calibrated/rails/supervision/servico_correto.py
src/agent_framework/guardrails/calibrated/rails/supervision/verbalizacao_prematura.py
src/agent_framework/guardrails/calibrated/rules/__init__.py
src/agent_framework/guardrails/calibrated/rules/alcada.py
src/agent_framework/guardrails/calibrated/rules/oos_blocklist.py
src/agent_framework/guardrails/calibrated/rules/pinj_patterns.py
src/agent_framework/guardrails/calibrated/rules/tox_blocklist.py
src/agent_framework/identity/__init__.py
src/agent_framework/identity/mcp_mapper.py
src/agent_framework/identity/models.py
src/agent_framework/identity/resolver.py
src/agent_framework/judges/__init__.py
src/agent_framework/judges/judge.py
src/agent_framework/judges/calibrated/__init__.py
src/agent_framework/judges/calibrated/_compat.py
src/agent_framework/judges/calibrated/llm_client.py
src/agent_framework/judges/calibrated/models.py
src/agent_framework/judges/calibrated/prompts/__init__.py
src/agent_framework/judges/calibrated/prompts/aluc.py
src/agent_framework/judges/calibrated/prompts/csi.py
src/agent_framework/judges/calibrated/prompts/fallback.py
src/agent_framework/judges/calibrated/prompts/rqlt.py
src/agent_framework/judges/calibrated/prompts/vctn.py
src/agent_framework/llm/__init__.py
src/agent_framework/llm/base.py
src/agent_framework/llm/profile_resolver.py
src/agent_framework/llm/providers.py
src/agent_framework/mcp/__init__.py
src/agent_framework/mcp/client.py
src/agent_framework/mcp/models.py
src/agent_framework/mcp/registry.py
src/agent_framework/mcp/tool_policy.py
src/agent_framework/mcp/tool_router.py
src/agent_framework/memory/__init__.py
src/agent_framework/memory/long_term_extractor.py
src/agent_framework/memory/long_term_memory.py
src/agent_framework/memory/long_term_models.py
src/agent_framework/memory/long_term_store.py
src/agent_framework/memory/message_history.py
src/agent_framework/memory/summary_memory.py
src/agent_framework/memory/summary_store.py
src/agent_framework/models/__init__.py
src/agent_framework/models/identity.py
src/agent_framework/models/session.py
src/agent_framework/observability/__init__.py
src/agent_framework/observability/context.py
src/agent_framework/observability/control_events.py
src/agent_framework/observability/decorators.py
src/agent_framework/observability/event_bus.py
src/agent_framework/observability/grl_events.py
src/agent_framework/observability/guardrail_events.py
src/agent_framework/observability/ic_events.py
src/agent_framework/observability/informational_events.py
src/agent_framework/observability/judge_events.py
src/agent_framework/observability/langfuse_enterprise.py
src/agent_framework/observability/langgraph_telemetry.py
src/agent_framework/observability/llm_advisors.py
src/agent_framework/observability/noc_contract.py
src/agent_framework/observability/noc_events.py
src/agent_framework/observability/noc_otel.py
src/agent_framework/observability/observer.py
src/agent_framework/observability/otel.py
src/agent_framework/observability/streaming_events.py
src/agent_framework/observability/streaming_exporter.py
src/agent_framework/observability/telemetry.py
src/agent_framework/observability/tim_backoffice_contract.py
src/agent_framework/observability/token_cost.py
src/agent_framework/observability/workflow_events.py
src/agent_framework/oci/__init__.py
src/agent_framework/oci/auth.py
src/agent_framework/persistence/__init__.py
src/agent_framework/persistence/mongodb_store.py
src/agent_framework/persistence/oracle_store.py
src/agent_framework/persistence/sqlite_store.py
src/agent_framework/rag/__init__.py
src/agent_framework/rag/embedding_provider.py
src/agent_framework/rag/graph_store.py
src/agent_framework/rag/ingest.py
src/agent_framework/rag/rag_service.py
src/agent_framework/rag/vector_store.py
src/agent_framework/repositories/__init__.py
src/agent_framework/repositories/session_repository.py
src/agent_framework/routing/__init__.py
src/agent_framework/routing/config_loader.py
src/agent_framework/routing/continuity.py
src/agent_framework/routing/enterprise_router.py
src/agent_framework/routing/models.py
src/agent_framework/runtime/__init__.py
src/agent_framework/runtime/agent_runtime.py
src/agent_framework/security/__init__.py
src/agent_framework/security/authentication.py
src/agent_framework/security/factory.py
src/agent_framework/security/installer.py
src/agent_framework/security/middleware.py
src/agent_framework/sse/__init__.py
src/agent_framework/sse/events.py
src/agent_framework/supervisor/__init__.py
src/agent_framework/supervisor/router_supervisor.py
src/agent_framework/supervisor/supervisor.py
src/agent_framework/workflows/__init__.py
src/agent_framework/workflows/models.py
src/agent_framework/workflows/registry.py
src/agent_framework/workflows/repository.py
src/agent_framework/workflows/runtime.py
src/agent_framework/workflows/tool_executor.py

View File

@@ -0,0 +1,21 @@
fastapi>=0.115.0
pydantic>=2.8.0
pydantic-settings>=2.4.0
python-dotenv>=1.0.1
httpx>=0.27.0
openai>=1.60.0
langfuse>=3.0.0
langgraph>=0.2.60
langchain-core>=0.3.0
oracledb>=2.4.0
pymongo>=4.8.0
redis>=5.0.0
oci>=2.130.0
opentelemetry-api>=1.25.0
opentelemetry-sdk>=1.25.0
PyYAML>=6.0.2
aiohttp>=3.9.0
motor>=3.6.0
google-cloud-pubsub>=2.28.0
mcp>=1.9.0
PyJWT[crypto]>=2.9.0

View File

@@ -0,0 +1 @@
agent_framework

View File

@@ -0,0 +1,4 @@
__all__ = ['settings']
from .config.settings import settings
from .idempotency import IdempotencyStore, InMemoryIdempotencyStore, create_idempotency_store

View File

@@ -0,0 +1,12 @@
from .publisher import AnalyticsPublisher, NoopAnalyticsPublisher
from .composite_publisher import CompositeAnalyticsPublisher
from .event_builder import build_analytics_event
from .factory import create_analytics_publisher
__all__ = [
"AnalyticsPublisher",
"NoopAnalyticsPublisher",
"CompositeAnalyticsPublisher",
"build_analytics_event",
"create_analytics_publisher",
]

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import asyncio
import logging
from typing import Any, Iterable
from .publisher import AnalyticsPublisher
logger = logging.getLogger("agent_framework.analytics.composite")
class CompositeAnalyticsPublisher(AnalyticsPublisher):
"""Publica o mesmo evento em múltiplos destinos.
Use para rodar OCI Streaming e Pub/Sub em paralelo durante transição,
homologação ou estratégia multi-cloud.
"""
def __init__(self, publishers: Iterable[AnalyticsPublisher], *, fail_silent: bool = True):
self.publishers = list(publishers)
self.fail_silent = fail_silent
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
if not self.publishers:
return
async def _safe_publish(publisher: AnalyticsPublisher) -> None:
try:
await publisher.publish(event_type, payload)
except Exception:
logger.exception("analytics.publisher_failed provider=%s event_type=%s", publisher.__class__.__name__, event_type)
if not self.fail_silent:
raise
await asyncio.gather(*[_safe_publish(p) for p in self.publishers])

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
def build_analytics_event(
event_type: str,
payload: dict[str, Any] | None = None,
*,
source: str = "agent_framework",
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Monta envelope uniforme para IC/NOC/GRL.
O campo metadata.noc=true é preservado para que o Observer consiga rotear
eventos também para NOC/OTEL/Elastic quando aplicável.
"""
body = dict(payload or {})
meta = dict(metadata or {})
return {
"eventType": event_type,
"source": source,
"eventDate": datetime.now(timezone.utc).isoformat(),
"payload": body,
"metadata": meta,
}

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
import logging
from typing import Any
from .composite_publisher import CompositeAnalyticsPublisher
from .publisher import AnalyticsPublisher, NoopAnalyticsPublisher
logger = logging.getLogger("agent_framework.analytics.factory")
def _split_csv(value: str | None) -> list[str]:
return [item.strip().lower() for item in (value or "").split(",") if item.strip()]
def create_analytics_publisher(settings: Any | None = None) -> AnalyticsPublisher:
"""Cria publisher conforme env/config.
Variáveis novas compatíveis:
- ENABLE_ANALYTICS=true|false
- ANALYTICS_PROVIDERS=oci_streaming,pubsub
- GCP_PUBSUB_TOPIC_PATH=projects/.../topics/...
- AGENT_PUBSUB_TOPIC=projects/.../topics/... # compatibilidade FIRST/TIM
- GCP_PROJECT_ID=... + GCP_PUBSUB_TOPIC=...
"""
if settings is None:
from agent_framework.config.settings import settings as default_settings
settings = default_settings
analytics_enabled = bool(getattr(settings, "ENABLE_ANALYTICS", False))
langfuse_enabled = bool(getattr(settings, "ENABLE_LANGFUSE", False))
# Historicamente o observer era usado para enviar IC/NOC/GRL ao Langfuse
# mesmo quando o pipeline de analytics/streaming não estava habilitado.
# Portanto, ENABLE_LANGFUSE=true também ativa o publisher Langfuse do observer.
if not analytics_enabled and not langfuse_enabled:
return NoopAnalyticsPublisher()
providers = _split_csv(getattr(settings, "ANALYTICS_PROVIDERS", "")) or ["oci_streaming"]
if langfuse_enabled and "langfuse" not in providers:
providers.insert(0, "langfuse")
# Se analytics geral estiver desligado, publica somente no Langfuse para
# evitar inicializar OCI Streaming/PubSub por engano em ambientes locais.
if not analytics_enabled:
providers = [p for p in providers if p in {"langfuse", "noop", "none"}] or ["langfuse"]
publishers: list[AnalyticsPublisher] = []
for provider in providers:
try:
if provider == "langfuse":
from .providers.langfuse import LangfuseAnalyticsPublisher
publishers.append(LangfuseAnalyticsPublisher(settings=settings))
elif provider == "oci_streaming":
from .providers.oci_streaming import OCIStreamingAnalyticsPublisher
publishers.append(OCIStreamingAnalyticsPublisher(settings=settings))
elif provider in {"pubsub", "gcp_pubsub", "gcp"}:
from .providers.pubsub import PubSubAnalyticsPublisher
topic = (
getattr(settings, "GCP_PUBSUB_TOPIC_PATH", None)
or getattr(settings, "AGENT_PUBSUB_TOPIC", None)
)
publishers.append(PubSubAnalyticsPublisher(topic_path=topic))
elif provider in {"noop", "none"}:
publishers.append(NoopAnalyticsPublisher())
else:
logger.warning("analytics.provider_ignored provider=%s", provider)
except Exception:
logger.exception("analytics.provider_init_failed provider=%s", provider)
if not publishers:
# Sem este log, "analytics ligado mas todos os providers falharam" fica
# indistinguivel de "analytics desligado": o publisher no-op descarta
# IC/NOC/GRL em silencio ate o processo ser reiniciado.
logger.error(
"analytics.no_publisher_available providers=%s enable_analytics=%s "
"enable_langfuse=%s; telemetria sera descartada ate o proximo restart",
",".join(providers),
analytics_enabled,
langfuse_enabled,
)
return NoopAnalyticsPublisher()
if len(publishers) == 1:
return publishers[0]
return CompositeAnalyticsPublisher(publishers)

View File

@@ -0,0 +1,11 @@
from .oci_streaming import OCIStreamingAnalyticsPublisher
from .pubsub import PubSubAnalyticsPublisher
from .kafka import KafkaAnalyticsPublisher
from .langfuse import LangfuseAnalyticsPublisher
__all__ = [
"OCIStreamingAnalyticsPublisher",
"PubSubAnalyticsPublisher",
"KafkaAnalyticsPublisher",
"LangfuseAnalyticsPublisher",
]

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
import json
from typing import Any
from agent_framework.analytics.publisher import AnalyticsPublisher
class KafkaAnalyticsPublisher(AnalyticsPublisher):
"""Publisher Kafka opcional.
Recebe um producer já criado para não acoplar o framework a uma lib específica
(confluent-kafka, aiokafka, kafka-python etc.). O producer precisa expor send
assíncrono ou síncrono.
"""
def __init__(self, producer: Any, topic: str):
self.producer = producer
self.topic = topic
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
message = json.dumps({"type": event_type, "payload": payload}, default=str).encode("utf-8")
result = self.producer.send(self.topic, key=event_type.encode("utf-8"), value=message)
if hasattr(result, "__await__"):
await result

View File

@@ -0,0 +1,429 @@
from __future__ import annotations
import hashlib
import logging
import os
import re
from typing import Any
from agent_framework.analytics.publisher import AnalyticsPublisher
try: # Avoid making analytics import fragile in old deployments.
from agent_framework.observability.context import get_current_observation_id, get_observability_context
except Exception: # pragma: no cover
get_observability_context = None # type: ignore
get_current_observation_id = None # type: ignore
logger = logging.getLogger("agent_framework.analytics.langfuse")
def _truthy(value: Any, default: bool = False) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
return str(value).strip().lower() in {"1", "true", "yes", "on", "y"}
def _safe_metadata(value: Any) -> Any:
"""Remove/mascara segredos antes de enviar metadata para Langfuse."""
if isinstance(value, dict):
out: dict[str, Any] = {}
for key, item in value.items():
lk = str(key).lower()
if any(token in lk for token in ("password", "secret", "token", "api_key", "authorization")):
out[key] = "***"
else:
out[key] = _safe_metadata(item)
return out
if isinstance(value, list):
return [_safe_metadata(item) for item in value]
return value
_LANGFUSE_TRACE_ID_RE = re.compile(r"^[0-9a-f]{32}$")
_INTERNAL_PREFIXES = ("IC.", "AGA.", "NOC.", "GRL.")
_TECHNICAL_PREFIXES = (
"langgraph.",
"mcp.",
"guardrail.",
"judge.",
"workflow.",
"rag.",
"cache.",
"checkpoint.",
)
def _clean_str(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _first(*values: Any) -> str | None:
for value in values:
text = _clean_str(value)
if text:
return text
return None
def _current_context() -> dict[str, Any]:
if get_observability_context is None:
return {}
try:
return get_observability_context().clean()
except Exception:
return {}
def _current_parent_observation_id() -> str | None:
if get_current_observation_id is None:
return None
try:
value = get_current_observation_id()
return str(value) if value else None
except Exception:
return None
def _is_internal_name(name: Any) -> bool:
text = _clean_str(name) or ""
return text.startswith(_INTERNAL_PREFIXES)
def _is_technical_name(name: Any) -> bool:
text = _clean_str(name) or ""
return text.startswith(_TECHNICAL_PREFIXES)
def _is_control_or_technical(name: Any) -> bool:
return _is_internal_name(name) or _is_technical_name(name)
def _extract_envelope_event_type(envelope: dict[str, Any]) -> str | None:
return _first(
envelope.get("eventType"),
envelope.get("event_type"),
envelope.get("name"),
envelope.get("type"),
)
def _is_wrapped_internal_event(event_type: str, envelope: dict[str, Any]) -> bool:
"""Detecta caso que gerava trace raiz errado.
Exemplo observado no Langfuse:
name=http.request.completed
input={"eventType": "NOC.006", ...}
output={"published": true}
Isso não é o trace real da request; é apenas o publisher de analytics
emitindo um envelope IC/NOC/GRL através de um evento técnico. Esse registro
deve ser suprimido para não poluir a tela Tracing -> Traces.
"""
envelope_event_type = _extract_envelope_event_type(envelope)
return bool(
envelope_event_type
and _is_internal_name(envelope_event_type)
and str(event_type) != envelope_event_type
and str(event_type).startswith(("http.request.", "gateway.", "telemetry."))
)
def _raw_correlation_id(metadata: dict[str, Any]) -> str | None:
# IMPORTANT: prefer request/trace ids over transaction/session ids. Using
# transaction/session as first choice created duplicate root traces for
# IC/NOC/GRL events while the HTTP trace used request_id.
value = (
metadata.get("traceId")
or metadata.get("trace_id")
or metadata.get("requestId")
or metadata.get("request_id")
or metadata.get("transactionId")
or metadata.get("transaction_id")
or metadata.get("sessionId")
or metadata.get("session_id")
)
return str(value) if value else None
def _langfuse_trace_id(value: Any) -> str | None:
"""Normaliza ids do framework/business para o formato aceito pelo Langfuse.
Langfuse SDK v3 exige 32 caracteres hex minúsculos. UUIDs com hífens são
compactados; ids de negócio/sessão viram hash md5 determinístico.
"""
if value is None:
return None
raw = str(value).strip().lower()
if not raw:
return None
compact = raw.replace("-", "")
if _LANGFUSE_TRACE_ID_RE.match(compact):
return compact
return hashlib.md5(raw.encode("utf-8")).hexdigest()
def _correlation_trace_id(metadata: dict[str, Any]) -> str | None:
return _langfuse_trace_id(_raw_correlation_id(metadata))
def _with_trace_context(kwargs: dict[str, Any], metadata: dict[str, Any]) -> dict[str, Any]:
raw_id = _raw_correlation_id(metadata)
trace_id = _langfuse_trace_id(raw_id)
parent_id = (
metadata.get("parent_observation_id")
or metadata.get("parent_span_id")
or kwargs.get("parent_observation_id")
or kwargs.get("parent_span_id")
or _current_parent_observation_id()
)
if trace_id:
trace_context = dict(kwargs.get("trace_context") or {})
trace_context.setdefault("trace_id", trace_id)
if parent_id:
trace_context.setdefault("parent_span_id", str(parent_id))
kwargs["trace_context"] = trace_context
meta = kwargs.setdefault("metadata", {})
if isinstance(meta, dict):
meta.setdefault("framework_trace_id", raw_id)
meta.setdefault("langfuse_trace_id", trace_id)
if parent_id:
meta.setdefault("parent_observation_id", str(parent_id))
return kwargs
def _allow_standalone_internal_events() -> bool:
# Default false: IC/NOC/GRL sem contexto de request não devem criar linhas
# soltas na tela principal de Traces. Habilite só para debug isolado.
return _truthy(os.getenv("LANGFUSE_ALLOW_STANDALONE_INTERNAL_EVENTS"), False)
class LangfuseAnalyticsPublisher(AnalyticsPublisher):
"""Publica eventos IC/NOC/GRL no Langfuse sem criar traces raiz duplicados.
Regra principal:
- 1 request/workflow = 1 trace raiz;
- IC/NOC/GRL e eventos técnicos entram como observations/spans dentro do
trace corrente;
- envelopes internos embrulhados em eventos HTTP/gateway não criam trace
próprio com output {"published": true}.
"""
def __init__(self, settings: Any | None = None, langfuse: Any | None = None):
self.settings = settings
self.langfuse = langfuse
self.enabled = True
if self.langfuse is not None:
return
if settings is None:
from agent_framework.config.settings import settings as default_settings
settings = default_settings
self.settings = settings
public_key = getattr(settings, "LANGFUSE_PUBLIC_KEY", None) or os.getenv("LANGFUSE_PUBLIC_KEY")
secret_key = getattr(settings, "LANGFUSE_SECRET_KEY", None) or os.getenv("LANGFUSE_SECRET_KEY")
host = getattr(settings, "LANGFUSE_HOST", None) or os.getenv("LANGFUSE_HOST") or "https://cloud.langfuse.com"
if not public_key or not secret_key:
self.enabled = False
logger.warning("LangfuseAnalyticsPublisher desabilitado: LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY ausentes")
return
try:
from langfuse import Langfuse # type: ignore
self.langfuse = Langfuse(public_key=public_key, secret_key=secret_key, host=host)
logger.info("LangfuseAnalyticsPublisher habilitado host=%s", host)
except Exception:
self.enabled = False
self.langfuse = None
logger.exception("Falha ao inicializar LangfuseAnalyticsPublisher")
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
if not self.enabled or self.langfuse is None:
return
event_type = str(event_type)
envelope = dict(payload or {})
# Prevent the exact pollution seen in Langfuse: http.request.completed
# traces whose input is a NOC/IC envelope and output is {published:true}.
if _is_wrapped_internal_event(event_type, envelope):
logger.debug(
"langfuse.analytics.skip_wrapped_internal event_type=%s envelope_event_type=%s",
event_type,
_extract_envelope_event_type(envelope),
)
return
body = envelope.get("payload") if isinstance(envelope.get("payload"), dict) else {}
metadata = envelope.get("metadata") if isinstance(envelope.get("metadata"), dict) else {}
ctx = _current_context()
source = envelope.get("source") or "agent_framework"
event_date = envelope.get("eventDate")
envelope_event_type = _extract_envelope_event_type(envelope)
effective_event_type = envelope_event_type if _is_internal_name(envelope_event_type) else event_type
# Correlation priority: current ObservabilityContext > payload metadata >
# transaction/session fallback. This keeps IC/NOC/GRL in the same HTTP trace.
correlation_request_id = _first(
ctx.get("request_id"),
ctx.get("trace_id"),
body.get("request_id"), metadata.get("request_id"),
body.get("requestId"), metadata.get("requestId"),
envelope.get("request_id"), envelope.get("requestId"),
)
correlation_trace_id = _first(
ctx.get("trace_id"),
ctx.get("request_id"),
body.get("trace_id"), metadata.get("trace_id"),
body.get("traceId"), metadata.get("traceId"),
correlation_request_id,
)
correlation_session_id = _first(
ctx.get("session_id"),
body.get("session_id"), metadata.get("session_id"),
body.get("sessionId"), metadata.get("sessionId"),
body.get("transaction_id"), metadata.get("transaction_id"),
body.get("transactionId"), metadata.get("transactionId"),
)
is_internal = _is_internal_name(effective_event_type)
is_technical = _is_technical_name(effective_event_type)
# IC/NOC/GRL without current/request correlation are usually emitted by
# background/legacy publishers. Do not create standalone trace rows unless
# explicitly requested for debugging.
if (is_internal or is_technical) and not correlation_trace_id and not _allow_standalone_internal_events():
logger.debug("langfuse.analytics.skip_unrelated_internal event_type=%s", effective_event_type)
return
langfuse_metadata = _safe_metadata({
"eventType": effective_event_type,
"original_event_type": event_type if event_type != effective_event_type else None,
"source": source,
"eventDate": event_date,
"payload": body,
"metadata": metadata,
"ic": _is_ic(str(effective_event_type), metadata),
"noc": _is_noc(str(effective_event_type), metadata),
"grl": _is_grl(str(effective_event_type), metadata),
"tag": body.get("tag") or metadata.get("tag") or effective_event_type,
"request_id": correlation_request_id,
"trace_id": correlation_trace_id,
"transaction_id": body.get("transaction_id") or metadata.get("transaction_id") or body.get("transactionId") or metadata.get("transactionId"),
"sessionId": correlation_session_id,
"session_id": correlation_session_id,
"messageId": body.get("messageId") or metadata.get("messageId") or body.get("message_id") or metadata.get("message_id") or ctx.get("message_id"),
"agentId": body.get("agentId") or metadata.get("agentId") or body.get("agent_id") or metadata.get("agent_id") or ctx.get("agent_id"),
"channelId": body.get("channelId") or metadata.get("channelId") or body.get("channel") or metadata.get("channel") or ctx.get("channel"),
"workflow_id": body.get("workflow_id") or metadata.get("workflow_id") or ctx.get("workflow_id"),
"tenant_id": body.get("tenant_id") or metadata.get("tenant_id") or ctx.get("tenant_id"),
"parent_observation_id": body.get("parent_observation_id") or metadata.get("parent_observation_id") or _current_parent_observation_id(),
})
# Keep correlation metadata on the trace, but do not turn every control
# event code into a trace tag. IC/NOC/GRL are represented by the child
# observation below; tags are not a substitute for the event span and
# high-cardinality event-code tags make the trace harder to inspect.
self._update_current_trace(langfuse_metadata)
# Prefer current/correlated observation API. For internal/technical events,
# do not fall back to standalone span/trace APIs if this fails.
try:
if hasattr(self.langfuse, "start_as_current_observation"):
kwargs = {
"name": str(effective_event_type),
"as_type": "span",
"input": envelope,
"metadata": langfuse_metadata,
}
# trace_context rebuilds the parent as a remote span (SDK cross-process
# propagation); skip it when a real span is already active locally.
if not _current_parent_observation_id():
kwargs = _with_trace_context(kwargs, langfuse_metadata)
try:
cm = self.langfuse.start_as_current_observation(**kwargs)
except (TypeError, ValueError):
kwargs.pop("trace_context", None)
cm = self.langfuse.start_as_current_observation(**kwargs)
with cm as observation:
_update_observation(observation, output={"published": True})
return
except Exception:
log = logger.warning if is_internal else logger.debug
log("Falha ao publicar Langfuse observation para %s", effective_event_type, exc_info=True)
if is_internal or is_technical:
return
if is_internal or is_technical:
return
# Legacy fallbacks only for non-internal, high-level events.
try:
trace_id = _correlation_trace_id(langfuse_metadata)
if trace_id and hasattr(self.langfuse, "trace"):
trace = self.langfuse.trace(
id=str(trace_id),
name=str(langfuse_metadata.get("request_id") or langfuse_metadata.get("sessionId") or "agent_framework.request"),
session_id=langfuse_metadata.get("sessionId"),
user_id=langfuse_metadata.get("user_id") or langfuse_metadata.get("userId"),
metadata={k: v for k, v in langfuse_metadata.items() if v is not None},
)
if hasattr(trace, "span"):
span = trace.span(name=str(effective_event_type), input=envelope, metadata=langfuse_metadata)
if hasattr(span, "end"):
span.end(output={"published": True})
return
except Exception:
logger.debug("Falha ao publicar Langfuse span correlacionado para %s", effective_event_type, exc_info=True)
try:
if hasattr(self.langfuse, "span"):
span = self.langfuse.span(name=str(effective_event_type), input=envelope, metadata=langfuse_metadata)
if hasattr(span, "end"):
span.end(output={"published": True})
return
except Exception:
logger.debug("Falha ao publicar Langfuse span legado para %s", effective_event_type, exc_info=True)
def _update_current_trace(self, metadata: dict[str, Any]) -> None:
try:
kwargs: dict[str, Any] = {
"metadata": {k: v for k, v in metadata.items() if v is not None},
}
session_id = metadata.get("sessionId") or metadata.get("session_id")
if session_id:
kwargs["session_id"] = str(session_id)
if hasattr(self.langfuse, "update_current_trace"):
self.langfuse.update_current_trace(**kwargs)
except Exception:
logger.debug("Langfuse update_current_trace ignorado", exc_info=True)
def _update_observation(observation: Any, **kwargs: Any) -> None:
if observation is None:
return
try:
if hasattr(observation, "update"):
observation.update(**{k: v for k, v in kwargs.items() if v is not None})
except Exception:
logger.debug("Langfuse observation update ignorado", exc_info=True)
def _is_noc(event_type: str, metadata: dict[str, Any]) -> bool:
return event_type.startswith("NOC.") or _truthy(metadata.get("noc"))
def _is_grl(event_type: str, metadata: dict[str, Any]) -> bool:
return event_type.startswith("GRL.") or _truthy(metadata.get("grl"))
def _is_ic(event_type: str, metadata: dict[str, Any]) -> bool:
return event_type.startswith(("IC.", "AGA.")) or _truthy(metadata.get("ic"))

View File

@@ -0,0 +1,28 @@
from __future__ import annotations
from typing import Any
from agent_framework.analytics.publisher import AnalyticsPublisher
from agent_framework.analytics.tim_sequence import ensure_sequence_envelope
class OCIStreamingAnalyticsPublisher(AnalyticsPublisher):
"""Adapter para reutilizar o publisher OCI Streaming existente do framework."""
def __init__(self, settings: Any | None = None, event_publisher: Any | None = None):
if event_publisher is not None:
self.event_publisher = event_publisher
else:
from agent_framework.config.settings import settings as default_settings
from agent_framework.events.oci_streaming import create_event_publisher
self.event_publisher = create_event_publisher(settings or default_settings)
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
# Carimba o contador de sequence no envelope antes do publish, espelhando o
# PubSubAnalyticsPublisher. Sem isto o path OCI Streaming sai sem sequence
# (a geração estava amarrada apenas ao Pub/Sub na migração do framework).
# ensure_sequence_envelope não quebra observabilidade: se faltar sessionId
# ou o backend do contador falhar, o evento segue sem o campo.
if isinstance(payload, dict):
payload = await ensure_sequence_envelope(payload)
await self.event_publisher.publish(event_type, payload)

View File

@@ -0,0 +1,111 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
from typing import Any
from agent_framework.analytics.tim_payload_mapper import map_analytics_event_to_tim_flat_payload
from agent_framework.analytics.tim_sequence import ensure_sequence
from agent_framework.analytics.publisher import AnalyticsPublisher
logger = logging.getLogger("agent_framework.analytics.pubsub")
class PubSubAnalyticsPublisher(AnalyticsPublisher):
"""Publisher GCP Pub/Sub real, compatível com FIRST/TIM.
Formas aceitas de configuração:
1. GCP_PUBSUB_TOPIC_PATH=projects/<project-id>/topics/<topic-id>
2. AGENT_PUBSUB_TOPIC=projects/<project-id>/topics/<topic-id>
3. GCP_PROJECT_ID=<project-id> + GCP_PUBSUB_TOPIC=<topic-id>
Credenciais seguem o padrão Google:
GOOGLE_APPLICATION_CREDENTIALS=/secrets/service-account.json
"""
def __init__(
self,
topic_path: str | None = None,
*,
project_id: str | None = None,
topic_id: str | None = None,
ordering_key: str | None = None,
timeout_seconds: float | None = None,
):
self.topic_path = self._resolve_topic_path(topic_path, project_id=project_id, topic_id=topic_id)
self.ordering_key = ordering_key or os.getenv("GCP_PUBSUB_ORDERING_KEY") or ""
self.timeout_seconds = float(timeout_seconds or os.getenv("GCP_PUBSUB_TIMEOUT_SECONDS") or 30)
self.payload_mode = (os.getenv("PUBSUB_PAYLOAD_MODE") or os.getenv("ANALYTICS_PUBSUB_PAYLOAD_MODE") or "flat").strip().lower()
self.exclude_noc = (os.getenv("PUBSUB_EXCLUDE_NOC") or "true").strip().lower() in {"1", "true", "yes", "y", "on"}
self.excluded_event_types = {
item.strip().upper()
for item in os.getenv("PUBSUB_EXCLUDED_EVENT_TYPES", "").split(",")
if item.strip()
}
from google.cloud import pubsub_v1 # type: ignore
self.client = pubsub_v1.PublisherClient()
@staticmethod
def _resolve_topic_path(topic_path: str | None, *, project_id: str | None, topic_id: str | None) -> str:
explicit = (
topic_path
or os.getenv("GCP_PUBSUB_TOPIC_PATH")
or os.getenv("AGENT_PUBSUB_TOPIC")
or os.getenv("PUBSUB_TOPIC_PATH")
)
if explicit:
explicit = explicit.strip()
if explicit.startswith("projects/"):
return explicit
# Permite passar só o nome do tópico quando project_id estiver disponível.
project = project_id or os.getenv("GCP_PROJECT_ID") or os.getenv("GOOGLE_CLOUD_PROJECT")
if project:
return f"projects/{project}/topics/{explicit}"
raise ValueError("topic_path deve estar no formato projects/<project-id>/topics/<topic-id> quando GCP_PROJECT_ID não está definido")
project = project_id or os.getenv("GCP_PROJECT_ID") or os.getenv("GOOGLE_CLOUD_PROJECT")
topic = topic_id or os.getenv("GCP_PUBSUB_TOPIC") or os.getenv("PUBSUB_TOPIC")
if project and topic:
return f"projects/{project}/topics/{topic}"
raise ValueError("Configure GCP_PUBSUB_TOPIC_PATH, AGENT_PUBSUB_TOPIC ou GCP_PROJECT_ID + GCP_PUBSUB_TOPIC")
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
event_key = str(event_type).upper()
if event_key in self.excluded_event_types:
logger.debug("analytics.pubsub.skipped_event event_type=%s", event_type)
return
metadata = payload.get("metadata") if isinstance(payload, dict) else None
is_noc = str(event_type).startswith("NOC.") or (isinstance(metadata, dict) and metadata.get("noc") is True)
if is_noc and self.exclude_noc:
logger.debug("analytics.pubsub.skipped_noc event_type=%s", event_type)
return
if self.payload_mode in {"legacy", "envelope", "wrapped"}:
message = {"type": event_type, "payload": payload}
else:
message = map_analytics_event_to_tim_flat_payload(event_type, payload, keep_none=False)
message = await ensure_sequence(message)
data = json.dumps(message, default=str, ensure_ascii=False).encode("utf-8")
attributes = {
"event_type": str(event_type),
"source": str(payload.get("source") or "agent_framework"),
}
if is_noc:
attributes["noc"] = "true"
kwargs: dict[str, Any] = dict(attributes)
if self.ordering_key:
kwargs["ordering_key"] = self.ordering_key
future = self.client.publish(self.topic_path, data=data, **kwargs)
await asyncio.to_thread(future.result, timeout=self.timeout_seconds)
logger.debug("analytics.pubsub.published event_type=%s topic=%s", event_type, self.topic_path)

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import Any
logger = logging.getLogger("agent_framework.analytics")
class AnalyticsPublisher(ABC):
"""Contrato único para eventos analíticos corporativos.
A intenção é desacoplar o agente de OCI Streaming, GCP Pub/Sub, Kafka,
BigQuery ou qualquer outro destino. Os agentes publicam eventos de negócio
ou operação usando apenas este contrato.
"""
@abstractmethod
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
raise NotImplementedError
class NoopAnalyticsPublisher(AnalyticsPublisher):
"""Publisher seguro para ambientes locais/testes."""
async def publish(self, event_type: str, payload: dict[str, Any]) -> None:
logger.info("analytics.noop event_type=%s payload_keys=%s", event_type, sorted(payload.keys()))

View File

@@ -0,0 +1,152 @@
from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import Any
def _first(mapping: dict[str, Any], *keys: str) -> Any:
for key in keys:
if key in mapping and mapping.get(key) is not None:
return mapping.get(key)
return None
def _as_list(value: Any) -> Any:
if value is None:
return None
if isinstance(value, list):
return value
if isinstance(value, (tuple, set)):
return list(value)
return [value]
def _collect_agent_specific_data(metadata: dict[str, Any], body: dict[str, Any]) -> dict[str, Any] | None:
prefixed: dict[str, Any] = {}
for source in (metadata, body):
for key, value in source.items():
if key.startswith("agentSpecificData."):
prefixed[key.removeprefix("agentSpecificData.")] = value
if prefixed:
return prefixed
direct = _first(metadata, "agentSpecificData")
if isinstance(direct, dict):
return dict(direct)
if isinstance(direct, str) and direct.strip():
try:
parsed = json.loads(direct)
if isinstance(parsed, dict):
return parsed
except (TypeError, ValueError, json.JSONDecodeError):
pass
direct = _first(body, "agentSpecificData")
if isinstance(direct, dict):
return dict(direct)
if isinstance(direct, str) and direct.strip():
try:
parsed = json.loads(direct)
if isinstance(parsed, dict):
return parsed
except (TypeError, ValueError, json.JSONDecodeError):
pass
return None
def map_analytics_event_to_tim_flat_payload(
event_type: str,
event: dict[str, Any],
*,
keep_none: bool = False,
) -> dict[str, Any]:
"""Map the framework analytics envelope to TIM's flat Pub/Sub/NOC schema.
The canonical fields are published at the JSON root. The only intentional
nested object is ``agentSpecificData``.
"""
if not isinstance(event, dict):
event = {}
body = event.get("payload") if isinstance(event.get("payload"), dict) else {}
metadata = event.get("metadata") if isinstance(event.get("metadata"), dict) else {}
data: dict[str, Any] = {**body, **metadata}
token_usage = event.get("token_usage") if isinstance(event.get("token_usage"), dict) else {}
payload: dict[str, Any] = {
# Tracking
"eventType": event.get("eventType") or event_type,
"traceId": _first(data, "traceId", "trace_id"),
"transactionId": _first(data, "transactionId", "transaction_id", "transactionID"),
"spanId": _first(data, "spanId", "span_id"),
"parentSpanId": _first(data, "parentSpanId", "parent_span_id"),
"eventName": _first(data, "eventName", "name"),
"version": _first(data, "version") or "1.0",
"eventDate": _first(data, "eventDate") or event.get("eventDate") or datetime.now(timezone.utc).isoformat(),
# Session/channel
"sessionId": _first(data, "sessionId", "session_id"),
"channelId": _first(data, "channelId", "channel", "channel_id"),
"agentId": _first(data, "agentId", "agent_id"),
"customerCode": _first(data, "customerCode", "customer_code"),
"touchpoint": _first(data, "touchpoint"),
"protocol": _first(data, "protocol"),
"tag": _first(data, "tag") or event.get("eventType") or event_type,
"noc": True if _first(data, "noc") is True else None,
# Protocol/session
"agentProtocolId": _first(data, "agentProtocolId", "agent_protocol_id"),
"adjustedProtocol": _first(data, "adjustedProtocol", "adjusted_protocol"),
"sessionCreatedAt": _first(data, "sessionCreatedAt", "session_created_at"),
"sessionEndAt": _first(data, "sessionEndAt", "session_end_at"),
# URA/voice
"uraCallId": _first(data, "uraCallId", "ura_call_id"),
"transcriptionId": _first(data, "transcriptionId", "transcription_id"),
"gsm": _first(data, "gsm"),
"ani": _first(data, "ani"),
"uraProtocolId": _first(data, "uraProtocolId", "ura_protocol_id"),
"uraLatency": _first(data, "uraLatency", "ura_latency"),
"uraResolution": _first(data, "uraResolution", "urResolution", "ura_resolution"),
"customerMessage": _first(data, "customerMessage", "customer_message"),
# Message/guardrails/analysis
"messageId": _first(data, "messageId", "message_id"),
"blockingGuardrailsOutput": _first(data, "blockingGuardrailsOutput", "blocking_guardrails_output"),
"blockingGuardrailsInput": _first(data, "blockingGuardrailsInput", "blocking_guardrails_input"),
"llmResponse": _first(data, "llmResponse", "llm_response"),
"alucinationScore": _first(data, "alucinationScore", "hallucinationScore", "alucination_score"),
"noMatchRag": _first(data, "noMatchRag", "no_match_rag"),
"promptLength": _first(data, "promptLength", "prompt_length"),
"intention": _first(data, "intention", "intent"),
"loop": _first(data, "loop"),
"inferredCsiScore": _first(data, "inferredCsiScore", "inferred_csi_score"),
"supervisorBlockReasons": _first(data, "supervisorBlockReasons", "supervisor_block_reasons"),
"resolution": _first(data, "resolution"),
"ConversationPrecision": _first(data, "ConversationPrecision", "conversationPrecision", "conversation_precision"),
# LLM metrics
"model": _first(data, "model") or event.get("model"),
"tokenInput": _first(token_usage, "input_tokens") or _first(data, "tokenInput", "input_tokens"),
"tokenOutput": _first(token_usage, "output_tokens") or _first(data, "tokenOutput", "output_tokens"),
"latencyMs": _first(data, "latencyMs", "duration_ms"),
"toxicityScore": _first(data, "toxicityScore", "toxicity_score"),
"nps": _first(data, "nps"),
"judgeScore": _first(data, "judgeScore", "judge_score"),
"accuracyScore": _first(data, "accuracyScore", "accuracy_score"),
"guardrails": _first(data, "guardrails"),
# RAG
"ragRetrievedDocuments": _as_list(_first(data, "documentsRetrieved", "ragRetrievedDocuments")),
"ragSelectedDocuments": _as_list(_first(data, "documentsSelected", "ragSelectedDocuments")),
# API
"apiUrl": _first(data, "apiUrl", "api_url"),
"apiStatusCode": _first(data, "httpStatusCode", "apiStatusCode", "http_status_code"),
"apiResponsePayload": _first(data, "apiResponsePayload", "api_response_payload"),
# I/O
"inputData": _first(data, "inputData", "input_data"),
"outputData": _first(data, "outputData", "output_data"),
# Business/status/sequence
"agentSpecificData": _collect_agent_specific_data(metadata, body),
"status": _first(data, "status"),
"sequence": _first(data, "sequence"),
}
if keep_none:
return {k: ("" if v is None else v) for k, v in payload.items()}
return {k: v for k, v in payload.items() if v is not None}

View File

@@ -0,0 +1,396 @@
from __future__ import annotations
import asyncio
import logging
import os
import threading
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
logger = logging.getLogger("agent_framework.analytics.tim_sequence")
# In-process fallback. This is not cross-process/global, but keeps telemetry alive
# when the configured shared sequence backend is unavailable, matching the
# framework principle that observability must not break business execution.
_memory_lock = threading.Lock()
_memory_counters: dict[str, int] = defaultdict(int)
SequenceProvider = Literal["auto", "redis", "mongodb", "mongo", "memory", "none"]
def _env_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
def sequence_enabled() -> bool:
return _env_bool("PUBSUB_SEQUENCE_ENABLED", True)
def _sequence_provider() -> SequenceProvider:
raw = (os.getenv("PUBSUB_SEQUENCE_PROVIDER") or "auto").strip().lower()
if raw in {"mongo"}:
return "mongodb"
if raw in {"auto", "redis", "mongodb", "memory", "none"}:
return raw # type: ignore[return-value]
logger.warning("tim_sequence.invalid_provider provider=%s; using auto", raw)
return "auto"
def _redis_url() -> str | None:
return os.getenv("PUBSUB_SEQUENCE_REDIS_URL") or os.getenv("REDIS_URL")
def _mongo_uri() -> str | None:
return (
os.getenv("PUBSUB_SEQUENCE_MONGODB_URI")
or os.getenv("MONGODB_URI")
or os.getenv("MONGO_URI")
)
def _mongo_database() -> str:
return (
os.getenv("PUBSUB_SEQUENCE_MONGODB_DATABASE")
or os.getenv("MONGODB_DATABASE")
or os.getenv("MONGO_DATABASE")
or "agent_platform"
)
def _legacy_agent_name() -> str:
return _safe_part(os.getenv("AGENT_NAME") or "agent", "agent")
def _mongo_collection() -> str:
"""Return the shared MongoDB collection used by every event producer.
The collection must not vary by agent. A transaction can emit GRL, AGA,
NOC and other events from different components, and all of them must
increment the same counter document. Deployments may override the name,
but the configured value must be identical in every producer/pod.
"""
return (
os.getenv("PUBSUB_SEQUENCE_MONGODB_COLLECTION")
or os.getenv("MONGODB_EVENT_COUNTERS_COLLECTION")
or os.getenv("EVENT_COUNTERS_COLLECTION")
or "observer_event_counters"
)
def _ttl_seconds() -> int:
raw = os.getenv("PUBSUB_SEQUENCE_TTL_SECONDS") or os.getenv("SESSION_TTL_SECONDS") or "86400"
try:
return max(0, int(raw))
except Exception:
return 86400
def _fallback_enabled() -> bool:
# An in-memory fallback creates duplicate sequences when multiple pods or
# event producers handle the same transaction. Keep it opt-in only for
# local/single-process development.
return _env_bool("PUBSUB_SEQUENCE_MEMORY_FALLBACK", False)
def _key_prefix() -> str:
return os.getenv("PUBSUB_SEQUENCE_KEY_PREFIX") or "observer:sequence"
def _safe_part(value: Any, fallback: str) -> str:
text = str(value or fallback).strip()
return text.replace(" ", "_").replace("/", "_").replace("\\", "_")
def build_sequence_key(
agent_id: str | None,
session_id: str | None,
transaction_id: str | None = None,
) -> str:
"""Build one counter key for the whole transaction.
``agent_id`` is intentionally ignored for transaction-scoped counters.
A single transaction may emit events from different agents/components
(for example GRL and AGA), and those events must share one monotonic
sequence. ``session_id`` is retained only as a compatibility fallback when
no transaction identifier is present.
"""
if transaction_id:
transaction = _safe_part(transaction_id, "unknown_transaction")
return f"{_key_prefix()}:transaction:{transaction}"
# Legacy fallback. Including the agent here avoids changing old session-only
# behavior, but new integrations should always provide transactionId.
agent = _safe_part(agent_id or os.getenv("AGENT_NAME"), "agent")
session = _safe_part(session_id, "unknown_session")
return f"{_key_prefix()}:{agent}:session:{session}"
async def _next_sequence_redis(key: str, ttl_seconds: int) -> int | None:
url = _redis_url()
if not url:
return None
try:
import redis.asyncio as redis_async # type: ignore
client = redis_async.Redis.from_url(url, decode_responses=True)
try:
value = await client.incr(key)
if ttl_seconds > 0 and value == 1:
await client.expire(key, ttl_seconds)
return int(value)
finally:
try:
await client.aclose()
except AttributeError: # redis-py older compatibility
await client.close()
except Exception:
logger.exception("tim_sequence.redis_failed key=%s", key)
return None
_mongo_index_checked = False
_mongo_index_lock = threading.Lock()
def _next_sequence_mongodb_sync(
key: str,
agent_id: str | None,
session_id: str | None,
transaction_id: str | None,
ttl_seconds: int,
) -> int | None:
uri = _mongo_uri()
if not uri:
return None
from pymongo import MongoClient, ReturnDocument # type: ignore
client = MongoClient(uri)
try:
collection = client[_mongo_database()][_mongo_collection()]
now = datetime.now(timezone.utc)
expires_at = now + timedelta(seconds=ttl_seconds) if ttl_seconds > 0 else None
# update: dict[str, Any] = {
# "$inc": {"sequence": 1},
# "$set": {
# "agentId": agent_id or os.getenv("AGENT_NAME") or "agent",
# "sessionId": session_id,
# "transactionId": transaction_id,
# "sequenceScope": "transaction" if transaction_id else "session",
# "updatedAt": now,
# },
# "$setOnInsert": {
# "_id": key,
# "createdAt": now,
# },
# }
update: dict[str, Any] = {
"$inc": {"sequence": 1},
"$set": {
"agentId": agent_id or os.getenv("AGENT_NAME") or "agent",
"sessionId": session_id,
"transactionId": transaction_id,
"sequenceScope": "transaction" if transaction_id else "session",
"updatedAt": now,
},
"$setOnInsert": {
"createdAt": now,
},
}
if expires_at is not None:
update["$set"]["expiresAt"] = expires_at
doc = collection.find_one_and_update(
{"_id": key},
update,
upsert=True,
return_document=ReturnDocument.AFTER,
)
if not doc:
return None
return int(doc.get("sequence", 0))
finally:
client.close()
def _ensure_mongo_ttl_index_once_sync(ttl_seconds: int) -> None:
"""Best-effort TTL index initialization, safe across threads/event loops.
``asyncio.Lock`` must not be shared by independent event loops. Observer
compatibility calls may originate in worker threads, so this one-time
process-local guard deliberately uses ``threading.Lock``. The blocking
Mongo operation is executed by the async wrapper in a worker thread.
"""
global _mongo_index_checked
if _mongo_index_checked or ttl_seconds <= 0 or not _mongo_uri():
return
with _mongo_index_lock:
if _mongo_index_checked:
return
try:
from pymongo import MongoClient # type: ignore
client = MongoClient(_mongo_uri())
try:
collection = client[_mongo_database()][_mongo_collection()]
collection.create_index("expiresAt", expireAfterSeconds=0, background=True)
finally:
client.close()
except Exception:
logger.warning("tim_sequence.mongodb_ttl_index_failed", exc_info=True)
finally:
# The index is an observability housekeeping concern, not a
# prerequisite for sequence generation. Do not retry on every
# event if the application user lacks index privileges.
_mongo_index_checked = True
async def _ensure_mongo_ttl_index_once(ttl_seconds: int) -> None:
await asyncio.to_thread(_ensure_mongo_ttl_index_once_sync, ttl_seconds)
async def _next_sequence_mongodb(
key: str,
agent_id: str | None,
session_id: str | None,
transaction_id: str | None,
ttl_seconds: int,
) -> int | None:
if not _mongo_uri():
return None
try:
await _ensure_mongo_ttl_index_once(ttl_seconds)
return await asyncio.to_thread(
_next_sequence_mongodb_sync,
key,
agent_id,
session_id,
transaction_id,
ttl_seconds,
)
except Exception:
logger.exception("tim_sequence.mongodb_failed key=%s", key)
return None
async def _next_sequence_memory(key: str) -> int:
# Tiny in-process critical section; a thread lock is intentional because
# this fallback can be reached from more than one asyncio event loop.
with _memory_lock:
_memory_counters[key] += 1
return _memory_counters[key]
async def next_sequence(
agent_id: str | None,
session_id: str | None,
transaction_id: str | None = None,
) -> int | None:
"""Return the next observer sequence isolated by transaction.
The preferred scope is only ``transaction_id``. Agent/event family must
never participate in the key because one transaction can emit events from
several components. ``session_id`` is used only as a backward-compatible
fallback. Redis and MongoDB increments remain atomic across replicas.
"""
if not sequence_enabled() or (not transaction_id and not session_id):
return None
provider = _sequence_provider()
if provider == "none":
return None
key = build_sequence_key(agent_id, session_id, transaction_id)
ttl_seconds = _ttl_seconds()
value: int | None = None
if provider == "memory":
return await _next_sequence_memory(key)
if provider == "redis":
value = await _next_sequence_redis(key, ttl_seconds)
elif provider == "mongodb":
value = await _next_sequence_mongodb(
key, agent_id, session_id, transaction_id, ttl_seconds
)
else: # auto
if _redis_url():
value = await _next_sequence_redis(key, ttl_seconds)
if value is None and _mongo_uri():
value = await _next_sequence_mongodb(
key, agent_id, session_id, transaction_id, ttl_seconds
)
if value is not None:
return value
if _fallback_enabled():
return await _next_sequence_memory(key)
return None
async def ensure_sequence(payload: dict[str, Any]) -> dict[str, Any]:
"""Inject sequence if missing, preserving explicit values from metadata/body.
Used by the flat Pub/Sub schema, where sessionId/agentId sit at the root.
For the nested analytics envelope (OCI Streaming) use
:func:`ensure_sequence_envelope`.
"""
if not isinstance(payload, dict):
return payload
if payload.get("sequence") is not None:
return payload
session_id = payload.get("sessionId") or payload.get("session_id")
transaction_id = (
payload.get("transactionId")
or payload.get("transaction_id")
or payload.get("transactionID")
)
agent_id = payload.get("agentId") or payload.get("agent_id") or os.getenv("AGENT_NAME")
seq = await next_sequence(agent_id, session_id, transaction_id)
if seq is not None:
payload["sequence"] = seq
return payload
async def ensure_sequence_envelope(event: dict[str, Any]) -> dict[str, Any]:
"""Inject sequence into a ``build_analytics_event`` envelope.
The envelope shape is ``{eventType, source, eventDate, payload, metadata}``.
Unlike the flat Pub/Sub payload, sessionId/agentId are not at the root: they
live inside ``payload`` and/or ``metadata``. We read them from the merged
``{**payload, **metadata}`` view, mirroring the flat mapper
(tim_payload_mapper.map_analytics_event_to_tim_flat_payload) and the legacy
observer (observer/api.py: metadata.sessionId -> sessionId).
The counter is written at the envelope root, as a sibling of ``eventType`` —
the faithful analog of the legacy flat payload where ``sequence`` sat next to
``eventType``/``traceId``. The outer transport contract ``{type, payload}`` is
left untouched; only this inner field is added.
"""
if not isinstance(event, dict):
return event
if event.get("sequence") is not None:
return event
body = event.get("payload") if isinstance(event.get("payload"), dict) else {}
metadata = event.get("metadata") if isinstance(event.get("metadata"), dict) else {}
data = {**body, **metadata}
session_id = data.get("sessionId") or data.get("session_id")
# Os adapters do BO emitem snake_case; o contrato TIM usa transactionId e
# payloads antigos trazem transactionID. Sem as tres grafias o contador cai
# em escopo de sessao e perde o isolamento por transacao.
transaction_id = (
data.get("transactionId")
or data.get("transaction_id")
or data.get("transactionID")
)
agent_id = data.get("agentId") or data.get("agent_id") or os.getenv("AGENT_NAME")
seq = await next_sequence(agent_id, session_id, transaction_id)
if seq is not None:
event["sequence"] = seq
return event

View File

@@ -0,0 +1 @@
from .usage_repository import UsageRecord, UsageRepository, SQLiteUsageRepository, OracleUsageRepository, create_usage_repository

View File

@@ -0,0 +1,173 @@
from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from typing import Any
from agent_framework.observability.context import get_observability_context
@dataclass
class UsageRecord:
provider: str
model: str
operation: str
prompt_tokens: int = 0
completion_tokens: int = 0
cached_tokens: int = 0
total_tokens: int = 0
cost_usd: float = 0.0
cost_brl: float = 0.0
metadata: dict[str, Any] | None = None
request_id: str | None = None
session_id: str | None = None
tenant_id: str | None = None
agent_id: str | None = None
user_id: str | None = None
message_id: str | None = None
created_at: str | None = None
@classmethod
def from_usage(cls, provider: str, model: str, operation: str, usage: dict[str, Any], metadata: dict[str, Any] | None = None) -> "UsageRecord":
ctx = get_observability_context()
return cls(
provider=provider, model=model, operation=operation,
prompt_tokens=int(usage.get("prompt_tokens") or 0),
completion_tokens=int(usage.get("completion_tokens") or 0),
cached_tokens=int(usage.get("cached_tokens") or 0),
total_tokens=int(usage.get("total_tokens") or 0),
cost_usd=float(usage.get("cost_usd") or 0),
cost_brl=float(usage.get("cost_brl") or 0),
metadata=metadata or {}, request_id=ctx.request_id, session_id=ctx.session_id,
tenant_id=ctx.tenant_id, agent_id=ctx.agent_id, user_id=ctx.user_id,
message_id=ctx.message_id, created_at=datetime.now(timezone.utc),
)
def model_dump(self) -> dict[str, Any]:
return asdict(self)
class UsageRepository:
async def record(self, usage: UsageRecord) -> None: ...
async def summarize(self, *, tenant_id: str | None = None, session_id: str | None = None) -> dict[str, Any]: ...
class SQLiteUsageRepository(UsageRepository):
def __init__(self, settings):
from agent_framework.persistence.sqlite_store import SQLiteStore
self.store = SQLiteStore(settings.SQLITE_DB_PATH)
self._init_schema()
def _init_schema(self):
ddl = """
create table if not exists llm_usage_records (
id integer primary key autoincrement,
request_id text, session_id text, tenant_id text, agent_id text, user_id text, message_id text,
provider text not null, model text not null, operation text not null,
prompt_tokens integer not null default 0,
completion_tokens integer not null default 0,
cached_tokens integer not null default 0,
total_tokens integer not null default 0,
cost_usd real not null default 0,
cost_brl real not null default 0,
metadata_json text,
created_at text not null
);
create index if not exists idx_usage_tenant_created on llm_usage_records(tenant_id, created_at);
create index if not exists idx_usage_session_created on llm_usage_records(session_id, created_at);
"""
with self.store._lock, self.store.connect() as con:
con.executescript(ddl)
async def record(self, usage: UsageRecord) -> None:
with self.store._lock, self.store.connect() as con:
con.execute("""
insert into llm_usage_records(
request_id,session_id,tenant_id,agent_id,user_id,message_id,
provider,model,operation,prompt_tokens,completion_tokens,cached_tokens,total_tokens,
cost_usd,cost_brl,metadata_json,created_at
) values(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
usage.request_id, usage.session_id, usage.tenant_id, usage.agent_id, usage.user_id, usage.message_id,
usage.provider, usage.model, usage.operation, usage.prompt_tokens, usage.completion_tokens,
usage.cached_tokens, usage.total_tokens, usage.cost_usd, usage.cost_brl,
json.dumps(usage.metadata or {}, ensure_ascii=False, default=str), usage.created_at,
))
async def summarize(self, *, tenant_id: str | None = None, session_id: str | None = None) -> dict[str, Any]:
where=[]; params=[]
if tenant_id: where.append('tenant_id=?'); params.append(tenant_id)
if session_id: where.append('session_id=?'); params.append(session_id)
sql="""select count(*) calls, coalesce(sum(prompt_tokens),0) prompt_tokens,
coalesce(sum(completion_tokens),0) completion_tokens,
coalesce(sum(total_tokens),0) total_tokens,
coalesce(sum(cost_usd),0) cost_usd,
coalesce(sum(cost_brl),0) cost_brl
from llm_usage_records"""
if where: sql += ' where ' + ' and '.join(where)
with self.store._lock, self.store.connect() as con:
row=con.execute(sql, params).fetchone()
return dict(row) if row else {"calls":0,"prompt_tokens":0,"completion_tokens":0,"total_tokens":0,"cost_usd":0,"cost_brl":0}
class OracleUsageRepository(UsageRepository):
def __init__(self, settings):
from agent_framework.persistence.oracle_store import OracleStore
self.store = OracleStore(settings)
self._init_schema()
def _init_schema(self):
with self.store.connect() as conn:
cur=conn.cursor()
self.store._exec_ddl_ignore_exists(cur, f"""
create table {self.store.t('LLM_USAGE_RECORD')} (
ID number generated always as identity primary key,
REQUEST_ID varchar2(128), SESSION_ID varchar2(256), TENANT_ID varchar2(128),
AGENT_ID varchar2(128), USER_ID varchar2(256), MESSAGE_ID varchar2(256),
PROVIDER varchar2(128) not null, MODEL varchar2(256) not null, OPERATION varchar2(128) not null,
PROMPT_TOKENS number default 0, COMPLETION_TOKENS number default 0, CACHED_TOKENS number default 0,
TOTAL_TOKENS number default 0, COST_USD number default 0, COST_BRL number default 0,
METADATA_JSON clob check (METADATA_JSON is json), CREATED_AT timestamp with time zone not null
)
""")
self.store._exec_ddl_ignore_exists(cur, f"create index {self.store.t('IX_USAGE_TENANT')} on {self.store.t('LLM_USAGE_RECORD')}(TENANT_ID, CREATED_AT)")
self.store._exec_ddl_ignore_exists(cur, f"create index {self.store.t('IX_USAGE_SESSION')} on {self.store.t('LLM_USAGE_RECORD')}(SESSION_ID, CREATED_AT)")
async def record(self, usage: UsageRecord) -> None:
await asyncio.to_thread(self._record_sync, usage)
def _record_sync(self, usage: UsageRecord):
with self.store.connect() as conn:
conn.cursor().execute(f"""
insert into {self.store.t('LLM_USAGE_RECORD')}(
REQUEST_ID,SESSION_ID,TENANT_ID,AGENT_ID,USER_ID,MESSAGE_ID,PROVIDER,MODEL,OPERATION,
PROMPT_TOKENS,COMPLETION_TOKENS,CACHED_TOKENS,TOTAL_TOKENS,COST_USD,COST_BRL,METADATA_JSON,CREATED_AT
) values(:1,:2,:3,:4,:5,:6,:7,:8,:9,:10,:11,:12,:13,:14,:15,:16,:17)
""", [
usage.request_id, usage.session_id, usage.tenant_id, usage.agent_id, usage.user_id, usage.message_id,
usage.provider, usage.model, usage.operation, usage.prompt_tokens, usage.completion_tokens, usage.cached_tokens,
usage.total_tokens, usage.cost_usd, usage.cost_brl, json.dumps(usage.metadata or {}, ensure_ascii=False, default=str), usage.created_at,
])
async def summarize(self, *, tenant_id: str | None = None, session_id: str | None = None) -> dict[str, Any]:
return await asyncio.to_thread(self._summarize_sync, tenant_id, session_id)
def _summarize_sync(self, tenant_id, session_id):
where=[]; params={}
if tenant_id: where.append('TENANT_ID=:tenant_id'); params['tenant_id']=tenant_id
if session_id: where.append('SESSION_ID=:session_id'); params['session_id']=session_id
sql=f"""select count(*) CALLS, coalesce(sum(PROMPT_TOKENS),0) PROMPT_TOKENS,
coalesce(sum(COMPLETION_TOKENS),0) COMPLETION_TOKENS,
coalesce(sum(TOTAL_TOKENS),0) TOTAL_TOKENS,
coalesce(sum(COST_USD),0) COST_USD,
coalesce(sum(COST_BRL),0) COST_BRL
from {self.store.t('LLM_USAGE_RECORD')}"""
if where: sql += ' where ' + ' and '.join(where)
with self.store.connect() as conn:
cur=conn.cursor(); cur.execute(sql, params); row=cur.fetchone()
cols=[d[0].lower() for d in cur.description]
return dict(zip(cols,row)) if row else {}
def create_usage_repository(settings) -> UsageRepository:
provider = getattr(settings, 'USAGE_REPOSITORY_PROVIDER', None) or getattr(settings, 'MEMORY_REPOSITORY_PROVIDER', 'memory')
if provider in {'autonomous','oracle'}:
return OracleUsageRepository(settings)
return SQLiteUsageRepository(settings)

View File

@@ -0,0 +1,184 @@
from __future__ import annotations
import asyncio
import json
import logging
import time
from datetime import datetime, timezone, timedelta
from typing import Any
logger = logging.getLogger("agent_framework.cache")
class Cache:
async def get(self, key: str) -> Any | None: ...
async def set(self, key: str, value: Any, ttl_seconds: int | None = None) -> None: ...
async def delete(self, key: str) -> None: ...
class InMemoryCache(Cache):
def __init__(self):
self._data: dict[str, tuple[Any, float | None]] = {}
self._lock = asyncio.Lock()
async def get(self, key):
async with self._lock:
item = self._data.get(key)
if not item:
return None
value, expires = item
if expires and expires < time.time():
self._data.pop(key, None)
return None
return value
async def set(self, key, value, ttl_seconds=None):
async with self._lock:
self._data[key] = (value, time.time() + ttl_seconds if ttl_seconds else None)
async def delete(self, key):
async with self._lock:
self._data.pop(key, None)
class RedisCache(Cache):
"""Redis L2 cache with redis-py sync/async compatibility and safe fallback."""
def __init__(self, settings):
self.url = settings.REDIS_URL
self.prefix = getattr(settings, "CACHE_KEY_PREFIX", "agentfw")
self._async = False
try:
import redis.asyncio as redis_async
self.client = redis_async.Redis.from_url(self.url, decode_responses=True)
self._async = True
except Exception:
import redis
self.client = redis.Redis.from_url(self.url, decode_responses=True)
def _key(self, key: str) -> str:
return f"{self.prefix}:{key}"
async def get(self, key):
try:
raw = await self.client.get(self._key(key)) if self._async else await asyncio.to_thread(self.client.get, self._key(key))
return json.loads(raw) if raw else None
except Exception:
logger.exception("Redis GET falhou key=%s", key)
return None
async def set(self, key, value, ttl_seconds=None):
raw = json.dumps(value, ensure_ascii=False, default=str)
try:
if self._async:
await self.client.set(self._key(key), raw, ex=ttl_seconds)
else:
await asyncio.to_thread(self.client.set, self._key(key), raw, ex=ttl_seconds)
except Exception:
logger.exception("Redis SET falhou key=%s", key)
async def delete(self, key):
try:
if self._async:
await self.client.delete(self._key(key))
else:
await asyncio.to_thread(self.client.delete, self._key(key))
except Exception:
logger.exception("Redis DELETE falhou key=%s", key)
class SQLiteCache(Cache):
def __init__(self, settings):
from agent_framework.persistence.sqlite_store import SQLiteStore
self.store = SQLiteStore(settings.SQLITE_DB_PATH)
async def get(self, key):
return await asyncio.to_thread(self._get_sync, key)
def _get_sync(self, key):
with self.store._lock, self.store.connect() as con:
row = con.execute("select value_json, expires_at from cache_entries where key=?", (key,)).fetchone()
if not row:
return None
if row["expires_at"] and row["expires_at"] < time.time():
con.execute("delete from cache_entries where key=?", (key,))
return None
return json.loads(row["value_json"])
async def set(self, key, value, ttl_seconds=None):
await asyncio.to_thread(self._set_sync, key, value, ttl_seconds)
def _set_sync(self, key, value, ttl_seconds=None):
expires = time.time() + ttl_seconds if ttl_seconds else None
with self.store._lock, self.store.connect() as con:
con.execute(
"insert or replace into cache_entries(key,value_json,expires_at,created_at) values(?,?,?,?)",
(key, json.dumps(value, ensure_ascii=False, default=str), expires, self.store.now()),
)
async def delete(self, key):
await asyncio.to_thread(self._delete_sync, key)
def _delete_sync(self, key):
with self.store._lock, self.store.connect() as con:
con.execute("delete from cache_entries where key=?", (key,))
class OracleCache(Cache):
def __init__(self, settings):
from agent_framework.persistence.oracle_store import OracleStore
self.store = OracleStore(settings)
async def get(self, key): return await self.store.cache_get(key)
async def set(self, key, value, ttl_seconds=None):
expires = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds) if ttl_seconds else None
await self.store.cache_set(key, value, expires_at=expires)
async def delete(self, key): await self.store.cache_delete(key)
class DistributedCache(Cache):
"""L1 memory + optional L2 Redis/SQLite/Oracle with telemetry hooks."""
def __init__(self, l1: Cache, l2: Cache | None = None, telemetry=None, default_ttl: int | None = None):
self.l1, self.l2, self.telemetry, self.default_ttl = l1, l2, telemetry, default_ttl
async def get(self, key):
v = await self.l1.get(key)
if v is not None:
if self.telemetry: await self.telemetry.cache_event("hit.l1", key, True)
return v
if not self.l2:
if self.telemetry: await self.telemetry.cache_event("miss", key, False)
return None
v = await self.l2.get(key)
if v is not None:
await self.l1.set(key, v, self.default_ttl)
if self.telemetry: await self.telemetry.cache_event("hit.l2", key, True)
return v
if self.telemetry: await self.telemetry.cache_event("miss", key, False)
return None
async def set(self, key, value, ttl_seconds=None):
ttl = ttl_seconds if ttl_seconds is not None else self.default_ttl
await self.l1.set(key, value, ttl)
if self.l2: await self.l2.set(key, value, ttl)
if self.telemetry: await self.telemetry.cache_event("set", key, None, {"ttl_seconds": ttl})
async def delete(self, key):
await self.l1.delete(key)
if self.l2: await self.l2.delete(key)
if self.telemetry: await self.telemetry.cache_event("delete", key, None)
def create_cache(settings, telemetry=None):
l1 = InMemoryCache()
l2 = None
if getattr(settings, "ENABLE_REDIS_CACHE", False):
try:
l2 = RedisCache(settings)
except Exception:
logger.exception("Redis indisponível; cache seguirá apenas com L1 memória")
l2 = None
if l2 is None:
provider = getattr(settings, "CACHE_BACKEND_PROVIDER", "memory")
if provider == "sqlite": l2 = SQLiteCache(settings)
elif provider in {"autonomous", "oracle"}: l2 = OracleCache(settings)
return DistributedCache(l1, l2, telemetry=telemetry, default_ttl=getattr(settings, "CACHE_TTL_SECONDS", None))

View File

@@ -0,0 +1,69 @@
from .base import ChannelAdapter, ChannelMessage, ChannelResponse
def _merge_context(payload: dict) -> dict:
"""Preserva todo payload como contexto.
Antes o WebAdapter só copiava payload["context"]. Com isso, campos como
business_context, msisdn, invoice_id e ura_call_id eram perdidos antes de
chegar ao workflow/MCP.
"""
payload = dict(payload or {})
ctx = dict(payload.get("context") or {})
for k, v in payload.items():
if k != "context" and k not in ctx:
ctx[k] = v
return ctx
class WebAdapter(ChannelAdapter):
name = "web"
async def normalize(self, payload):
payload = payload or {}
text = payload.get("message") or payload.get("text") or payload.get("content") or ""
return ChannelMessage(
channel="web",
text=text,
session_id=payload.get("session_id"),
user_id=payload.get("user_id"),
channel_id=payload.get("channel_id") or payload.get("channelId"),
context=_merge_context(payload),
)
async def render(self, response):
return response.model_dump()
class WhatsAppAdapter(ChannelAdapter):
name = "whatsapp"
async def normalize(self, payload):
payload = payload or {}
return ChannelMessage(
channel="whatsapp",
channel_id=payload.get("from"),
text=payload.get("text") or payload.get("message") or "",
session_id=payload.get("session_id"),
context=_merge_context(payload),
)
async def render(self, response):
return {"to": response.metadata.get("channel_id"), "text": response.text, "session_id": response.session_id}
class VoiceAdapter(ChannelAdapter):
name = "voice"
async def normalize(self, payload):
payload = payload or {}
return ChannelMessage(
channel="voice",
channel_id=payload.get("ani"),
text=payload.get("transcript") or payload.get("text") or payload.get("message") or "",
session_id=payload.get("session_id"),
context=_merge_context(payload),
)
async def render(self, response):
return {"speak": response.text, "session_id": response.session_id}

View File

@@ -0,0 +1,21 @@
from pydantic import BaseModel, Field
from typing import Any
class ChannelMessage(BaseModel):
channel: str
channel_id: str | None = None
session_id: str | None = None
user_id: str | None = None
text: str
context: dict[str, Any] = Field(default_factory=dict)
class ChannelResponse(BaseModel):
channel: str
session_id: str
text: str
metadata: dict[str, Any] = Field(default_factory=dict)
class ChannelAdapter:
name = 'base'
async def normalize(self, payload: dict) -> ChannelMessage: ...
async def render(self, response: ChannelResponse) -> dict: ...

View File

@@ -0,0 +1,92 @@
from __future__ import annotations
from .adapters import WebAdapter, WhatsAppAdapter, VoiceAdapter, _merge_context
from .base import ChannelMessage, ChannelResponse
try:
from agent_framework.config.settings import settings
except Exception: # pragma: no cover
settings = None
class ChannelGateway:
"""Normalize and render messages at the Agent Framework boundary.
This class is used by the Agent Framework backend, not by the external
Channel Gateway service.
input_mode semantics:
- embedded: the backend may use internal channel adapters to interpret
simple/native channel payloads. This is useful for demos, labs and local
testing.
- external: the backend expects a GatewayRequest payload that was already
normalized by an external Channel Gateway. In this mode the backend does
not parse native WhatsApp, Voice, Teams, or other channel payloads.
Backward compatibility:
- The legacy constructor argument ``mode`` and setting
``CHANNEL_GATEWAY_MODE`` are still accepted, but the preferred setting is
``FRAMEWORK_CHANNEL_INPUT_MODE``.
"""
def __init__(self, input_mode: str | None = None, mode: str | None = None):
configured = (
input_mode
or mode
or getattr(settings, "FRAMEWORK_CHANNEL_INPUT_MODE", None)
or getattr(settings, "CHANNEL_GATEWAY_MODE", None)
or "embedded"
)
self.input_mode = str(configured).strip().lower()
if self.input_mode not in {"embedded", "external"}:
raise ValueError(
"INVALID_FRAMEWORK_CHANNEL_INPUT_MODE: expected 'embedded' or 'external'"
)
# Compatibility with previous code that accessed gateway.mode.
self.mode = self.input_mode
self.adapters = {a.name: a for a in [WebAdapter(), WhatsAppAdapter(), VoiceAdapter()]}
def get(self, channel: str):
return self.adapters.get(channel, self.adapters["web"])
def _validate_external_payload(self, channel: str, payload: dict):
"""Validate the payload portion of a GatewayRequest.
In external input mode, the backend is not accepting native channel
payloads. It expects req.channel plus req.payload.message at minimum.
Business keys remain optional because some journeys start without all
identifiers and are completed by IdentityResolver or the agent.
"""
if not isinstance(channel, str) or not channel.strip():
raise ValueError("INVALID_GATEWAY_REQUEST: channel is required")
if not isinstance(payload, dict):
raise ValueError("INVALID_GATEWAY_REQUEST: payload must be an object")
message = payload.get("message")
if not isinstance(message, str) or not message.strip():
raise ValueError(
"INVALID_GATEWAY_REQUEST: payload.message is required and must be a non-empty string"
)
async def _normalize_external(self, channel: str, payload: dict) -> ChannelMessage:
self._validate_external_payload(channel, payload)
return ChannelMessage(
channel=channel,
text=payload.get("message"),
session_id=payload.get("session_id") or payload.get("session_key"),
user_id=payload.get("user_id"),
channel_id=payload.get("channel_id") or payload.get("channelId"),
context=_merge_context(payload),
)
async def normalize(self, channel: str, payload: dict) -> ChannelMessage:
if self.input_mode == "external":
return await self._normalize_external(channel, payload)
return await self.get(channel).normalize(payload)
async def render(self, response: ChannelResponse) -> dict:
if self.input_mode == "external":
# The external Channel Gateway owns the final translation back to
# WhatsApp, Voice, Teams, etc. The backend returns its canonical
# response shape.
return response.model_dump()
return await self.get(response.channel).render(response)

View File

@@ -0,0 +1,156 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(slots=True)
class InterruptionDecision:
action: str # process | replay | classify
text: str
replay_text: str = ""
reason: str = ""
is_interruptible: bool = True
terminal_status: str = ""
heard_text: str = ""
def _idle_nudges(payload: dict[str, Any]) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for event in payload.get("events") or []:
if not isinstance(event, dict) or event.get("type") != "idle_nudge":
continue
text = str(event.get("text") or "").strip()
if text and text not in seen:
seen.add(text)
out.append(text)
return out
async def classify_processing_interruption(
llm: Any,
*,
original_agent: str,
original_client: str = "",
supplement_client: str = "",
profile_name: str = "processing_interruption_classifier",
) -> bool:
"""Decide se um barge-in interrompível exige regeneração da resposta.
Fail-safe: qualquer erro, resposta vazia ou formato inesperado retorna False,
fazendo replay da fala anterior. O domínio não conhece este classificador;
ele usa exclusivamente o LLMProvider do framework.
"""
if llm is None:
return False
prompt = (
"Você classifica interrupções de voz durante uma resposta de atendimento. "
"Responda somente 1 ou 0.\n"
"1 = a fala/complemento do cliente adiciona ou altera informação relevante e "
"a resposta do agente deve ser regenerada.\n"
"0 = a interrupção não exige nova resposta; a fala anterior deve ser repetida.\n\n"
f"Última fala do agente: {original_agent}\n"
f"Última fala do cliente antes da resposta: {original_client}\n"
f"Complemento/interrupção atual: {supplement_client}\n"
)
try:
response = await llm.ainvoke(
[{"role": "system", "content": prompt}],
temperature=0,
max_tokens=8,
profile_name=profile_name,
component_name=profile_name,
generation_name=f"llm.{profile_name}",
)
raw = getattr(response, "content", response)
text = str(raw or "").strip()
return text.startswith("1")
except Exception:
return False
def evaluate_interruption(
*,
payload: dict[str, Any],
message_text: str,
session_metadata: dict[str, Any] | None,
terminal_fallback_text: str = "",
terminal_fallback_status: str = "erro_falha_sistema",
) -> InterruptionDecision:
"""Framework-level replay/interruption policy.
- sessão terminal: replay da última fala/fallback, sem reabrir o workflow;
- idle_nudge: replay da última fala real;
- fala não interrompível: replay;
- fala interrompível com fala anterior: classificar antes de regenerar;
- sem contexto anterior suficiente: processar normalmente.
"""
metadata = session_metadata or {}
last_text = str(metadata.get("last_assistant_text") or "").strip()
last_interruptible = bool(metadata.get("last_assistant_is_interruptible", True))
if bool(metadata.get("conversation_closed")):
replay_text = (
last_text
or str(metadata.get("terminal_replay_text") or "").strip()
or str(terminal_fallback_text or "").strip()
)
terminal_status = str(metadata.get("terminal_status") or "").strip() or terminal_fallback_status
if replay_text:
return InterruptionDecision(
action="replay",
text=message_text,
replay_text=replay_text,
reason="post_finalize",
is_interruptible=False,
terminal_status=terminal_status,
)
if _idle_nudges(payload) and last_text:
return InterruptionDecision(
action="replay",
text=message_text,
replay_text=last_text,
reason="idle_nudge",
is_interruptible=last_interruptible,
)
interruption = payload.get("processing_interruption")
if isinstance(interruption, dict):
heard = str(interruption.get("heard_text") or "").strip()
current_text = str(message_text or heard).strip()
if not last_interruptible and last_text:
return InterruptionDecision(
action="replay",
text=current_text,
replay_text=last_text,
reason="non_interruptible_speech",
is_interruptible=False,
heard_text=heard,
)
if last_text:
return InterruptionDecision(
action="classify",
text=current_text,
replay_text=last_text,
reason="interruptible_speech",
is_interruptible=True,
heard_text=heard,
)
return InterruptionDecision(
action="process",
text=current_text,
reason="interruptible_speech_no_history",
is_interruptible=True,
heard_text=heard,
)
return InterruptionDecision(action="process", text=message_text)
__all__ = [
"InterruptionDecision",
"classify_processing_interruption",
"evaluate_interruption",
]

View File

@@ -0,0 +1,31 @@
"""Correções determinísticas e conservadoras para transcrição de canal de voz."""
from __future__ import annotations
import re
from typing import Mapping
# Só falas inteiras entram nesta tabela. Nunca substitua tokens dentro de frases.
DEFAULT_WHOLE_UTTERANCE_FIXES: dict[str, str] = {
"fim": "Sim",
"mim": "Sim",
}
_TRAILING_PUNCT = re.compile(r"[.!?]+$")
def fix_whole_utterance_transcription(
text: str,
*,
fixes: Mapping[str, str] | None = None,
) -> str:
raw = str(text or "")
stripped = raw.strip()
if not stripped:
return raw
candidate = _TRAILING_PUNCT.sub("", stripped).strip().casefold()
table = fixes or DEFAULT_WHOLE_UTTERANCE_FIXES
replacement = table.get(candidate)
return str(replacement) if replacement is not None else raw
__all__ = ["DEFAULT_WHOLE_UTTERANCE_FIXES", "fix_whole_utterance_transcription"]

View File

@@ -0,0 +1,32 @@
from .checkpoint_repository import (
AutonomousCheckpointRepository,
CheckpointIntegrityError,
CheckpointIntegrityService,
CheckpointRecoveryError,
InMemoryCheckpointRepository,
LangGraphCheckpointRepository,
OracleCheckpointRepository,
ResilientCheckpointRepository,
RetryPolicy,
SQLiteCheckpointRepository,
create_checkpoint_repository,
create_raw_checkpoint_repository,
)
from .langgraph_saver import RepositoryCheckpointSaver, create_langgraph_checkpointer
__all__ = [
"AutonomousCheckpointRepository",
"CheckpointIntegrityError",
"CheckpointIntegrityService",
"CheckpointRecoveryError",
"InMemoryCheckpointRepository",
"LangGraphCheckpointRepository",
"OracleCheckpointRepository",
"RepositoryCheckpointSaver",
"ResilientCheckpointRepository",
"RetryPolicy",
"SQLiteCheckpointRepository",
"create_checkpoint_repository",
"create_langgraph_checkpointer",
"create_raw_checkpoint_repository",
]

View File

@@ -0,0 +1,425 @@
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import random
import time
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable
from agent_framework.persistence.sqlite_store import SQLiteStore
logger = logging.getLogger("agent_framework.checkpoints")
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _json_dumps(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
def _json_loads(value: str | bytes | None, default: Any):
if value is None:
return default
if isinstance(value, bytes):
value = value.decode("utf-8")
try:
return json.loads(value)
except Exception:
return default
def _sha256(value: Any) -> str:
return hashlib.sha256(_json_dumps(value).encode("utf-8")).hexdigest()
class CheckpointIntegrityError(RuntimeError):
"""Raised when a persisted checkpoint envelope fails checksum validation."""
class CheckpointRecoveryError(RuntimeError):
"""Raised when recovery cannot find a valid checkpoint."""
@dataclass(frozen=True)
class RetryPolicy:
max_attempts: int = 3
base_delay_seconds: float = 0.05
max_delay_seconds: float = 1.0
jitter_seconds: float = 0.05
class CheckpointIntegrityService:
"""Creates and validates immutable checkpoint envelopes.
The repository stores an envelope instead of only the raw LangGraph payload:
- schema_version: enables future migrations;
- payload_hash: SHA-256 over the payload;
- envelope_id: idempotency/correlation id;
- compacted: marks synthetic compacted snapshots.
"""
SCHEMA_VERSION = 1
ENVELOPE_MARKER = "agent_framework_checkpoint_envelope"
def wrap(self, thread_id: str, checkpoint: dict[str, Any], *, compacted: bool = False) -> dict[str, Any]:
payload = checkpoint or {}
return {
"_type": self.ENVELOPE_MARKER,
"schema_version": self.SCHEMA_VERSION,
"envelope_id": str(uuid.uuid4()),
"thread_id": thread_id,
"checkpoint_id": str(payload.get("checkpoint_id") or (payload.get("checkpoint") or {}).get("id") or uuid.uuid4()),
"payload_hash": _sha256(payload),
"payload": payload,
"compacted": bool(compacted),
"created_at": _utc_now(),
}
def is_envelope(self, value: dict[str, Any] | None) -> bool:
return isinstance(value, dict) and value.get("_type") == self.ENVELOPE_MARKER
def unwrap(self, value: dict[str, Any] | None) -> dict[str, Any] | None:
if value is None:
return None
if not self.is_envelope(value):
# Backwards compatibility with old checkpoints from previous project versions.
return value
expected = value.get("payload_hash")
payload = value.get("payload") or {}
actual = _sha256(payload)
if expected != actual:
raise CheckpointIntegrityError(
f"Checkpoint corrompido para thread_id={value.get('thread_id')}: hash esperado={expected}, hash atual={actual}"
)
if int(value.get("schema_version") or 0) > self.SCHEMA_VERSION:
raise CheckpointIntegrityError(
f"Checkpoint usa schema_version={value.get('schema_version')} maior que o suportado={self.SCHEMA_VERSION}"
)
return payload
class LangGraphCheckpointRepository(ABC):
@abstractmethod
async def put(self, thread_id: str, checkpoint: dict[str, Any]) -> None: ...
@abstractmethod
async def get_latest(self, thread_id: str) -> dict[str, Any] | None: ...
async def list_latest(self, thread_id: str, limit: int = 20) -> list[dict[str, Any]]:
latest = await self.get_latest(thread_id)
return [latest] if latest else []
async def compact(self, thread_id: str, keep_last: int = 20) -> int:
return 0
@staticmethod
def is_valid_checkpoint(checkpoint):
if not isinstance(checkpoint, dict):
return False
if "v" in checkpoint:
return True
if (
"checkpoint" in checkpoint
and isinstance(checkpoint["checkpoint"], dict)
and "v" in checkpoint["checkpoint"]
):
return True
return False
class InMemoryCheckpointRepository(LangGraphCheckpointRepository):
def __init__(self):
self._data: dict[str, list[dict[str, Any]]] = {}
async def put(self, thread_id: str, checkpoint: dict[str, Any]):
self._data.setdefault(thread_id, []).append(checkpoint)
async def get_latest(self, thread_id: str):
items = self._data.get(thread_id, [])
return items[-1] if items else None
async def list_latest(self, thread_id: str, limit: int = 20) -> list[dict[str, Any]]:
return list(reversed(self._data.get(thread_id, [])[-limit:]))
async def compact(self, thread_id: str, keep_last: int = 20) -> int:
items = self._data.get(thread_id, [])
if len(items) <= keep_last:
return 0
removed = len(items) - keep_last
self._data[thread_id] = items[-keep_last:]
return removed
class SQLiteCheckpointRepository(LangGraphCheckpointRepository):
def __init__(self, settings):
self.store = SQLiteStore(settings.SQLITE_DB_PATH)
async def put(self, thread_id: str, checkpoint: dict[str, Any]):
await asyncio.to_thread(self.store.put_checkpoint, thread_id, checkpoint)
async def get_latest(self, thread_id: str):
return await asyncio.to_thread(self.store.get_latest_checkpoint, thread_id)
async def list_latest(self, thread_id: str, limit: int = 20) -> list[dict[str, Any]]:
def _list():
with self.store.connect() as con:
rows = con.execute(
"select checkpoint_json from workflow_checkpoints where thread_id=? order by id desc limit ?",
(thread_id, int(limit)),
).fetchall()
return [_json_loads(r["checkpoint_json"], None) for r in rows if r]
return await asyncio.to_thread(_list)
async def compact(self, thread_id: str, keep_last: int = 20) -> int:
def _compact():
with self.store.connect() as con:
rows = con.execute(
"select id from workflow_checkpoints where thread_id=? order by id desc",
(thread_id,),
).fetchall()
ids = [int(r["id"]) for r in rows]
delete_ids = ids[int(keep_last):]
if not delete_ids:
return 0
con.executemany("delete from workflow_checkpoints where id=?", [(i,) for i in delete_ids])
return len(delete_ids)
return await asyncio.to_thread(_compact)
class OracleCheckpointRepository(LangGraphCheckpointRepository):
"""Checkpoint repository real para Oracle/Autonomous Database.
O OracleStore já cria as tabelas FIRST-compatible. A compactação é best-effort:
remove checkpoints antigos quando o store expõe conexão e prefixo de tabelas.
"""
def __init__(self, settings):
from agent_framework.persistence.oracle_store import OracleStore
self.store = OracleStore(settings)
async def put(self, thread_id: str, checkpoint: dict[str, Any]):
await self.store.put_checkpoint(thread_id, checkpoint)
async def get_latest(self, thread_id: str):
return await self.store.get_latest_checkpoint(thread_id)
async def list_latest(self, thread_id: str, limit: int = 20) -> list[dict[str, Any]]:
if not hasattr(self.store, "connect") or not hasattr(self.store, "t"):
return await super().list_latest(thread_id, limit)
def _list():
sql = f"""
select CHECKPOINT_JSON
from {self.store.t('WORKFLOW_CHECKPOINT')}
where THREAD_ID = :thread_id
order by ID desc
fetch first :limit rows only
"""
with self.store.connect() as conn:
rows = conn.cursor().execute(sql, dict(thread_id=thread_id, limit=int(limit))).fetchall()
return [_json_loads(r[0], None) for r in rows if r]
return await asyncio.to_thread(_list)
async def compact(self, thread_id: str, keep_last: int = 20) -> int:
if not hasattr(self.store, "connect") or not hasattr(self.store, "t"):
return 0
def _compact():
table = self.store.t("WORKFLOW_CHECKPOINT")
sql_count = f"select count(*) from {table} where THREAD_ID = :thread_id"
sql_delete = f"""
delete from {table}
where THREAD_ID = :thread_id
and ID not in (
select ID from {table}
where THREAD_ID = :thread_id
order by ID desc
fetch first :keep_last rows only
)
"""
with self.store.connect() as conn:
cur = conn.cursor()
before = int(cur.execute(sql_count, dict(thread_id=thread_id)).fetchone()[0])
cur.execute(sql_delete, dict(thread_id=thread_id, keep_last=int(keep_last)))
after = int(cur.execute(sql_count, dict(thread_id=thread_id)).fetchone()[0])
return max(0, before - after)
return await asyncio.to_thread(_compact)
AutonomousCheckpointRepository = OracleCheckpointRepository
class ResilientCheckpointRepository(LangGraphCheckpointRepository):
"""Adds integrity, retry, compaction and recovery to any repository.
This wrapper is intentionally repository-neutral. It can protect memory,
SQLite and Oracle repositories without changing LangGraph code.
"""
def __init__(
self,
inner: LangGraphCheckpointRepository,
*,
integrity: CheckpointIntegrityService | None = None,
retry_policy: RetryPolicy | None = None,
enable_integrity: bool = True,
enable_compaction: bool = True,
compact_every: int = 50,
keep_last: int = 20,
recovery_scan_limit: int = 25,
):
self.inner = inner
self.integrity = integrity or CheckpointIntegrityService()
self.retry_policy = retry_policy or RetryPolicy()
self.enable_integrity = enable_integrity
self.enable_compaction = enable_compaction
self.compact_every = max(1, int(compact_every))
self.keep_last = max(1, int(keep_last))
self.recovery_scan_limit = max(1, int(recovery_scan_limit))
self._put_count_by_thread: dict[str, int] = {}
async def _with_retry(self, operation_name: str, coro_factory):
last_exc: Exception | None = None
for attempt in range(1, self.retry_policy.max_attempts + 1):
try:
return await coro_factory()
except Exception as exc: # noqa: BLE001 - repository failures vary by backend
last_exc = exc
if attempt >= self.retry_policy.max_attempts:
break
delay = min(
self.retry_policy.max_delay_seconds,
self.retry_policy.base_delay_seconds * (2 ** (attempt - 1)),
) + random.uniform(0, self.retry_policy.jitter_seconds)
logger.warning("checkpoint.%s.retry attempt=%s delay=%.3fs error=%s", operation_name, attempt, delay, exc)
await asyncio.sleep(delay)
raise last_exc # type: ignore[misc]
async def put(self, thread_id: str, checkpoint: dict[str, Any]) -> None:
payload = self.integrity.wrap(thread_id, checkpoint) if self.enable_integrity else checkpoint
await self._with_retry("put", lambda: self.inner.put(thread_id, payload))
self._put_count_by_thread[thread_id] = self._put_count_by_thread.get(thread_id, 0) + 1
if self.enable_compaction and self._put_count_by_thread[thread_id] % self.compact_every == 0:
try:
removed = await self.inner.compact(thread_id, keep_last=self.keep_last)
if removed:
logger.info("checkpoint.compaction thread_id=%s removed=%s keep_last=%s", thread_id, removed, self.keep_last)
except Exception as exc: # compaction must never break the user flow
logger.warning("checkpoint.compaction.failed thread_id=%s error=%s", thread_id, exc)
async def get_latest(self, thread_id: str) -> dict[str, Any] | None:
return await self.recover_latest(thread_id)
async def list_latest(self, thread_id: str, limit: int = 20) -> list[dict[str, Any]]:
raw_items = await self.inner.list_latest(thread_id, limit)
out: list[dict[str, Any]] = []
for item in raw_items:
try:
payload = self.integrity.unwrap(item) if self.enable_integrity else item
if payload is not None:
out.append(payload)
except CheckpointIntegrityError:
continue
return out
async def compact(self, thread_id: str, keep_last: int = 20) -> int:
return await self.inner.compact(thread_id, keep_last=keep_last)
async def recover_latest(self, thread_id: str) -> dict[str, Any] | None:
"""Return the newest valid LangGraph checkpoint, skipping corrupt or legacy records."""
raw_items = await self._with_retry(
"list_latest",
lambda: self.inner.list_latest(thread_id, self.recovery_scan_limit),
)
first_integrity_error: Exception | None = None
invalid_count = 0
for raw in raw_items:
try:
payload = self.integrity.unwrap(raw)
candidate = payload
if (
isinstance(payload, dict)
and "checkpoint" in payload
):
candidate = payload["checkpoint"]
if not self.is_valid_checkpoint(candidate):
continue
return payload
except CheckpointIntegrityError as exc:
first_integrity_error = first_integrity_error or exc
logger.error(
"checkpoint.recovery.skip_corrupt thread_id=%s error=%s",
thread_id,
exc,
)
continue
if first_integrity_error:
# No valid checkpoint: return None so the run starts clean instead of crashing ainvoke.
logger.error(
"checkpoint.recovery.no_valid_checkpoint thread_id=%s starting_fresh error=%s",
thread_id,
first_integrity_error,
)
return None
if invalid_count:
logger.warning(
"checkpoint.recovery.no_valid_langgraph_checkpoint "
"thread_id=%s invalid_count=%s",
thread_id,
invalid_count,
)
return None
def _retry_policy_from_settings(settings) -> RetryPolicy:
return RetryPolicy(
max_attempts=int(getattr(settings, "CHECKPOINT_RETRY_MAX_ATTEMPTS", 3) or 3),
base_delay_seconds=float(getattr(settings, "CHECKPOINT_RETRY_BASE_DELAY_SECONDS", 0.05) or 0.05),
max_delay_seconds=float(getattr(settings, "CHECKPOINT_RETRY_MAX_DELAY_SECONDS", 1.0) or 1.0),
jitter_seconds=float(getattr(settings, "CHECKPOINT_RETRY_JITTER_SECONDS", 0.05) or 0.05),
)
def create_raw_checkpoint_repository(settings):
provider = getattr(settings, "CHECKPOINT_REPOSITORY_PROVIDER", "memory")
if provider == "sqlite":
return SQLiteCheckpointRepository(settings)
if provider in {"autonomous", "oracle"}:
return OracleCheckpointRepository(settings)
return InMemoryCheckpointRepository()
def create_checkpoint_repository(settings):
raw = create_raw_checkpoint_repository(settings)
if not bool(getattr(settings, "ENABLE_RESILIENT_CHECKPOINTER", True)):
return raw
return ResilientCheckpointRepository(
raw,
retry_policy=_retry_policy_from_settings(settings),
enable_integrity=bool(getattr(settings, "ENABLE_CHECKPOINT_INTEGRITY", True)),
enable_compaction=bool(getattr(settings, "ENABLE_CHECKPOINT_COMPACTION", True)),
compact_every=int(getattr(settings, "CHECKPOINT_COMPACT_EVERY", 50) or 50),
keep_last=int(getattr(settings, "CHECKPOINT_KEEP_LAST", 20) or 20),
recovery_scan_limit=int(getattr(settings, "CHECKPOINT_RECOVERY_SCAN_LIMIT", 25) or 25),
)

View File

@@ -0,0 +1,208 @@
from __future__ import annotations
try:
from langgraph.checkpoint.base import BaseCheckpointSaver
except Exception: # pragma: no cover - fallback for lightweight unit tests without langgraph installed
class BaseCheckpointSaver: # type: ignore[no-redef]
pass
"""LangGraph checkpoint saver backed by the framework checkpoint repository.
This module intentionally keeps a small adapter surface so the framework can run
with multiple LangGraph versions. It implements the common synchronous and
asynchronous methods used by BaseCheckpointSaver/MemorySaver: get_tuple,
aget_tuple, put, aput, put_writes, aput_writes, list and alist.
The persisted payload stores LangGraph's raw checkpoint/config/metadata values in
repository-neutral JSON. When LangGraph is installed, checkpoint tuples are
returned using CheckpointTuple; otherwise a simple dict is returned for tests.
"""
import asyncio
import json
import uuid
from typing import Any, AsyncIterator, Iterator
from .checkpoint_repository import create_checkpoint_repository
def _jsonable(value: Any) -> Any:
try:
json.dumps(value, default=str)
return value
except TypeError:
return json.loads(json.dumps(value, default=str))
def _thread_id(config: dict[str, Any] | None) -> str:
configurable = (config or {}).get("configurable") or {}
return str(configurable.get("thread_id") or configurable.get("checkpoint_ns") or "default")
def _checkpoint_id(checkpoint: dict[str, Any] | None) -> str:
if isinstance(checkpoint, dict):
return str(checkpoint.get("id") or checkpoint.get("checkpoint_id") or uuid.uuid4())
return str(uuid.uuid4())
def _normalize_pending_writes(pending_writes: Any) -> list[tuple[Any, Any, Any]]:
"""Normalize persisted pending_writes to LangGraph's expected runtime format.
LangGraph 1.1.x expects CheckpointTuple.pending_writes to be an iterable of
3-item tuples: (task_id, channel, value).
Older framework versions persisted writes as dictionaries containing
task_id, task_path, channel and value. Some stores/tests may also contain
4-item tuples: (task_id, task_path, channel, value). This adapter accepts
those legacy forms while preserving already-correct 3-item tuples.
"""
normalized: list[tuple[Any, Any, Any]] = []
for item in pending_writes or []:
if isinstance(item, dict):
normalized.append((
item.get("task_id"),
item.get("channel"),
item.get("value"),
))
continue
if isinstance(item, (list, tuple)):
if len(item) == 3:
task_id, channel, value = item
normalized.append((task_id, channel, value))
continue
if len(item) == 4:
task_id, _task_path, channel, value = item
normalized.append((task_id, channel, value))
continue
# Defensive fallback: keep malformed legacy entries from crashing resume.
# Use a synthetic channel so the data remains inspectable in telemetry/logs.
normalized.append((None, "__malformed_pending_write__", item))
return normalized
class RepositoryCheckpointSaver(BaseCheckpointSaver):
"""Checkpoint saver nativo para LangGraph usando os repositories do framework."""
def __init__(self, settings, repository=None):
self.settings = settings
self.repository = repository or create_checkpoint_repository(settings)
self._loop: asyncio.AbstractEventLoop | None = None
def _run(self, coro):
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(coro)
# LangGraph may call sync methods from a worker thread; when already in
# an event loop prefer a short-lived thread to avoid nested-loop errors.
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex:
return ex.submit(lambda: asyncio.run(coro)).result()
def _make_tuple(self, payload: dict[str, Any] | None):
if not payload:
return None
config = payload.get("config") or {"configurable": {"thread_id": payload.get("thread_id")}}
checkpoint = payload.get("checkpoint") or {}
metadata = payload.get("metadata") or {}
parent_config = payload.get("parent_config")
pending_writes = _normalize_pending_writes(payload.get("pending_writes") or [])
try:
from langgraph.checkpoint.base import CheckpointTuple
return CheckpointTuple(config=config, checkpoint=checkpoint, metadata=metadata, parent_config=parent_config, pending_writes=pending_writes)
except Exception:
return {
"config": config,
"checkpoint": checkpoint,
"metadata": metadata,
"parent_config": parent_config,
"pending_writes": pending_writes,
}
async def aget_tuple(self, config: dict[str, Any]):
return self._make_tuple(await self.repository.get_latest(_thread_id(config)))
def get_tuple(self, config: dict[str, Any]):
return self._run(self.aget_tuple(config))
async def aput(self, config: dict[str, Any], checkpoint: dict[str, Any], metadata: dict[str, Any] | None = None, new_versions: dict[str, Any] | None = None):
thread_id = _thread_id(config)
checkpoint_id = _checkpoint_id(checkpoint)
next_config = {
**(config or {}),
"configurable": {
**((config or {}).get("configurable") or {}),
"thread_id": thread_id,
"checkpoint_id": checkpoint_id,
},
}
await self.repository.put(thread_id, {
"thread_id": thread_id,
"config": _jsonable(next_config),
"checkpoint": _jsonable(checkpoint),
"metadata": _jsonable(metadata or {}),
"new_versions": _jsonable(new_versions or {}),
"checkpoint_id": checkpoint_id,
})
return next_config
def put(self, config: dict[str, Any], checkpoint: dict[str, Any], metadata: dict[str, Any] | None = None, new_versions: dict[str, Any] | None = None):
return self._run(self.aput(config, checkpoint, metadata, new_versions))
async def aput_writes(self, config: dict[str, Any], writes: list[tuple[str, Any]], task_id: str, task_path: str = ""):
thread_id = _thread_id(config)
try:
latest = await self.repository.get_latest(thread_id) or {"thread_id": thread_id, "config": config, "checkpoint": {}, "metadata": {}}
except:
latest = {
"thread_id": thread_id,
"config": config,
"checkpoint": {},
"metadata": {},
"pending_writes": [],
}
pending = list(latest.get("pending_writes") or [])
for channel, value in writes or []:
pending.append({"task_id": task_id, "task_path": task_path, "channel": channel, "value": _jsonable(value)})
latest["pending_writes"] = pending
await self.repository.put(thread_id, latest)
def put_writes(self, config: dict[str, Any], writes: list[tuple[str, Any]], task_id: str, task_path: str = ""):
return self._run(self.aput_writes(config, writes, task_id, task_path))
async def alist(self, config: dict[str, Any] | None = None, *, filter: dict[str, Any] | None = None, before: dict[str, Any] | None = None, limit: int | None = None) -> AsyncIterator[Any]:
# Repository interface currently exposes only latest; this is enough for
# resume/recovery. Oracle/SQLite repositories can later implement full list.
if config is None:
return
item = await self.aget_tuple(config)
if item:
yield item
def list(self, config: dict[str, Any] | None = None, *, filter: dict[str, Any] | None = None, before: dict[str, Any] | None = None, limit: int | None = None) -> Iterator[Any]:
item = self.get_tuple(config or {}) if config else None
if item:
yield item
def create_langgraph_checkpointer(settings):
"""Factory used by applications when compiling LangGraph.
By default the framework now returns RepositoryCheckpointSaver even for
CHECKPOINT_REPOSITORY_PROVIDER=memory, because the repository wrapper adds
integrity checks, retry, recovery and compaction.
Set ENABLE_RESILIENT_CHECKPOINTER=false to fall back to LangGraph MemorySaver
for very small local experiments.
"""
provider = getattr(settings, "CHECKPOINT_REPOSITORY_PROVIDER", "memory")
resilient = bool(getattr(settings, "ENABLE_RESILIENT_CHECKPOINTER", True))
if provider == "memory" and not resilient:
try:
from langgraph.checkpoint.memory import MemorySaver
return MemorySaver()
except Exception:
return RepositoryCheckpointSaver(settings)
return RepositoryCheckpointSaver(settings)

View File

@@ -0,0 +1,90 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
try:
import yaml
except Exception: # pragma: no cover
yaml = None
@dataclass
class AgentProfile:
agent_id: str
name: str = ""
description: str = ""
prompt_policy_path: str | None = None
routing_config_path: str | None = None
guardrails_config_path: str | None = None
judges_config_path: str | None = None
mcp_servers_config_path: str | None = None
tools_config_path: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
class AgentProfileRegistry:
"""Carrega perfis de agentes/templates a partir de YAML.
O objetivo é permitir múltiplos agent_template no mesmo backend sem misturar
memória, checkpoints, prompts, guardrails ou judges.
"""
def __init__(self, settings):
self.settings = settings
self.base_dir = Path.cwd()
self.profiles: dict[str, AgentProfile] = {}
self.default_agent_id = "default_agent"
self._load()
def _resolve(self, value: str | None) -> str | None:
if not value:
return None
path = Path(value)
return str(path if path.is_absolute() else (self.base_dir / path).resolve())
def _load(self) -> None:
config_path = Path(getattr(self.settings, "AGENTS_CONFIG_PATH", "./config/agents.yaml"))
if not config_path.is_absolute():
config_path = self.base_dir / config_path
if not config_path.exists() or yaml is None:
self.profiles[self.default_agent_id] = AgentProfile(
agent_id=self.default_agent_id,
name="Default Agent",
prompt_policy_path=self._resolve(getattr(self.settings, "PROMPT_POLICY_PATH", None)),
routing_config_path=self._resolve(getattr(self.settings, "ROUTING_CONFIG_PATH", None)),
guardrails_config_path=self._resolve(getattr(self.settings, "GUARDRAILS_CONFIG_PATH", None)),
judges_config_path=self._resolve(getattr(self.settings, "JUDGES_CONFIG_PATH", None)),
mcp_servers_config_path=self._resolve(getattr(self.settings, "MCP_SERVERS_CONFIG_PATH", None)),
tools_config_path=self._resolve(getattr(self.settings, "TOOLS_CONFIG_PATH", None)),
)
return
raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
self.default_agent_id = raw.get("default_agent_id") or self.default_agent_id
for item in raw.get("agents", []):
agent_id = str(item.get("agent_id") or item.get("id") or "").strip()
if not agent_id:
continue
self.profiles[agent_id] = AgentProfile(
agent_id=agent_id,
name=item.get("name", agent_id),
description=item.get("description", ""),
prompt_policy_path=self._resolve(item.get("prompt_policy_path") or getattr(self.settings, "PROMPT_POLICY_PATH", None)),
routing_config_path=self._resolve(item.get("routing_config_path") or getattr(self.settings, "ROUTING_CONFIG_PATH", None)),
guardrails_config_path=self._resolve(item.get("guardrails_config_path") or getattr(self.settings, "GUARDRAILS_CONFIG_PATH", None)),
judges_config_path=self._resolve(item.get("judges_config_path") or getattr(self.settings, "JUDGES_CONFIG_PATH", None)),
mcp_servers_config_path=self._resolve(item.get("mcp_servers_config_path") or getattr(self.settings, "MCP_SERVERS_CONFIG_PATH", None)),
tools_config_path=self._resolve(item.get("tools_config_path") or getattr(self.settings, "TOOLS_CONFIG_PATH", None)),
metadata=item.get("metadata") or {},
)
if self.default_agent_id not in self.profiles and self.profiles:
self.default_agent_id = next(iter(self.profiles))
def get(self, agent_id: str | None = None) -> AgentProfile:
key = agent_id or self.default_agent_id
return self.profiles.get(key) or self.profiles[self.default_agent_id]
def list_profiles(self) -> list[AgentProfile]:
return list(self.profiles.values())

View File

@@ -0,0 +1,247 @@
from functools import lru_cache
from typing import Literal
from dotenv import load_dotenv
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
# Load .env into os.environ as well.
# Pydantic Settings reads .env for Settings fields, but parts of the calibrated
# guardrails intentionally use os.getenv for compatibility with the original
# guardrails package. Loading here keeps both paths consistent.
load_dotenv(override=False)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', extra='ignore')
APP_NAME: str = 'ai-agent-template'
APP_ENV: str = 'local'
LOG_LEVEL: str = 'INFO'
API_HOST: str = '0.0.0.0'
API_PORT: int = 8000
CORS_ORIGINS: str = 'http://localhost:5173'
LLM_PROVIDER: Literal['mock','oci_openai','oci_sdk','openai_compatible'] = 'mock'
LLM_TEMPERATURE: float = 0.2
LLM_MAX_TOKENS: int = 2048
LLM_TIMEOUT_SECONDS: int = 120
LLM_PROFILES_PATH: str = './llm_profiles.yaml'
# Reasoning controls. When absent from .env, auto is the default.
# auto = enable only when the provider/model capability resolver says it is supported.
# true = force-enable (the provider still performs SDK/request safety checks).
# false = never send reasoning_effort.
LLM_REASONING_ENABLED: Literal['auto','true','false'] = 'auto'
LLM_REASONING_EFFORT: str | None = None
OCI_GENAI_BASE_URL: str = 'https://inference.generativeai.sa-saopaulo-1.oci.oraclecloud.com/openai/v1'
OCI_GENAI_MODEL: str = 'openai.gpt-4.1'
OCI_GENAI_API_KEY: str | None = None
OCI_GENAI_PROJECT_OCID: str | None = None
# OCI SDK authentication mode.
# config_file = ~/.oci/config profile (default/local development)
# instance_principal = OCI Instance Principal signer (Compute/OKE without API key)
# resource_principal = OCI Resource Principal signer (Functions/resource principal contexts)
OCI_AUTH_MODE: Literal['config_file','instance_principal','resource_principal', 'oke_workload_identity'] = 'config_file'
OCI_CONFIG_FILE: str = '~/.oci/config'
OCI_PROFILE: str = 'DEFAULT'
OCI_COMPARTMENT_ID: str | None = None
OCI_REGION: str = 'sa-saopaulo-1'
OCI_GENAI_ENDPOINT: str | None = None
OCI_EMBEDDING_ENDPOINT: str | None = None
SESSION_REPOSITORY_PROVIDER: Literal['memory','sqlite','autonomous','oracle','mongodb'] = 'memory'
MEMORY_REPOSITORY_PROVIDER: Literal['memory','sqlite','autonomous','oracle','mongodb'] = 'memory'
CHECKPOINT_REPOSITORY_PROVIDER: Literal['memory','sqlite','autonomous','oracle','mongodb'] = 'memory'
# ConversationSummaryMemory: compressão de contexto conversacional.
# none = não injeta histórico no prompt
# window = injeta somente últimas mensagens
# summary = resumo acumulado + últimas mensagens completas
ENABLE_CONVERSATION_SUMMARY_MEMORY: bool = False
MEMORY_CONTEXT_STRATEGY: Literal['none','window','summary'] = 'window'
MEMORY_HISTORY_LIMIT: int = 80
MEMORY_RECENT_MESSAGES_LIMIT: int = 8
MEMORY_SUMMARY_TRIGGER_MESSAGES: int = 20
MEMORY_MAX_SUMMARY_CHARS: int = 6000
MEMORY_SUMMARY_USE_LLM: bool = True
MEMORY_INJECT_RECENT_MESSAGES: bool = True
MEMORY_INJECT_SUMMARY: bool = True
ENABLE_LONG_TERM_MEMORY: bool = False
LONG_TERM_MEMORY_PROVIDER: Literal['memory','sqlite','autonomous','oracle'] = 'sqlite'
LONG_TERM_MEMORY_SQLITE_PATH: str | None = None
LONG_TERM_MEMORY_TABLE: str = 'agentfw_long_term_memory'
LONG_TERM_MEMORY_ORACLE_TABLE: str | None = None
LONG_TERM_MEMORY_MAX_CONTEXT_ITEMS: int = 20
LONG_TERM_MEMORY_MIN_CONFIDENCE: float = 0.70
LONG_TERM_MEMORY_AUTO_EXTRACT: bool = True
LONG_TERM_MEMORY_INJECT_CONTEXT: bool = True
# LangGraph enterprise checkpointing
ENABLE_RESILIENT_CHECKPOINTER: bool = True
ENABLE_CHECKPOINT_INTEGRITY: bool = True
ENABLE_CHECKPOINT_COMPACTION: bool = True
CHECKPOINT_COMPACT_EVERY: int = 50
CHECKPOINT_KEEP_LAST: int = 20
CHECKPOINT_RECOVERY_SCAN_LIMIT: int = 25
CHECKPOINT_RETRY_MAX_ATTEMPTS: int = 3
CHECKPOINT_RETRY_BASE_DELAY_SECONDS: float = 0.05
CHECKPOINT_RETRY_MAX_DELAY_SECONDS: float = 1.0
CHECKPOINT_RETRY_JITTER_SECONDS: float = 0.05
USAGE_REPOSITORY_PROVIDER: Literal['sqlite','autonomous','oracle'] = 'sqlite'
ADB_USER: str | None = None
ADB_PASSWORD: str | None = None
ADB_DSN: str | None = None
ADB_WALLET_LOCATION: str | None = None
ADB_WALLET_PASSWORD: str | None = None
ADB_TABLE_PREFIX: str = 'AGENTFW'
MONGODB_URI: str = 'mongodb://localhost:27017'
MONGODB_DATABASE: str = 'agent_platform'
REDIS_URL: str = 'redis://localhost:6379/0'
ENABLE_REDIS_CACHE: bool = False
CACHE_KEY_PREFIX: str = 'agentfw'
VECTOR_STORE_PROVIDER: Literal['memory','sqlite','autonomous','oracle','mongodb'] = 'memory'
GRAPH_STORE_PROVIDER: Literal['memory','autonomous','oracle'] = 'memory'
ORACLE_GRAPH_NAME: str = 'AGENTFW_GRAPH'
ORACLE_GRAPH_AUTO_CREATE: bool = False
RAG_TOP_K: int = 5
SKIP_RAG_WHEN_MCP_SUFFICIENT: bool = True
ENABLE_RAG_QUERY_REWRITE: bool = False
ENABLE_RAG_CONTEXT_COMPRESSION: bool = False
ENABLE_RAG_GENERATION: bool = False
EMBEDDING_PROVIDER: Literal['mock','oci'] = 'mock'
OCI_EMBEDDING_MODEL: str = 'cohere.embed-multilingual-v3.0'
ENABLE_LANGFUSE: bool = False
LANGFUSE_TRACE_MODE: Literal['verbose','compact'] = 'verbose'
LANGFUSE_ROOT_SPAN_NAME: str = 'agent.gateway_message'
LANGFUSE_LEGACY_IO_FALLBACK: bool = True
LANGFUSE_PUBLIC_KEY: str | None = None
LANGFUSE_SECRET_KEY: str | None = None
LANGFUSE_HOST: str = 'https://cloud.langfuse.com'
MODEL_PRICES_JSON: str | None = None
USD_BRL_RATE: str = '5.0'
ENABLE_OTEL: bool = False
OTEL_EXPORTER_OTLP_ENDPOINT: str | None = None
OTEL_SERVICE_NAME: str = 'ai-agent-template'
# Dedicated NOC OpenTelemetry Logs channel. This is separate from trace/span OTel.
ENABLE_NOC_OTEL_LOGS: bool = False
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: str | None = None
OTEL_EXPORTER_OTLP_HOST_HEADER: str | None = None
ENABLE_ANALYTICS: bool = False
ANALYTICS_PROVIDERS: str = 'oci_streaming'
GCP_PUBSUB_TOPIC_PATH: str | None = None
AGENT_PUBSUB_TOPIC: str | None = None
GCP_PROJECT_ID: str | None = None
GCP_PUBSUB_TOPIC: str | None = None
GCP_PUBSUB_TIMEOUT_SECONDS: float = 30.0
# flat = TIM/Data canonical contract. legacy/envelope keeps the old framework wrapper.
PUBSUB_PAYLOAD_MODE: Literal['flat','legacy','envelope','wrapped'] = 'flat'
# Match the old Observer behavior: NOC.* goes to OTel Logs, not Pub/Sub.
PUBSUB_EXCLUDE_NOC: bool = True
# Automatic TIM/Data Pub/Sub sequence generation.
# auto: Redis if configured; otherwise MongoDB if configured; otherwise memory fallback.
# mongodb: atomic find_one_and_update/$inc, matching the legacy TIM Observer behavior.
PUBSUB_SEQUENCE_ENABLED: bool = True
PUBSUB_SEQUENCE_PROVIDER: Literal['auto','redis','mongodb','mongo','memory','none'] = 'auto'
PUBSUB_SEQUENCE_REDIS_URL: str | None = None
PUBSUB_SEQUENCE_MONGODB_URI: str | None = None
PUBSUB_SEQUENCE_MONGODB_DATABASE: str | None = None
PUBSUB_SEQUENCE_MONGODB_COLLECTION: str = 'observer_sequences'
PUBSUB_SEQUENCE_TTL_SECONDS: int = 86400
PUBSUB_SEQUENCE_MEMORY_FALLBACK: bool = True
PUBSUB_SEQUENCE_KEY_PREFIX: str = 'observer:sequence'
ANALYTICS_FAIL_SILENT: bool = True
ENABLE_OCI_STREAMING: bool = False
OCI_STREAM_ENDPOINT: str | None = None
OCI_STREAM_OCID: str | None = None
OCI_STREAM_PARTITION_KEY: str = 'agent-events'
ENABLE_INPUT_GUARDRAILS: bool = True
ENABLE_OUTPUT_GUARDRAILS: bool = True
ENABLE_PARALLEL_GUARDRAILS: bool = True
GUARDRAILS_FAIL_FAST: bool = True
# Optional LLM inference points. Defaults keep the current deterministic behavior.
ENABLE_JUDGES: bool = True
ENABLE_SUPERVISOR: bool = True
ENABLE_OUTPUT_SUPERVISOR: bool = True
OUTPUT_SUPERVISOR_MAX_RETRIES: int = 3
GUARDRAILS_CONFIG_PATH: str = './config/guardrails.yaml'
JUDGES_CONFIG_PATH: str = './config/judges.yaml'
PROMPT_POLICY_PATH: str = './config/prompt_policy.yaml'
AGENTS_CONFIG_PATH: str = './config/agents.yaml'
ROUTING_CONFIG_PATH: str = './config/routing.yaml'
ENABLE_LLM_ROUTER: bool = False
ROUTING_MODE: Literal['router','supervisor'] = 'router'
# Semantic route stickiness. Uses an LLM profile; no regex or language rules.
ENABLE_ROUTE_STICKINESS: bool = False
ROUTE_STICKINESS_LLM_PROFILE: str = 'route_continuity'
ROUTE_STICKINESS_CONFIDENCE_THRESHOLD: float = 0.90
ROUTE_STICKINESS_HISTORY_TURNS: int = 2
ROUTE_STICKINESS_MAX_TOKENS: int = 80
HUMAN_HANDOFF_MESSAGE: str = 'Vou encaminhar seu atendimento para uma pessoa.'
END_SESSION_MESSAGE: str = 'Atendimento encerrado. Obrigado pelo contato.'
POST_FINALIZE_REPLAY_MESSAGE: str = (
'Por aqui finalizamos o tratamento da sua solicitação. '
'Aguarde um instante na linha.'
)
SESSION_ALREADY_ENDED_MESSAGE: str = 'Este atendimento já foi encerrado. Inicie uma nova sessão para continuar.'
# MCP / Tooling
ENABLE_MCP_TOOLS: bool = True
ENABLE_MCP_CACHE: bool = True
MCP_CACHE_TTL_SECONDS: int = 300
MCP_SERVERS_CONFIG_PATH: str = './config/mcp_servers.yaml'
TOOLS_CONFIG_PATH: str = './config/tools.yaml'
# Opcional. Se ausente, permanecem válidas as políticas legadas de tools.yaml.
TOOL_POLICIES_PATH: str | None = './config/tool_policies.yaml'
ENABLE_TRANSACTIONAL_WORKFLOWS: bool = False
WORKFLOWS_PATH: str = './workflows'
IDENTITY_CONFIG_PATH: str = './config/identity.yaml'
MCP_PARAMETER_MAPPING_PATH: str = './config/mcp_parameter_mapping.yaml'
MCP_TOOL_TIMEOUT_SECONDS: int = 30
# When enabled, the framework routes tool calls to the dedicated MCP Gateway
# instead of calling individual MCP servers directly. The gateway then owns
# server selection, retry, cache and policy enforcement.
MCP_GATEWAY_ENABLED: bool = False
MCP_GATEWAY_URL: str = 'http://localhost:8300'
MCP_GATEWAY_TIMEOUT_SECONDS: int = 60
MCP_GATEWAY_TOKEN: str | None = None
MCP_GATEWAY_AGENT_ID: str = 'telecom_contas'
MCP_GATEWAY_TENANT_ID: str = 'default'
DEFAULT_CHANNEL: str = 'web'
# Agent Framework channel input mode.
# embedded = backend may use internal adapters to interpret simple/native payloads.
# external = backend accepts only GatewayRequest payloads already normalized by an external Channel Gateway.
FRAMEWORK_CHANNEL_INPUT_MODE: Literal['embedded','external'] = 'embedded'
# Legacy alias kept for compatibility with older .env files. Prefer FRAMEWORK_CHANNEL_INPUT_MODE.
CHANNEL_GATEWAY_MODE: str | None = None
ENABLE_VOICE_ADAPTER: bool = True
ENABLE_WHATSAPP_ADAPTER: bool = True
ENABLE_TEXT_ADAPTER: bool = True
# FIRST-ready runtime options
SQLITE_DB_PATH: str = './data/agent_framework.db'
ENABLE_SSE: bool = True
SSE_KEEPALIVE_SECONDS: float = 15.0
SSE_EVENT_REPLAY_LIMIT: int = 100
ENABLE_MESSAGE_IDEMPOTENCY: bool = True
ENABLE_LOCAL_CACHE: bool = True
CACHE_TTL_SECONDS: int = 300
CACHE_BACKEND_PROVIDER: Literal['memory','sqlite','autonomous','oracle'] = 'memory'
SSE_STORE_PROVIDER: Literal['sqlite','autonomous','oracle'] | None = None
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

View File

@@ -0,0 +1,28 @@
import json, base64, logging
logger=logging.getLogger('agent_framework.streaming')
class EventPublisher:
async def publish(self, event_type: str, payload: dict): ...
class NoopEventPublisher(EventPublisher):
async def publish(self, event_type, payload):
logger.info('event.noop %s %s', event_type, payload)
class OCIStreamingPublisher(EventPublisher):
def __init__(self, settings):
import oci
config = oci.config.from_file(settings.OCI_CONFIG_FILE, settings.OCI_PROFILE)
self.client = oci.streaming.StreamClient(config, service_endpoint=settings.OCI_STREAM_ENDPOINT)
self.stream_id = settings.OCI_STREAM_OCID
self.partition_key = settings.OCI_STREAM_PARTITION_KEY
async def publish(self, event_type, payload):
import oci
body = json.dumps({'type': event_type, 'payload': payload}, default=str).encode()
entry = oci.streaming.models.PutMessagesDetailsEntry(key=self.partition_key.encode(), value=body)
details = oci.streaming.models.PutMessagesDetails(messages=[entry])
self.client.put_messages(self.stream_id, details)
def create_event_publisher(settings):
if settings.ENABLE_OCI_STREAMING and settings.OCI_STREAM_ENDPOINT and settings.OCI_STREAM_OCID:
return OCIStreamingPublisher(settings)
return NoopEventPublisher()

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from typing import Any
def get_gateway_model_policy(state: dict[str, Any]) -> dict[str, Any] | None:
metadata = state.get("metadata") or {}
policy = metadata.get("model_policy")
return policy if isinstance(policy, dict) else None
def apply_gateway_model_policy_to_llm_kwargs(
state: dict[str, Any],
fallback_profile: dict[str, Any] | None = None,
) -> dict[str, Any]:
policy = get_gateway_model_policy(state)
if not policy:
return fallback_profile or {}
params = dict(policy.get("parameters") or {})
if policy.get("model"):
params["model"] = policy["model"]
if policy.get("provider"):
params["provider"] = policy["provider"]
if policy.get("profile"):
params["profile"] = policy["profile"]
return params

View File

@@ -0,0 +1,3 @@
from .mcp_gateway_client import MCPGatewayClient
__all__ = ["MCPGatewayClient"]

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from typing import Any
import httpx
class MCPGatewayClient:
def __init__(self, base_url: str, token: str | None = None, timeout_seconds: int = 60):
self.base_url = base_url.rstrip("/")
self.token = token
self.timeout_seconds = timeout_seconds
def _headers(self) -> dict[str, str]:
return {"Authorization": f"Bearer {self.token}"} if self.token else {}
async def list_tools(self) -> dict[str, Any]:
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
response = await client.get(f"{self.base_url}/v1/tools", headers=self._headers())
response.raise_for_status()
return response.json()
async def invoke_tool(
self,
*,
tenant_id: str,
agent_id: str,
channel: str | None,
tool_name: str,
arguments: dict[str, Any] | None = None,
business_context: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
) -> dict[str, Any]:
payload = {
"tenant_id": tenant_id,
"agent_id": agent_id,
"channel": channel,
"tool_name": tool_name,
"arguments": arguments or {},
"business_context": business_context or {},
"metadata": metadata or {},
}
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
response = await client.post(
f"{self.base_url}/v1/tools/{tool_name}/invoke",
json=payload,
headers=self._headers(),
)
response.raise_for_status()
return response.json()

View File

@@ -0,0 +1,25 @@
from .client import BackendClient
from .config import BackendRegistry
from .models import (
BackendCallResult,
BackendDefinition,
BackendRegistryConfig,
GlobalRouteDecision,
GlobalRouteRequest,
GlobalSessionState,
)
from .router import GlobalSupervisorRouter
from .session_store import InMemoryGlobalSessionStore
__all__ = [
"BackendClient",
"BackendRegistry",
"BackendCallResult",
"BackendDefinition",
"BackendRegistryConfig",
"GlobalRouteDecision",
"GlobalRouteRequest",
"GlobalSessionState",
"GlobalSupervisorRouter",
"InMemoryGlobalSessionStore",
]

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
import time
from typing import Any
import httpx
from .models import BackendCallResult, BackendDefinition, GlobalRouteDecision
class BackendClient:
def __init__(self, timeout_seconds: float = 120.0):
self.timeout_seconds = timeout_seconds
async def call_message(
self,
backend: BackendDefinition,
request_payload: dict[str, Any],
route_decision: GlobalRouteDecision,
use_sse: bool = False,
) -> BackendCallResult:
path = backend.sse_message_path if use_sse else backend.message_path
url = f"{backend.base_url}{path}"
payload = dict(request_payload)
# Mantém compatibilidade com agent_template_backend.
payload.setdefault("agent_id", backend.default_agent_id)
payload.setdefault("tenant_id", request_payload.get("tenant_id"))
inner = payload.setdefault("payload", {}) if isinstance(payload.get("payload"), dict) else None
if inner is not None:
inner.setdefault("selected_backend", backend.backend_id)
inner.setdefault("global_route_decision", route_decision.model_dump(mode="json"))
started = time.time()
async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
resp = await client.post(url, json=payload)
elapsed_ms = int((time.time() - started) * 1000)
resp.raise_for_status()
data = resp.json()
return BackendCallResult(
backend_id=backend.backend_id,
backend_url=backend.base_url,
status_code=resp.status_code,
response=data,
route_decision=route_decision,
elapsed_ms=elapsed_ms,
)
async def health(self, backend: BackendDefinition) -> dict[str, Any]:
url = f"{backend.base_url}{backend.health_path}"
async with httpx.AsyncClient(timeout=10.0) as client:
try:
resp = await client.get(url)
return {"backend_id": backend.backend_id, "status_code": resp.status_code, "ok": resp.is_success, "body": self._safe_json(resp)}
except Exception as exc:
return {"backend_id": backend.backend_id, "ok": False, "error": str(exc)}
def _safe_json(self, resp: httpx.Response) -> Any:
try:
return resp.json()
except Exception:
return resp.text[:500]

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
import yaml
from .models import BackendDefinition, BackendRegistryConfig
class BackendRegistry:
def __init__(self, config: BackendRegistryConfig):
self.config = config
self.backends: dict[str, BackendDefinition] = {
b.backend_id: b for b in config.backends if b.enabled
}
if not self.backends:
raise ValueError("Nenhum backend habilitado no registry do Global Supervisor.")
@classmethod
def from_yaml(cls, path: str | Path) -> "BackendRegistry":
p = Path(path)
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
raw_backends = data.get("backends") or []
# Aceita lista ou dict para facilitar edição humana do YAML.
if isinstance(raw_backends, dict):
normalized = []
for backend_id, value in raw_backends.items():
item = dict(value or {})
item.setdefault("backend_id", backend_id)
normalized.append(item)
raw_backends = normalized
config = BackendRegistryConfig(
default_backend=data.get("default_backend"),
backends=[BackendDefinition(**b) for b in raw_backends],
)
return cls(config)
def get(self, backend_id: str) -> BackendDefinition:
try:
return self.backends[backend_id]
except KeyError as exc:
raise KeyError(f"Backend não registrado ou desabilitado: {backend_id}") from exc
def default(self) -> BackendDefinition:
if self.config.default_backend and self.config.default_backend in self.backends:
return self.backends[self.config.default_backend]
return sorted(self.backends.values(), key=lambda b: b.priority)[0]
def list(self) -> list[BackendDefinition]:
return sorted(self.backends.values(), key=lambda b: (b.priority, b.backend_id))
def describe_for_prompt(self) -> str:
lines: list[str] = []
for b in self.list():
lines.append(
f"- {b.backend_id}: {b.description} | domínios={', '.join(b.domains)} | exemplos={'; '.join(b.examples[:3])}"
)
return "\n".join(lines)
def as_dict(self) -> dict[str, Any]:
return {
"default_backend": self.config.default_backend,
"backends": [b.model_dump(mode="json") for b in self.list()],
}

View File

@@ -0,0 +1,79 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
from pydantic import BaseModel, Field
RoutingMode = Literal["router", "supervisor", "hybrid"]
class BackendDefinition(BaseModel):
"""Contrato de um backend de agente registrado no Global Supervisor."""
backend_id: str = Field(..., description="Identificador lógico. Ex.: contas, ofertas, suporte")
name: str | None = None
url: str = Field(..., description="Base URL do backend, sem barra final")
description: str = ""
domains: list[str] = Field(default_factory=list)
keywords: list[str] = Field(default_factory=list)
examples: list[str] = Field(default_factory=list)
priority: int = 100
enabled: bool = True
health_path: str = "/health"
message_path: str = "/gateway/message"
sse_message_path: str = "/gateway/message/sse"
events_path_template: str = "/gateway/events/{session_id}"
default_agent_id: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
@property
def base_url(self) -> str:
return self.url.rstrip("/")
class BackendRegistryConfig(BaseModel):
default_backend: str | None = None
backends: list[BackendDefinition] = Field(default_factory=list)
class GlobalRouteRequest(BaseModel):
channel: str = "web"
payload: dict[str, Any] = Field(default_factory=dict)
tenant_id: str | None = None
session_id: str | None = None
current_backend: str | None = None
force_backend: str | None = None
mode: RoutingMode | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class GlobalRouteDecision(BaseModel):
backend_id: str
confidence: float = 0.0
reason: str = ""
mode: RoutingMode = "hybrid"
used_llm: bool = False
keep_active_backend: bool = False
candidates: list[dict[str, Any]] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
class BackendCallResult(BaseModel):
backend_id: str
backend_url: str
status_code: int
response: dict[str, Any]
route_decision: GlobalRouteDecision
elapsed_ms: int
@dataclass
class GlobalSessionState:
session_id: str
tenant_id: str = "default"
active_backend: str | None = None
active_domain: str | None = None
turn_count: int = 0
metadata: dict[str, Any] = field(default_factory=dict)

View File

@@ -0,0 +1,258 @@
from __future__ import annotations
import json
import logging
import re
from typing import Any
from .config import BackendRegistry
from .models import BackendDefinition, GlobalRouteDecision, GlobalRouteRequest, RoutingMode
from .session_store import InMemoryGlobalSessionStore
logger = logging.getLogger("agent_framework.global_supervisor")
_TERMINAL_WORDS = {
"obrigado", "obrigada", "valeu", "tchau", "encerrar", "fim", "cancelar atendimento"
}
class GlobalSupervisorRouter:
"""Roteador global entre backends.
Modos:
- router: usa regras/keywords/domínios do YAML.
- supervisor: usa LLM para escolher backend.
- hybrid: mantém backend ativo quando coerente; usa router; chama LLM quando ambíguo.
"""
def __init__(
self,
registry: BackendRegistry,
llm: Any | None = None,
session_store: InMemoryGlobalSessionStore | None = None,
mode: RoutingMode = "hybrid",
keep_active_backend: bool = True,
use_supervisor_on_conflict: bool = True,
min_router_confidence: float = 0.55,
):
self.registry = registry
self.llm = llm
self.session_store = session_store or InMemoryGlobalSessionStore()
self.mode = mode
self.keep_active_backend = keep_active_backend
self.use_supervisor_on_conflict = use_supervisor_on_conflict
self.min_router_confidence = min_router_confidence
async def route(self, request: GlobalRouteRequest) -> GlobalRouteDecision:
mode = request.mode or self.mode
session_id = self._session_id(request)
tenant_id = request.tenant_id or request.payload.get("tenant_id") or "default"
if request.force_backend:
decision = self._forced_decision(request.force_backend, mode)
await self.session_store.set_active_backend(session_id, decision.backend_id, tenant_id, forced=True)
return decision
state = await self.session_store.get(session_id)
text = self._extract_text(request).strip()
if mode == "router":
decision = self._route_by_rules(text, mode)
elif mode == "supervisor":
decision = await self._route_by_llm(text, request, mode)
else:
decision = await self._route_hybrid(text, request, state, mode)
await self.session_store.set_active_backend(
session_id,
decision.backend_id,
tenant_id,
last_reason=decision.reason,
last_mode=decision.mode,
last_confidence=decision.confidence,
)
return decision
async def _route_hybrid(self, text: str, request: GlobalRouteRequest, state, mode: RoutingMode) -> GlobalRouteDecision:
# Se a conversa já tem backend ativo e a mensagem parece continuação curta, mantenha.
active_backend = request.current_backend or (state.active_backend if state else None)
if self.keep_active_backend and active_backend and active_backend in self.registry.backends:
if self._looks_like_followup(text):
return GlobalRouteDecision(
backend_id=active_backend,
confidence=0.78,
reason="Mensagem parece continuação; mantendo backend ativo da sessão.",
mode=mode,
keep_active_backend=True,
)
rule_decision = self._route_by_rules(text, mode)
if rule_decision.confidence >= self.min_router_confidence:
return rule_decision
if self.use_supervisor_on_conflict and self.llm:
llm_decision = await self._route_by_llm(text, request, mode, fallback=rule_decision)
return llm_decision
if active_backend and active_backend in self.registry.backends:
return GlobalRouteDecision(
backend_id=active_backend,
confidence=0.50,
reason="Router ficou ambíguo; mantendo backend ativo por política híbrida.",
mode=mode,
keep_active_backend=True,
candidates=rule_decision.candidates,
)
return rule_decision
def _route_by_rules(self, text: str, mode: RoutingMode) -> GlobalRouteDecision:
normalized = self._normalize(text)
scored: list[tuple[float, BackendDefinition, list[str]]] = []
for backend in self.registry.list():
hits: list[str] = []
score = 0.0
for kw in backend.keywords:
nkw = self._normalize(kw)
if nkw and nkw in normalized:
hits.append(kw)
score += 1.0
for domain in backend.domains:
nd = self._normalize(domain)
if nd and nd in normalized:
hits.append(domain)
score += 0.7
if score:
# prioridade menor aumenta levemente confiança
score += max(0, (200 - backend.priority)) / 1000
scored.append((score, backend, hits))
scored.sort(key=lambda x: (-x[0], x[1].priority, x[1].backend_id))
best_score, best_backend, hits = scored[0] if scored else (0.0, self.registry.default(), [])
if best_score <= 0:
best_backend = self.registry.default()
confidence = 0.25
reason = "Nenhuma regra forte encontrada; usando backend default."
else:
# normalização simples para 0..1
confidence = min(0.95, 0.35 + best_score / 4)
reason = f"Backend escolhido por regras: matches={hits}."
candidates = [
{"backend_id": b.backend_id, "score": round(s, 3), "matches": h}
for s, b, h in scored[:5]
]
return GlobalRouteDecision(
backend_id=best_backend.backend_id,
confidence=confidence,
reason=reason,
mode=mode,
used_llm=False,
candidates=candidates,
)
async def _route_by_llm(
self,
text: str,
request: GlobalRouteRequest,
mode: RoutingMode,
fallback: GlobalRouteDecision | None = None,
) -> GlobalRouteDecision:
if not self.llm:
return fallback or self._route_by_rules(text, mode)
prompt = self._build_supervisor_prompt(text, request)
try:
raw = await self.llm.ainvoke([
{"role": "system", "content": "Você é um supervisor global de backends. Responda somente JSON válido."},
{"role": "user", "content": prompt},
], temperature=0, profile_name="supervisor", component_name="supervisor", generation_name="llm.supervisor")
data = self._parse_json(raw)
backend_id = str(data.get("backend") or data.get("backend_id") or "").strip()
if backend_id not in self.registry.backends:
raise ValueError(f"LLM retornou backend inválido: {backend_id!r}")
return GlobalRouteDecision(
backend_id=backend_id,
confidence=float(data.get("confidence", 0.75)),
reason=str(data.get("reason", "Selecionado pelo supervisor LLM.")),
mode=mode,
used_llm=True,
candidates=(fallback.candidates if fallback else []),
metadata={"raw_llm": raw},
)
except Exception as exc:
logger.exception("Falha no supervisor LLM; usando fallback/router: %s", exc)
decision = fallback or self._route_by_rules(text, mode)
decision.reason = f"Fallback após falha do supervisor LLM: {decision.reason}"
return decision
def _build_supervisor_prompt(self, text: str, request: GlobalRouteRequest) -> str:
history = request.payload.get("history") or request.metadata.get("history") or []
return (
"Escolha o backend mais adequado para atender a mensagem do usuário.\n\n"
"Backends disponíveis:\n"
f"{self.registry.describe_for_prompt()}\n\n"
"Mensagem atual:\n"
f"{text}\n\n"
"Histórico/metadata resumidos:\n"
f"{json.dumps({'history': history[-6:] if isinstance(history, list) else history, 'metadata': request.metadata}, ensure_ascii=False)[:4000]}\n\n"
"Retorne somente JSON neste formato:\n"
'{"backend":"<id>","confidence":0.0,"reason":"..."}'
)
def _forced_decision(self, backend_id: str, mode: RoutingMode) -> GlobalRouteDecision:
self.registry.get(backend_id)
return GlobalRouteDecision(
backend_id=backend_id,
confidence=1.0,
reason="Backend forçado na requisição.",
mode=mode,
used_llm=False,
)
def _looks_like_followup(self, text: str) -> bool:
n = self._normalize(text)
if not n:
return True
if n in _TERMINAL_WORDS:
return False
tokens = n.split()
followup_markers = ["esse", "essa", "isso", "valor", "ele", "ela", "tambem", "e ", "entao", "nesse", "nessa"]
return len(tokens) <= 6 or any(marker in n for marker in followup_markers)
def _extract_text(self, request: GlobalRouteRequest) -> str:
payload = request.payload or {}
for key in ("text", "message", "input", "user_text"):
if payload.get(key):
return str(payload[key])
if isinstance(payload.get("payload"), dict):
inner = payload["payload"]
for key in ("text", "message", "input", "user_text"):
if inner.get(key):
return str(inner[key])
return str(payload)
def _session_id(self, request: GlobalRouteRequest) -> str:
payload = request.payload or {}
return (
request.session_id
or payload.get("session_id")
or payload.get("conversation_key")
or request.metadata.get("session_id")
or "global-default-session"
)
def _normalize(self, text: str) -> str:
text = text.lower()
text = re.sub(r"[^a-z0-9áàâãéêíóôõúçñ\s]", " ", text)
text = re.sub(r"\s+", " ", text)
return text.strip()
def _parse_json(self, raw: Any) -> dict[str, Any]:
if isinstance(raw, dict):
return raw
text = str(raw).strip()
if text.startswith("```"):
text = re.sub(r"^```(?:json)?", "", text).strip()
text = re.sub(r"```$", "", text).strip()
match = re.search(r"\{.*\}", text, flags=re.S)
if match:
text = match.group(0)
return json.loads(text)

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
import time
from dataclasses import asdict
from .models import GlobalSessionState
class InMemoryGlobalSessionStore:
"""Store simples para o Agent Gateway.
Em produção, use o mesmo repositório compartilhado dos backends
(Autonomous DB/Mongo/Redis) para manter handoff entre serviços.
"""
def __init__(self, ttl_seconds: int = 3600):
self.ttl_seconds = ttl_seconds
self._data: dict[str, tuple[float, GlobalSessionState]] = {}
async def get(self, session_id: str) -> GlobalSessionState | None:
item = self._data.get(session_id)
if not item:
return None
ts, state = item
if time.time() - ts > self.ttl_seconds:
self._data.pop(session_id, None)
return None
return state
async def upsert(self, state: GlobalSessionState) -> None:
state.turn_count += 1
self._data[state.session_id] = (time.time(), state)
async def set_active_backend(self, session_id: str, backend_id: str, tenant_id: str = "default", **metadata) -> GlobalSessionState:
state = await self.get(session_id) or GlobalSessionState(session_id=session_id, tenant_id=tenant_id)
state.active_backend = backend_id
state.metadata.update(metadata)
await self.upsert(state)
return state
async def dump(self) -> dict:
return {k: asdict(v[1]) for k, v in self._data.items()}
async def rename_session(
self,
old_session_id: str,
new_session_id: str
) -> GlobalSessionState | None:
item = self._data.pop(old_session_id, None)
if not item:
return None
ts, state = item
state.session_id = new_session_id
self._data[new_session_id] = (ts, state)
return state

View File

@@ -0,0 +1,60 @@
from .base import Guardrail, RailDecision
from .pipeline import GuardrailPipeline
from .llm_rails import LLMGuardrailRail, LLMOutputGRLRail
from .rails import (
ComplianceRail,
DataLeakageInputRail,
DataLeakageOutputRail,
GroundednessRail,
HallucinationRiskRail,
JailbreakRail,
LoopRail,
MessageSizeRail,
OutOfScopeRail,
OutputPiiMaskRail,
OutputToxicitySanitizationRail,
PiiMaskRail,
PrematureActionRail,
ProactiveOfferRail,
PromptInjectionRail,
RagSecurityRail,
RetrievalRelevanceRail,
ToolValidationRail,
ToxicityRail,
)
__all__ = [
"Guardrail",
"RailDecision",
"GuardrailPipeline",
"LLMGuardrailRail",
"LLMOutputGRLRail",
"PiiMaskRail",
"OutputPiiMaskRail",
"OutputToxicitySanitizationRail",
"ToxicityRail",
"PromptInjectionRail",
"JailbreakRail",
"MessageSizeRail",
"OutOfScopeRail",
"LoopRail",
"PrematureActionRail",
"ProactiveOfferRail",
"RagSecurityRail",
"ComplianceRail",
"DataLeakageInputRail",
"DataLeakageOutputRail",
"GroundednessRail",
"HallucinationRiskRail",
"RetrievalRelevanceRail",
"ToolValidationRail",
"ParallelRailExecutor",
"ParallelRailExecution",
]
from .rail_action import RailAction
from .rail_result import RailResult
from .rail_decision import RailDecisionV2
from .output_supervisor import OutputSupervisor
from .custom_rails import CustomRails
from .parallel_executor import ParallelRailExecutor, ParallelRailExecution

View File

@@ -0,0 +1,15 @@
from pydantic import BaseModel, Field
from typing import Any
class RailDecision(BaseModel):
code: str
allowed: bool = True
reason: str = ''
sanitized_text: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class Guardrail:
code = 'BASE'
stage = 'input'
async def evaluate(self, text: str, context: dict[str, Any]) -> RailDecision:
return RailDecision(code=self.code, allowed=True)

View File

@@ -0,0 +1,86 @@
"""Guardrails de Supervisao TIM (extensao do agent_framework).
Padrao de uso:
from agent_framework.guardrails.calibrated import (
apply_input_rails,
apply_output_rails,
sanitizar_output,
)
# Input — MSK sanitiza PII e OOS bloqueia fora de escopo.
in_decision = apply_input_rails(user_text)
if not in_decision.allowed:
return in_decision.fallback_text
user_text = in_decision.sanitized_text or user_text
result = agent.run(user_text=user_text)
# Output sanitization (PII + toxicidade, sanitize-and-pass-through).
sanitized = sanitizar_output(result["content"])
result["content"] = sanitized.sanitized_text or result["content"]
# Output rails bloqueantes.
out_decision = apply_output_rails(
text=result["content"],
tool_calls=result.get("tool_calls"),
)
if not out_decision.allowed:
result["content"] = out_decision.fallback_text # AOFERTA ou REVPREC
Rails ativos:
- MSK — input/output sanitize; mascara PII antes do LLM e na resposta final.
- OOS — input rail; bloqueia mensagens fora do escopo de contas/faturas TIM.
- AOFERTA (extensao local) — output rail; supervisor LLM contra oferta proativa.
- REVPREC (extensao local) — output rail contra promessa operacional futura;
prompt em prompts/revprec.py, routing via GuardrailLLMClient.
- TOXOUT (extensao local) — sanitizacao toxica do output em 3 niveis.
Conformidade:
- RailResult eh importado de agent_framework.guardrails_old.nemo.models (mesma estrutura).
- USE_MOCK_LLM env var respeitada (mesmo nome/default da lib).
- Multi-provider via TIM_LLM_PROVIDER (oci/openai/groq/...) para AOFERTA e
TOXOUT atraves de agent_framework.llm.providers.create_llm.
"""
from .input_size import verificar_tamanho_input
from .llm_rails import ausencia_oferta_proativa, compliance_anatel, out_of_scope, detectar_toxicidade
from .contestation_validation import validate_contestation_items
from .output_sanitization import (
mascarar_pii_output,
sanitizar_output,
sanitizar_toxicidade_output,
)
from .pipeline import (
RailDecision,
apply_input_rails,
apply_output_rails,
_verbalizacao_prematura,
)
def verbalizacao_prematura(
text: str,
context: dict | None = None,
callbacks: list | None = None,
):
return _verbalizacao_prematura(
text,
context=context,
callbacks=callbacks,
)
__all__ = [
"verificar_tamanho_input",
"ausencia_oferta_proativa",
"detectar_toxicidade",
"compliance_anatel",
"out_of_scope",
"apply_input_rails",
"apply_output_rails",
"validate_contestation_items",
"verbalizacao_prematura",
"mascarar_pii_output",
"sanitizar_output",
"sanitizar_toxicidade_output",
"RailDecision",
]

View File

@@ -0,0 +1,44 @@
"""Compatibilidade com primitivos do agent_framework.guardrails_old.
A lib (agent_framework 2.1.1) tem dois imports eager problematicos:
1. agent_framework/__init__.py instancia google.cloud.pubsub_v1.PublisherClient
no carregamento, exigindo GOOGLE_APPLICATION_CREDENTIALS no ambiente.
2. agent_framework/guardrails/nemo/__init__.py importa .factory que importa
nemoguardrails, mesmo para usos do Padrao 1 (rails individuais) que o
guia da lib documenta como nao requerendo nemoguardrails.
Este modulo tenta importar RailResult e span direto da lib legacy
(`guardrails_old`) para manter compatibilidade com os rails NeMo antigos.
Quando isso falha por qualquer motivo, cai num clone local com
exatamente os mesmos campos/assinaturas — instancias sao estruturalmente
indistinguiveis das da lib, intercambiaveis em qualquer downstream
(serializers, dashboards, executar_atendimento etc).
"""
from __future__ import annotations
try:
from agent_framework.guardrails_old.nemo.models import RailResult # noqa: F401
from agent_framework.guardrails_old.nemo.tracing import span # noqa: F401
except Exception:
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any
@dataclass
class RailResult:
allowed: bool
reason: str
sanitized_text: str | None = None
code: str | None = None
mechanism: str | None = None
data: dict[str, Any] | None = None
timings_ms: dict[str, float] = field(default_factory=dict)
latency_ms: float = 0.0
@contextmanager
def span(name: str, **kwargs):
yield
__all__ = ["RailResult", "span"]

View File

@@ -0,0 +1,23 @@
id: guardrail_pinj
prompt_id: guardrail_pinj
version: 2
description: >
Detecta prompt injection, jailbreak e tentativas de override de instrucoes
no input do cliente. Versao 2: prompt expandido de 22 para 181 linhas com
7 categorias de injection, 11 exemplos positivos, 6 falso-positivos e
excecoes explicitas para o dominio TIM. Prompts estruturados com exemplos
canonicos permitem execucao em modelo leve sem perda de cobertura.
prompt_source: builtin
execution_mode: completion
prompt_type: text
model_variant: 20b
# Criterio de downgrade de 120b -> 20b (AT-15):
# Anterior: 120b como compensacao pelo prompt subdimensionado (22 linhas, 0 exemplos)
# Atual: 20b habilitado apos reescrita com exemplos canonicos e criterios explícitos
#
# Limiar de aprovacao em homologacao (a validar antes de ativar em producao):
# - Recall em injections conhecidas: > 99%
# - Falso-negativo em injections sofisticadas: < 1%
# - Falso-positivo em pedidos TIM legitimos: < 0.5%
# - Dataset de avaliacao: minimo 200 inputs (positivos + negativos)

View File

@@ -0,0 +1,123 @@
"""Configuração feature-flag dos guardrails TIM.
Usa pydantic_settings.BaseSettings quando disponível (lê variáveis de
ambiente e .env automaticamente). Cai em dataclass com os.getenv quando
pydantic_settings não estiver instalado.
Convenção de nomes de env var: prefixo GUARDRAIL_ + nome do campo em
maiúsculas. Ex.: GUARDRAIL_PINJ_ENABLED, GUARDRAIL_TEST_MODE.
Exemplo de uso:
from agent_framework.guardrails.calibrated.config import GuardRailConfig
cfg = GuardRailConfig()
if cfg.oos_enabled:
...
"""
from __future__ import annotations
import os
from decimal import Decimal
try:
from pydantic_settings import BaseSettings
from pydantic import Field
class GuardRailConfig(BaseSettings):
"""Feature flags e limites dos guardrails TIM.
Todos os campos têm defaults conservadores (False / zero) para que
o pipeline mantenha o comportamento atual enquanto rails novos são
validados em staging.
Grupos:
Input rails:
pinj_enabled — Prompt Injection / Jailbreak.
input_size_enabled — Tamanho máximo de input.
msk_enabled — Mascaramento de PII no input.
tox_enabled — Toxicidade no input (desativado por latência).
dlex_in_enabled — Data Leakage no input.
Output rails:
oos_enabled — Out-of-Scope.
aoferta_enabled — Ausência de Oferta Proativa.
anatel_enabled — Compliance Anatel (protocolo obrigatório).
revprec_enabled — Verbalizacao Prematura.
ragsec_enabled — RAG Security / Context Poisoning.
dlex_out_enabled — Data Leakage no output.
Test:
test_mode — Ativa bypass controlado p/ testes de fumaça.
Substitui o bypass hardcoded ###teste[1,2,3,4]###
que existia em out_of_scope.py.
Específicos:
alcada_ajuste_enabled — Habilita validação de alçada em ajustes.
alcada_ajuste_max_value — Valor máximo (R$) permitido sem escalonamento.
"""
model_config = {"env_prefix": "GUARDRAIL_", "env_file": ".env", "extra": "ignore"}
# --- Input rails ---
pinj_enabled: bool = Field(default=True)
input_size_enabled: bool = Field(default=True)
msk_enabled: bool = Field(default=True)
tox_enabled: bool = Field(default=False)
dlex_in_enabled: bool = Field(default=False)
# --- Output rails ---
oos_enabled: bool = Field(default=True)
aoferta_enabled: bool = Field(default=True)
anatel_enabled: bool = Field(default=True)
revprec_enabled: bool = Field(default=False)
ragsec_enabled: bool = Field(default=False)
dlex_out_enabled: bool = Field(default=False)
# --- Test mode ---
test_mode: bool = Field(default=False)
# --- Alçada de ajuste ---
alcada_ajuste_enabled: bool = Field(default=False)
alcada_ajuste_max_value: Decimal = Field(default=Decimal("0"))
except ImportError:
# Fallback para dataclass quando pydantic_settings não está disponível.
import dataclasses
def _bool_env(name: str, default: bool) -> bool:
val = os.getenv(f"GUARDRAIL_{name.upper()}", str(default)).lower()
return val in ("1", "true", "yes", "on")
def _decimal_env(name: str, default: Decimal) -> Decimal:
val = os.getenv(f"GUARDRAIL_{name.upper()}")
if val is None:
return default
try:
return Decimal(val)
except Exception:
return default
@dataclasses.dataclass
class GuardRailConfig: # type: ignore[no-redef]
"""Feature flags e limites dos guardrails TIM (fallback sem pydantic_settings)."""
# Input rails
pinj_enabled: bool = dataclasses.field(default_factory=lambda: _bool_env("pinj_enabled", True))
input_size_enabled: bool = dataclasses.field(default_factory=lambda: _bool_env("input_size_enabled", True))
msk_enabled: bool = dataclasses.field(default_factory=lambda: _bool_env("msk_enabled", True))
tox_enabled: bool = dataclasses.field(default_factory=lambda: _bool_env("tox_enabled", False))
dlex_in_enabled: bool = dataclasses.field(default_factory=lambda: _bool_env("dlex_in_enabled", False))
# Output rails
oos_enabled: bool = dataclasses.field(default_factory=lambda: _bool_env("oos_enabled", True))
aoferta_enabled: bool = dataclasses.field(default_factory=lambda: _bool_env("aoferta_enabled", True))
anatel_enabled: bool = dataclasses.field(default_factory=lambda: _bool_env("anatel_enabled", True))
revprec_enabled: bool = dataclasses.field(default_factory=lambda: _bool_env("revprec_enabled", False))
ragsec_enabled: bool = dataclasses.field(default_factory=lambda: _bool_env("ragsec_enabled", False))
dlex_out_enabled: bool = dataclasses.field(default_factory=lambda: _bool_env("dlex_out_enabled", False))
# Test mode
test_mode: bool = dataclasses.field(default_factory=lambda: _bool_env("test_mode", False))
# Alçada de ajuste
alcada_ajuste_enabled: bool = dataclasses.field(default_factory=lambda: _bool_env("alcada_ajuste_enabled", False))
alcada_ajuste_max_value: Decimal = dataclasses.field(default_factory=lambda: _decimal_env("alcada_ajuste_max_value", Decimal("0")))
__all__ = ["GuardRailConfig"]

View File

@@ -0,0 +1,550 @@
from __future__ import annotations
from contextlib import nullcontext
from decimal import Decimal, ROUND_HALF_UP
import logging
import os
import re
import unicodedata as ud
from typing import Any
_CENT = Decimal("0.01")
_GUARDRAIL_ACTION = "abrir_contestacao_cliente"
_GUARDRAIL_CODE = "CVAL"
_STRATEGIC_SERVICE_ALIASES = (
"apple music",
"deezer",
"disney",
"fuze",
"forge",
"hbo",
"looke",
"netflix",
"paramount",
"paramount+",
"paramount plus",
"tim cloud gaming",
"youtube",
"youtube premium",
)
logger = logging.getLogger(__name__)
def _money(value: Decimal) -> Decimal:
return value.quantize(_CENT, rounding=ROUND_HALF_UP)
def _parse_amount(value: str) -> Decimal | None:
if not value:
return None
cleaned = (
str(value)
.replace("R$", "")
.replace(" ", "")
.replace(".", "")
.replace(",", ".")
)
try:
return Decimal(cleaned)
except Exception:
return None
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))
return _parse_amount(str(value or ""))
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_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 Exception:
return default
if "." in normalized:
normalized = normalized.rstrip("0").rstrip(".")
return normalized or default
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 _normalize_service_name_for_match(value: Any) -> str:
normalized = ud.normalize("NFKD", str(value or "").lower())
without_accents = "".join(ch for ch in normalized if not ud.combining(ch))
return re.sub(r"[^a-z0-9]+", "", without_accents)
def _is_strategic_partner_service(value: Any) -> bool:
normalized = _normalize_service_name_for_match(value)
if not normalized:
return False
for alias in _STRATEGIC_SERVICE_ALIASES:
normalized_alias = _normalize_service_name_for_match(alias)
if normalized_alias and normalized_alias in normalized:
return True
return False
def _is_vas_section_name(section_name: str) -> bool:
normalized = _normalize_match_text(section_name)
return (
"vas" in normalized
or "valor adicionado" in normalized
or "servicos de valor adicionado" in normalized
or "servicos valor adicionado" in normalized
or "sva detalhe total" in normalized
)
def _extract_invoice_total_geral(payload: Any) -> Decimal | None:
if isinstance(payload, dict):
desc = _normalize_match_text(payload.get("desc", ""))
if desc == "total geral":
total = _decimal_from_any(
payload.get("value")
if "value" in payload
else payload.get("valor")
)
if total is not None:
return total
for value in payload.values():
if isinstance(value, (dict, list, tuple)):
result = _extract_invoice_total_geral(value)
if result is not None:
return result
elif isinstance(payload, (list, tuple)):
for entry in payload:
if isinstance(entry, (dict, list, tuple)):
result = _extract_invoice_total_geral(entry)
if result is not None:
return result
return None
def _extract_contestation_invoice_items(
payload: Any,
*,
section_name: str = "",
) -> list[dict[str, Any]]:
found: list[dict[str, Any]] = []
if isinstance(payload, dict):
candidate_name = str(
payload.get("desc")
or payload.get("name")
or payload.get("service_name")
or payload.get("item_name")
or payload.get("itemName")
or payload.get("servico")
or ""
).strip()
candidate_amount = _first_decimal_from_mapping(
payload,
"valor_final",
"valor",
"price",
"amount",
"value",
"valor_bruto",
"claimedAmount",
"validatedAmount",
)
if candidate_name and candidate_amount is not None and candidate_amount > 0:
found.append(
{
"name": candidate_name,
"amount": _money(candidate_amount),
"is_vas": _is_vas_section_name(section_name),
"section": section_name,
"classe": str(payload.get("classe", "")).strip().lower(),
"estrategico": bool(payload.get("estrategico")),
"verb": str(payload.get("verb", "")).strip().lower(),
}
)
for key, value in payload.items():
next_section = section_name
if isinstance(key, str) and _is_vas_section_name(key):
next_section = key
if isinstance(value, (dict, list, tuple)):
found.extend(
_extract_contestation_invoice_items(
value,
section_name=next_section,
)
)
return found
if isinstance(payload, (list, tuple)):
for item in payload:
if isinstance(item, (dict, list, tuple)):
found.extend(
_extract_contestation_invoice_items(
item,
section_name=section_name,
)
)
return found
def _has_langfuse_credentials() -> bool:
return bool(
os.getenv("LANGFUSE_PUBLIC_KEY", "").strip()
and os.getenv("LANGFUSE_SECRET_KEY", "").strip()
)
def _start_guardrail_observation(
*,
name: str,
input: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
) -> Any:
if not _has_langfuse_credentials():
return nullcontext(None)
try:
from langfuse import get_client
return get_client().start_as_current_observation(
name=name,
as_type="span",
input=input,
metadata=metadata,
)
except Exception:
logger.debug(
"langfuse.contestation_guardrail_start_failed name=%s",
name,
exc_info=True,
)
return nullcontext(None)
def _summarize_requested_items(items: list[dict[str, Any]]) -> list[dict[str, str]]:
summary: list[dict[str, str]] = []
for item in items:
summary.append(
{
"item_name": str(item.get("item_name", "") or "").strip(),
"claimed_amount": _normalize_number_text(
item.get("claimed_amount", "0")
),
"validated_amount": _normalize_number_text(
item.get("validated_amount", "0")
),
}
)
return summary
def _validation_reason(validation_log: list[dict[str, Any]]) -> str:
for entry in validation_log:
reason = entry.get("erro")
if reason:
return str(reason).strip()
return ""
def _emit_contestation_validation_block_span(
*,
items: list[dict[str, Any]],
candidates: list[dict[str, Any]],
validation_log: list[dict[str, Any]],
validation_error: str,
) -> None:
reason = _validation_reason(validation_log)
approved_count = sum(
1 for entry in validation_log if entry.get("status") == "aprovado"
)
rejected_count = sum(
1 for entry in validation_log if entry.get("status") == "reprovado"
)
try:
with _start_guardrail_observation(
name=f"guardrail.{_GUARDRAIL_CODE}.blocked",
input={
"items_count": len(items),
"items": _summarize_requested_items(items),
"invoice_candidates_count": len(candidates),
},
metadata={
"mechanism": "guardrail_action_validation",
"code": _GUARDRAIL_CODE,
"action": _GUARDRAIL_ACTION,
"reason": reason,
},
) as obs:
if obs is None:
return
obs.update(
level="WARNING",
output={
"blocked": True,
"error": validation_error,
"items_validated_count": len(validation_log),
"items_approved_count": approved_count,
"items_rejected_count": rejected_count,
"validation_log": validation_log,
"code": _GUARDRAIL_CODE,
},
)
except Exception:
logger.debug(
"langfuse.contestation_guardrail_update_failed code=%s",
_GUARDRAIL_CODE,
exc_info=True,
)
def validate_contestation_items(
items: list[dict[str, Any]],
invoice_payload: dict[str, Any],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], str | None]:
candidates = _extract_contestation_invoice_items(invoice_payload)
validation_log: list[dict[str, Any]] = []
with _start_guardrail_observation(
name=f"guardrail.{_GUARDRAIL_CODE}.evaluated",
input={
"items_count": len(items),
"items": _summarize_requested_items(items),
"invoice_candidates_count": len(candidates),
},
metadata={
"mechanism": "guardrail_action_validation",
"code": _GUARDRAIL_CODE,
"action": _GUARDRAIL_ACTION,
},
) as obs:
def _safe_update(**kwargs: Any) -> None:
if obs is None:
return
try:
obs.update(**kwargs)
except Exception:
logger.debug(
"langfuse.contestation_guardrail_update_failed code=%s",
_GUARDRAIL_CODE,
exc_info=True,
)
first_error: str | None = None
def _record_failure(
item_log: dict[str, Any],
erro: str,
message: str,
) -> None:
nonlocal first_error
item_log["status"] = "reprovado"
item_log["erro"] = erro
validation_log.append(item_log)
if first_error is None:
first_error = message
for item in items:
claimed = Decimal(_normalize_number_text(item.get("claimed_amount", "0")))
validated = Decimal(
_normalize_number_text(item.get("validated_amount", "0"))
)
item_name = str(item.get("item_name", "")).strip()
if not item_name:
continue
item_log: dict[str, Any] = {
"item_name": item_name,
"item_na_fatura": False,
"item_confirmado": False,
"secao_vas": False,
"valor_item_fatura": "",
"valor_ajuste_solicitado": _normalize_number_text(
format(validated, "f")
),
"valor_ajuste_valido": False,
"vas_estrategico": False,
"status": "em_validacao",
}
matched_candidate = next(
(
candidate
for candidate in candidates
if _is_same_plan_name(candidate.get("name", ""), item_name)
),
None,
)
if matched_candidate is None:
_record_failure(
item_log,
"item_nao_encontrado_na_fatura",
f"Item '{item_name}' nao encontrado no json da fatura.",
)
continue
item_log["item_na_fatura"] = True
item_log["item_confirmado"] = True
classe = str(matched_candidate.get("classe", "")).strip().lower()
is_strategic = (
classe == "estrategico"
or bool(matched_candidate.get("estrategico"))
or _is_strategic_partner_service(item_name)
)
is_vas_avulso = classe == "avulso" or (
not classe
and not is_strategic
and bool(matched_candidate.get("is_vas"))
)
if not (is_vas_avulso or is_strategic):
_record_failure(
item_log,
"item_fora_secao_vas",
f"Item '{item_name}' nao e do tipo VAS no json da fatura.",
)
continue
item_log["secao_vas"] = True
item_amount = matched_candidate.get("amount")
if not isinstance(item_amount, Decimal) or item_amount <= 0:
_record_failure(
item_log,
"valor_item_invalido_na_fatura",
f"Nao foi possivel validar o valor do item '{item_name}' na fatura.",
)
continue
item_log["valor_item_fatura"] = _normalize_number_text(
format(item_amount, "f")
)
if is_strategic:
item_log["vas_estrategico"] = True
_record_failure(
item_log,
"vas_estrategico_nao_permitido",
f"Item '{item_name}' identificado como VAS estrategico e nao pode ser ajustado.",
)
continue
if claimed <= 0:
claimed = item_amount
if validated <= 0:
validated = claimed
if validated > item_amount:
_record_failure(
item_log,
"valor_ajuste_maior_que_item",
f"Valor de ajuste do item '{item_name}' excede o valor cobrado na fatura.",
)
continue
item_log["valor_ajuste_solicitado"] = _normalize_number_text(
format(validated, "f")
)
item_log["valor_ajuste_valido"] = True
item_log["status"] = "aprovado"
validation_log.append(item_log)
item["claimed_amount"] = _normalize_number_text(format(claimed, "f"))
item["validated_amount"] = _normalize_number_text(format(validated, "f"))
invoice_total = _extract_invoice_total_geral(invoice_payload)
if invoice_total is not None and invoice_total > 0:
total_ajustes = sum(
(
Decimal(
_normalize_number_text(entry.get("valor_ajuste_solicitado", "0"))
)
for entry in validation_log
if entry.get("status") == "aprovado"
),
Decimal("0"),
)
if total_ajustes > invoice_total:
total_log: dict[str, Any] = {
"item_name": "<total_ajustes>",
"status": "reprovado",
"erro": "total_ajustes_excede_fatura",
"valor_total_ajustes": _normalize_number_text(
format(_money(total_ajustes), "f")
),
"valor_total_fatura": _normalize_number_text(
format(_money(invoice_total), "f")
),
}
validation_log.append(total_log)
if first_error is None:
first_error = (
"Valor total de ajustes ("
f"{total_log['valor_total_ajustes']}) excede o "
f"valor total da fatura ({total_log['valor_total_fatura']})."
)
approved_count = sum(
1 for entry in validation_log if entry.get("status") == "aprovado"
)
rejected_count = sum(
1 for entry in validation_log if entry.get("status") == "reprovado"
)
if first_error is not None:
_emit_contestation_validation_block_span(
items=items,
candidates=candidates,
validation_log=validation_log,
validation_error=first_error,
)
_safe_update(
level="WARNING",
output={
"approved": False,
"items_count": len(items),
"items_validated_count": len(validation_log),
"items_approved_count": approved_count,
"items_rejected_count": rejected_count,
"validation_log": validation_log,
"error": first_error,
"reason": _validation_reason(validation_log),
},
)
return items, validation_log, first_error
_safe_update(
output={
"approved": True,
"items_count": len(items),
"items_validated_count": len(validation_log),
"items_approved_count": approved_count,
"items_rejected_count": rejected_count,
"validation_log": validation_log,
},
)
return items, validation_log, None

View File

@@ -0,0 +1,168 @@
"""Contratos centrais do sistema de guardrails TIM.
Define as abstrações de dados e protocolos que permitem desacoplar
implementações de rails, clientes LLM e o pipeline de orquestração.
- GuardRailContext: dados de entrada que todo rail recebe.
- RailDecision: decisão final do pipeline (re-exportada de pipeline.py
no futuro; por ora definida aqui para uso pelos novos rails).
- Rail: Protocol que todo rail deve implementar.
- GuardRailLLMClient: Protocol para clientes LLM usados pelos rails.
- GuardRailEvent: evento de telemetria emitido por rail executado.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable
# ---------------------------------------------------------------------------
# Contexto de execução
# ---------------------------------------------------------------------------
@dataclass
class GuardRailContext:
"""Dados de contexto que o pipeline passa a cada rail.
Campos:
session_id: identificador da sessão de atendimento.
user_text: texto do usuário (input) ou do agente (output) a avaliar.
conversation_history: histórico recente no formato
[{"role": "user"|"assistant", "content": str}, ...].
agent_metadata: metadados arbitrários do agente (tipo_fluxo,
expected_protocols, msisdn, etc.).
"""
session_id: str
user_text: str
conversation_history: list[dict] = field(default_factory=list)
agent_metadata: dict[str, Any] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# Decisão de rail (espelho do RailDecision em pipeline.py)
# ---------------------------------------------------------------------------
@dataclass
class RailDecision:
"""Resultado de avaliação de um rail individual.
Mantido aqui para que rails novos em guardrails/rails/ possam importar
sem depender de pipeline.py (que importa tudo da infra). pipeline.py
continuará definindo seu próprio RailDecision até a migração completa;
os dois são estruturalmente idênticos e intercambiáveis.
Campos:
allowed: True quando o rail aprova a mensagem.
code: código do rail que gerou a decisão (ex.: "PINJ", "OOS").
reason: explicação legível da decisão.
fallback_text: texto substituto quando allowed=False.
sanitized_text: texto transformado quando o rail faz sanitização.
is_soft_alert: distingue hard-block de soft-alert.
False (default) = hard-block: substituir result["content"] e patchar
histórico quando allowed=False.
True = soft-alert: logar a violação sem alterar a resposta ao cliente
(allowed é ignorado pelo pipeline neste caso).
regen_flag: flag corretiva para re-invocar o agente principal com
constraint adicional de contexto. None indica que o rail não
suporta regeneração e o pipeline deve usar apenas o fallback
estático (_FALLBACK_BY_CODE). String não-vazia é injetada como
mensagem de correção no histórico antes de re-invocar o agente.
"""
allowed: bool
code: str | None = None
reason: str = ""
fallback_text: str | None = None
sanitized_text: str | None = None
# Distingue hard-block (substitui resposta) de soft-alert (apenas loga).
# False = default = hard-block: substituir result["content"] + patchar histórico.
# True = soft-alert: logar violação, não alterar a resposta ao cliente.
is_soft_alert: bool = False
# Flag corretiva para re-invocar o agente principal com constraint.
# None = rail não suporta regeneração (usa apenas fallback estático).
regen_flag: str | None = None
# ---------------------------------------------------------------------------
# Protocolos
# ---------------------------------------------------------------------------
@runtime_checkable
class Rail(Protocol):
"""Protocolo que todo rail deve implementar.
Propriedades:
code: identificador do rail (ex.: "PINJ", "CMP", "ANATEL").
fallback_text: texto de fallback estático; None = rail não é hard-blocking.
regen_flag: flag corretiva para regeneração; None = sem regeneração.
is_soft_alert: True = violação apenas logada; False (default) = hard-block.
Métodos:
evaluate: avalia o contexto e devolve uma RailDecision.
"""
@property
def code(self) -> str:
...
@property
def fallback_text(self) -> str | None:
"""Texto de fallback estático. None = rail não é hard-blocking."""
return None
@property
def regen_flag(self) -> str | None:
"""Flag corretiva para regeneração do agente. None = sem regeneração."""
return None
@property
def is_soft_alert(self) -> bool:
"""True = violação apenas logada. False (default) = hard-block."""
return False
def evaluate(self, context: GuardRailContext) -> RailDecision:
...
@runtime_checkable
class GuardRailLLMClient(Protocol):
"""Protocolo para clientes LLM usados pelos rails.
Método:
invoke: executa uma capability identificada por `capability_id`
com as variáveis de `input_vars` e retorna a resposta como str
(texto bruto do LLM, antes de qualquer parse JSON).
"""
def invoke(self, capability_id: str, input_vars: dict[str, Any]) -> str:
...
# ---------------------------------------------------------------------------
# Evento de telemetria
# ---------------------------------------------------------------------------
@dataclass
class GuardRailEvent:
"""Evento emitido após a execução de um rail, para telemetria / auditoria.
Campos:
session_id: identificador da sessão.
rail_code: código do rail (ex.: "PINJ", "OOS", "CMP").
allowed: resultado da avaliação.
reason: explicação legível da decisão.
latency_ms: tempo de execução do rail em milissegundos.
"""
session_id: str
rail_code: str
allowed: bool
reason: str
latency_ms: float
__all__ = [
"GuardRailContext",
"RailDecision",
"Rail",
"GuardRailLLMClient",
"GuardRailEvent",
]

View File

@@ -0,0 +1,85 @@
"""Rail INPUT_SIZE: bloqueia inputs que excedem limite de tokens.
Defesa deterministica contra ataques de amplificacao que enviam payloads
grandes para estressar o modelo (CIS.16.063 - Negacao de Servico ao
Modelo). Executado antes de qualquer outro rail no pipeline de input
para curto-circuitar consumo de recursos.
Contagem de tokens via aproximacao chars/4 (conservadora, sem dependencia
externa). A precisao exata nao e necessaria: o objetivo e barrar payloads
ordens de grandeza maiores que o esperado, nao distinguir 4000 de 4100
tokens.
Configuracao via TIM_GUARDRAIL_INPUT_MAX_TOKENS (default 4096).
"""
from __future__ import annotations
import logging
import os
from ._compat import RailResult, span
logger = logging.getLogger(__name__)
_DEFAULT_MAX_TOKENS = 4096
_CHARS_PER_TOKEN = 4
def _max_tokens() -> int:
"""Le o cap do env. Default 4096 quando ausente/invalido."""
raw = os.getenv("TIM_GUARDRAIL_INPUT_MAX_TOKENS", "")
try:
val = int(raw)
return val if val > 0 else _DEFAULT_MAX_TOKENS
except (ValueError, TypeError):
return _DEFAULT_MAX_TOKENS
def _count_tokens(text: str) -> int:
"""Estima tokens via aproximacao chars/4.
A precisao exata nao importa para um cap defensivo. Subestima tokens
em CJK e codigo (raros no canal de fatura TIM), o que faz o cap
proteger mais agressivamente nesses casos - comportamento aceitavel.
"""
return max(1, len(text or "") // _CHARS_PER_TOKEN)
def verificar_tamanho_input(text: str, context: dict = None) -> RailResult:
"""Rail INPUT_SIZE: bloqueia text quando excede o cap configurado.
Executa em microssegundos. Quando bloqueia, o caller substitui a
resposta pelo fallback canonico definido em
pipeline._FALLBACK_BY_CODE["INPUT_SIZE"], que nao revela o limite
exato ao cliente (evita adaptacao por atacante).
"""
cap = _max_tokens()
with span("rail.INPUT_SIZE", mechanism="deterministic"):
estimated = _count_tokens(text)
if estimated > cap:
logger.warning(
"guardrails.input_size_excedido estimated=%s cap=%s len_chars=%s",
estimated, cap, len(text or ""),
)
return RailResult(
allowed=False,
reason=f"input excede limite ({estimated} > {cap} tokens estimados)",
sanitized_text=text,
code="INPUT_SIZE",
mechanism="deterministic",
data={
"estimated_tokens": estimated,
"max_tokens": cap,
"len_chars": len(text or ""),
},
)
return RailResult(
allowed=True,
reason="input dentro do limite",
sanitized_text=text,
code="INPUT_SIZE",
mechanism="deterministic",
data={"estimated_tokens": estimated, "max_tokens": cap},
)

View File

@@ -0,0 +1,77 @@
"""Adapter entre GuardRailLLMClient (Protocol) e GuardrailLLMClient (concreto).
AgentLLMClientAdapter implementa o Protocol GuardRailLLMClient definido em
contracts.py, delegando para o GuardrailLLMClient existente em llm_client.py.
Permite que os novos rails (guardrails/rails/*.py) usem o Protocol sem depender
diretamente do GuardrailLLMClient concreto — facilitando testes e futuras
trocas de implementação.
Mapeamento de capability_id -> task do GuardrailLLMClient:
O campo `capability_id` é passado diretamente como `task` para
GuardrailLLMClient.classify(). Os valores válidos são os mesmos já
suportados pelo cliente: "AOFERTA", "REVPREC", "OOS", "TOXOUT", "TOX",
"PINJ", "RAGSEC", "DLEX_IN", "DLEX_OUT", "FALLBACK".
Exemplo de uso:
from agent_framework.guardrails.calibrated.llm_adapter import AgentLLMClientAdapter
from agent_framework.guardrails.calibrated.llm_client import GuardrailLLMClient
adapter = AgentLLMClientAdapter(GuardrailLLMClient())
raw_json_str = adapter.invoke("PINJ", {"text": "ignore all rules"})
"""
from __future__ import annotations
import json
from typing import Any
from .llm_client import GuardrailLLMClient
class AgentLLMClientAdapter:
"""Implementa GuardRailLLMClient delegando para GuardrailLLMClient.
O Protocol GuardRailLLMClient define `invoke(capability_id, input_vars) -> str`.
O GuardrailLLMClient concreto expõe `classify(task, payload) -> dict`.
Este adapter:
1. Repassa `capability_id` como `task`.
2. Repassa `input_vars` como `payload`.
3. Serializa o dict retornado por `classify` de volta para str (JSON),
pois o Protocol contratua retorno como str — o rail chamador faz
json.loads() conforme necessário.
"""
def __init__(self, client: GuardrailLLMClient | None = None) -> None:
"""Inicializa o adapter.
Args:
client: instância de GuardrailLLMClient a delegar. Quando None,
cria uma nova instância com as configurações padrão
do ambiente.
"""
self._client: GuardrailLLMClient = client or GuardrailLLMClient()
def invoke(self, capability_id: str, input_vars: dict[str, Any]) -> str:
"""Invoca o LLM para a capability indicada e retorna JSON como str.
Args:
capability_id: identificador da tarefa de guardrail (ex.: "PINJ",
"OOS", "AOFERTA"). Mapeado diretamente para `task` do cliente.
input_vars: variáveis de input (ex.: {"text": ..., "context": ...}).
Mapeado diretamente para `payload` do cliente.
Returns:
Resposta do LLM serializada como string JSON. Em caso de falha
de classificação, o cliente já retorna {"allowed": False, "label":
"ERROR", "reason": ...} — este adapter apenas serializa o dict.
Raises:
ValueError: propagado pelo cliente quando `capability_id` não é
uma task suportada.
"""
result: dict = self._client.classify(capability_id, input_vars)
return json.dumps(result, ensure_ascii=False)
__all__ = ["AgentLLMClientAdapter"]

View File

@@ -0,0 +1,193 @@
from __future__ import annotations
import json
import os
from typing import Any
from .prompts.ausencia_oferta_proativa import build_aoferta_prompt
from .prompts.coerencia import build_coer_prompt
from .prompts._context import format_context_block
from .prompts.out_of_scope import build_oos_prompt
from .prompts.revprec import build_revprec_prompt
from .prompts.fraseologia import build_fraseologia_prompt
from .prompts.toxicidade_output import build_toxout_rewrite_prompt
from .prompts.tox import build_tox_prompt
# Segurança
from .prompts.dlex_in import build_dlex_in_prompt
from .prompts.dlex_out import build_dlex_out_prompt
from .prompts.pinj import build_pinj_prompt
from .prompts.ragsec import build_ragsec_prompt
from .prompts.fallback import build_fallback_prompt
_AOFERTA_TRIGGERS = (
"quer aproveitar",
"que tal tambem",
"que tal também",
"posso ja",
"posso já",
"ja que esta",
"já que está",
"aproveita e",
"aproveite e",
"tambem cancelar",
"também cancelar",
)
# Mock determinístico do REVPREC: substrings de ação dada como FEITA (a pergunta do rail
# desde 2026-08-06). A detecção rica (fatura × ação, protocolo, histórico) é do prompt.
_REVPREC_MARKERS = (
"cancelamento confirmado",
"foi cancelado",
"cancelado com sucesso",
"cancelei",
"cancelamos",
"retiramos o valor",
"retirei o valor",
"contestacao foi registrada",
"contestação foi registrada",
)
_TOXOUT_MOCK_PATTERNS = (
r"\b(idiota|imbecil|burro|estúpido|inútil|maldito|miserável|incompetente)\b",
r"\b(idiots?|stupid|useless|moron)\b",
)
_OOS_MOCK_TRIGGERS = (
"política",
"religião",
"presidente",
"concorrente",
"vivo",
)
# Substrings inequívocas de fraseado proibido (mock determinístico). Mantidas
# curtas e sem ambiguidade para não colidir com falas legítimas; a detecção rica
# (allow-list, "entendo" no início etc.) é responsabilidade do prompt 20b real.
_FRASEOLOGIA_MOCK_TRIGGERS = (
"bundle",
"parceiro",
"terceiros",
)
# Tasks cujo prompt pede UM DÍGITO (1 = passa, 0 = bloqueia) em vez de JSON, com o
# motivo do bloqueio fixado aqui. Gerar um `reason` por turno era o maior bloco de
# tokens de saída desses rails e nenhum consumidor o lia além do span.
_BINARY_TASKS: dict[str, str] = {
"COER": "fala incompreensível ou negação ambígua na transcrição",
"PINJ": "tentativa de prompt injection ou jailbreak detectada",
"REVPREC": "agente afirmou cancelamento/retirada já executado, sem execução no turno",
}
# Polaridade do dígito de BLOQUEIO. Nos binários, 1 = passa e 0 = bloqueia; o REVPREC
# INVERTE porque a pergunta dele é positiva ("o agente disse que cancelou?"), e é essa
# forma que dá acurácia — 1 = achou a afirmação = bloqueia.
_BINARY_BLOCK_DIGIT: dict[str, str] = {"REVPREC": "1"}
class GuardrailLLMClient:
"""Roteador de prompts para os guardrails de supervisao TIM.
Cliente síncrono de compatibilidade para os guardrails calibrados.
O backend real é sempre o LLMProvider oficial do agent_framework, com os
mesmos perfis/telemetria configurados na plataforma. Não cria gateway ou
cliente LangChain paralelo.
"""
# Todo guard ativo (AOFERTA, OOS, PINJ, FRASEOLOGIA) fixa 20b explicitamente
# aqui — nenhum depende do default global (TIM_LLM_OCI_VARIANT), que segue
# livre para a variante do orquestrador principal. PINJ usa 20b desde AT-15
# (prompt expandido com 11 exemplos e 7 categorias torna a tarefa
# suficientemente estruturada para modelo leve; antes da reescrita do
# prompt em AT-03 usava 120b como compensação). FRASEOLOGIA: blocklist de
# fraseado bem estruturada, mesma lógica. REVPREC (revprec_enabled=False
# por default) não está listado — segue o default global até ser ativado.
_TASK_OCI_VARIANT: dict[str, str] = {
"AOFERTA": "20b",
"OOS": "20b",
"PINJ": "20b",
"FRASEOLOGIA": "20b",
"COER": "20b",
}
def __init__(self) -> None:
# Mantido sem estado deliberadamente. O provider oficial resolve/cacheia
# seus próprios clientes e perfis; esta camada não deve possuir outro pool.
pass
@property
def use_mock(self) -> bool:
return os.getenv("USE_MOCK_LLM", "true").lower() == "true"
@staticmethod
def _run_framework_classifier(task: str, payload: dict) -> dict:
"""Executa a API async oficial a partir desta facade síncrona.
A aplicação nova usa GuardrailPipeline async diretamente. Esta bridge
existe apenas para compatibilidade com rails calibrados legados já
portados para o framework. Se houver event loop ativo, a coroutine é
executada em thread isolada para evitar nested-loop/cross-event-loop.
"""
import asyncio
from concurrent.futures import ThreadPoolExecutor
from agent_framework.guardrails.framework_llm_client import classify_with_framework_llm
async def _call() -> dict:
return await classify_with_framework_llm(None, task, payload)
try:
asyncio.get_running_loop()
except RuntimeError:
return asyncio.run(_call())
with ThreadPoolExecutor(max_workers=1, thread_name_prefix="guardrail-compat") as executor:
return executor.submit(lambda: asyncio.run(_call())).result()
def classify(
self,
task: str,
payload: dict,
*,
callbacks: list | None = None,
) -> dict:
"""Roteia uma task de guardrail para o LLM (ou mock).
Contrato de retorno depende da task:
- PINJ / COER: {"allowed", "label", "reason"} — o PROMPT devolve só um
dígito (1 = passa, 0 = bloqueia) e a conversão mora em `_BINARY_TASKS`;
o `reason` é fixo. Nenhum consumidor de produção lia o `label` desses
rails, e gerar `reason` por turno era a maior parcela da latência
(PINJ: 1115 ms -> 476 ms com a saída binária, medido em 2026-08-05).
- AOFERTA / OOS: {"allowed", "reason"} (JSON do prompt; `label` saiu de
ambos — nenhum consumidor o lia, só gastava token). Por contrato do
prompt o `reason` vem VAZIO quando allowed=true, como no FRASEOLOGIA.
- REVPREC: {"allowed", "label", "reason"} — binário como PINJ/COER, mas com
polaridade INVERTIDA (`_BINARY_BLOCK_DIGIT`): a pergunta é "o agente disse que
cancelou?", então `1` bloqueia. Reescrito em 2026-08-06; a forma anterior
(JSON de 4 campos, algoritmo de 9 passos) julgava promessa FUTURA e dava OK
ao pretérito — deixava passar exatamente a fala que interessa.
- TOXOUT: {"text": str} — texto reescrito sem trechos toxicos.
`callbacks` (opcional) eh repassado via `config={"callbacks": ...}`
para `llm.invoke`. Permite que o caller (ex.: loop._finalize_run)
injete o `LangfuseCallbackHandler` para que o `ChatLLM` da reescrita
apareca como span no Langfuse.
"""
if self.use_mock:
return self._mock_classify(task, payload)
# O caminho real usa exclusivamente o provider oficial do framework.
# O helper async preserva perfis (guardrail/grl), telemetria Langfuse e
# parsing binário/JSON calibrado.
return self._run_framework_classifier(task, payload)
def _mock_classify(self, task: str, payload: dict) -> dict:
# Reutiliza o mesmo fallback determinístico e explicável do pipeline
# moderno do framework, evitando divergência entre paths sync/async.
from agent_framework.guardrails.framework_llm_client import _mock_classify
return _mock_classify(task, payload)

View File

@@ -0,0 +1,203 @@
from __future__ import annotations
import re
from ._compat import RailResult, span
from .llm_client import GuardrailLLMClient
_client = GuardrailLLMClient()
def detectar_toxicidade(text:str, context: dict = None, *, callbacks: list | None = None)->RailResult:
with span("rail.TOX", mechanism="llm_rail"):
out=_client.classify("TOX", {"text":text}, callbacks=callbacks); return RailResult(out["allowed"],out.get("reason",""),text,"TOX","llm_rail",out)
def ausencia_oferta_proativa(text: str, context: dict = None, *, callbacks: list | None = None) -> RailResult:
"""Supervisor LLM: bloqueia oferta proativa nao solicitada.
Julga a fala mais recente do agente com referencia ao historico da
conversa (quando o pipeline o fornece via `context`), para que o
auditor consiga aplicar as regras 3a/3b do prompt — pedido de
permissao para acao sobre itens que sao o assunto da conversa nao
e proativa, mesmo quando o cliente nao repete os nomes na ultima
fala. Padroes de linguagem proativa ("quer aproveitar e...",
"ja que esta...") seguem caracterizando oferta indevida.
Args:
text: ultima fala do agente a ser auditada.
context: dict com `conversation_history` (formatado por
`format_context_block` em `llm_client.classify`).
Returns:
RailResult com code="AOFERTA", mechanism="llm_supervisor".
allowed=False quando o agente propoe acao nao solicitada.
"""
with span("supervisor.AOFERTA", mechanism="llm_supervisor"):
out = _client.classify(
"AOFERTA",
{"text": text, "context": context or {}},
callbacks=callbacks,
)
return RailResult(
allowed=bool(out.get("allowed", False)),
reason=out.get("reason", ""),
sanitized_text=text,
code="AOFERTA",
mechanism="llm_supervisor",
data=out,
)
_DIGIT_WORDS_RE = (
r"(?:zero|um|dois|tr[êe]s|quatro|cinco|seis|sete|oito|nove)"
)
# Token vocalizado: palavra de dígito ou letra única (a-z).
_SPOKEN_TOKEN_RE = rf"(?:{_DIGIT_WORDS_RE}|[a-z])"
# 6+ tokens vocalizados separados por espaço (cobre PRT-XXXX vocalizado).
_SPOKEN_PROTOCOL_RE = (
rf"(?:{_SPOKEN_TOKEN_RE}\s+){{5,}}{_SPOKEN_TOKEN_RE}\b"
)
_PROTOCOL_PATTERN = re.compile(
r"(?i)\bprotocolo\b"
r"[\s\S]{0,40}?"
r"(?:"
r"\d{6,}" # formato legado: 6+ dígitos literais
r"|"
r"PRT-[A-Z0-9]{6,}" # formato bruto da TIM (caso o LLM não vocalize)
r"|"
rf"{_SPOKEN_PROTOCOL_RE}" # formato vocalizado (palavras + letras)
r")"
)
def compliance_anatel(text: str, context: dict) -> RailResult:
"""Rail CMP: garante que respostas de ajuste contenham número de protocolo.
Aplica apenas quando o fluxo exige protocolo (tipo_fluxo='ajuste' ou
requer_protocolo=True no context). Se não aplicável, passa direto.
Aceita 3 formatos após "protocolo": dígitos literais (6+), `PRT-XXXX`
bruto, ou 6+ tokens vocalizados (palavras de dígito ou letras únicas).
Quando bloqueia, devolve em `data["expected_protocols"]` os números
crus que estavam pendentes no context — o caller pode usar para
aplicar fallback determinístico (concatenar a frase de protocolo).
"""
with span("rail.CMP", mechanism="regex"):
requer = (
context.get("tipo_fluxo") == "ajuste"
or context.get("requer_protocolo") is True
)
if not requer:
return RailResult(
allowed=True,
reason="Compliance Anatel não aplicável",
sanitized_text=text,
code="CMP",
mechanism="regex",
)
expected = list(context.get("expected_protocols") or [])
has_protocol = bool(_PROTOCOL_PATTERN.search(text))
if not has_protocol:
return RailResult(
allowed=False,
reason="Resposta de ajuste sem número de protocolo",
sanitized_text=text,
code="CMP",
mechanism="regex",
data={"expected_protocols": expected},
)
return RailResult(
allowed=True,
reason="Resposta contém protocolo obrigatório",
sanitized_text=text,
code="CMP",
mechanism="regex",
)
def out_of_scope(text: str, context: dict = None, *, callbacks: list | None = None) -> RailResult:
"""Rail OOS: bloqueia mensagens fora do dominio Telecom (contas/faturas TIM).
Roteia via GuardrailLLMClient (mesmo client de AOFERTA/REVPREC/TOXOUT) para
que o rail respeite TIM_LLM_PROVIDER (Groq/OCI/Azure/...) e USE_MOCK_LLM.
Antes delegava para `agent_framework.guardrails.nemo.llm_rails.detectar_out_of_scope`,
que tem cliente OpenAI proprio com defaults `OPENAI_BASE_URL=localhost:8051`
— incompativel com o setup do projeto e causa de APIConnectionError quando
USE_MOCK_LLM=false.
"""
with span("rail.OOS", mechanism="llm_supervisor"):
out = _client.classify(
"OOS",
{"text": text, "context": context or {}},
callbacks=callbacks,
)
allowed = bool(out.get("allowed", True))
return RailResult(
allowed=allowed,
reason=out.get("reason", ""),
sanitized_text=text,
code="OOS",
mechanism="llm_supervisor",
data=out,
)
# =========================
# FILTROS ADICIONADOS DE SEGURANCA
# =========================
def detectar_prompt_injection_jailbreak(text:str, context:dict, *, callbacks: list | None = None)->RailResult:
with span("rail.PINJ", mechanism="llm_rail"):
out=_client.classify("PINJ", {"text":text,"context":context}, callbacks=callbacks);
return RailResult(out["allowed"],out.get("reason",""),text,"PINJ","llm_rail",out)
def detectar_rag_injection_context_poisoning(text:str, context:dict, *, callbacks: list | None = None)->RailResult:
with span("rail.RAGSEC", mechanism="llm_rail"):
out=_client.classify("RAGSEC", {"text":text,"context":context}, callbacks=callbacks);
return RailResult(out["allowed"],out.get("reason",""),text,"RAGSEC","llm_rail",out)
def detectar_data_leakage_input(text:str, context:dict, *, callbacks: list | None = None)->RailResult:
with span("rail.DLEX_IN", mechanism="llm_rail"):
out=_client.classify("DLEX_IN", {"text":text,"context":context}, callbacks=callbacks);
return RailResult(out["allowed"],out.get("reason",""),text,"DLEX_IN","llm_rail",out)
def detectar_data_leakage_output(text:str, context:dict, *, callbacks: list | None = None)->RailResult:
with span("rail.DLEX_OUT", mechanism="llm_rail"):
out=_client.classify("DLEX_OUT", {"text":text,"context":context}, callbacks=callbacks);
return RailResult(out["allowed"],out.get("reason",""),text,"DLEX_OUT","llm_rail",out)
def detectar_fallback(
text: str,
context: dict = None,
*,
guardrail_code: str | None = None,
guardrail_reason: str | None = None,
callbacks: list | None = None,
) -> RailResult:
"""Reescreve o texto bloqueado por um rail.
`guardrail_code` e `guardrail_reason` vêm do `RailResult` do rail que
disparou — o prompt usa essa info para escolher a instrução de reescrita
específica (AOFERTA remove oferta proativa, REVPREC remove promessa de
ação, OOS redireciona ao escopo etc.). Sem esses kwargs o prompt cai
numa instrução genérica.
"""
with span("fallback", mechanism="llm_rail"):
out = _client.classify(
"FALLBACK",
{
"text": text,
"context": context,
"guardrail_code": guardrail_code,
"guardrail_reason": guardrail_reason,
},
callbacks=callbacks,
)
return RailResult(
out["allowed"],
out.get("reason", ""),
text,
"FALLBACK",
"llm_rail",
out,
)

View File

@@ -0,0 +1,345 @@
"""Rails de sanitizacao do output do agente.
Dois rails sanitize-and-pass-through (nao bloqueiam, transformam o texto):
- `mascarar_pii_output(text) -> RailResult` (code=MSK)
PII masking via regex local (CPF, cartao, senha) com fallback opcional para
`agent_framework.guardrails_old.nemo.deterministic_rails.mask_pii` quando a lib
conseguir importar.
- `sanitizar_toxicidade_output(text) -> RailResult` (code=TOXOUT)
Toxicidade do output em 3 niveis:
- Nivel 1: deteccao deterministica via regex (sem custo LLM). Quando
encontra trecho toxico, NAO devolve direto: escala para o nivel 2 para
evitar fragmentos sem coesao (ex.: "voce eh seu" apos remocao de
palavrao). O texto pre-limpo so eh usado como fallback do fallback.
- Nivel 2: reescrita via LLM atraves do GuardrailLLMClient (TOXOUT).
- Nivel 3: mensagem canonica fixa do dominio.
Ambos retornam `RailResult.allowed=True`; o caller substitui o texto por
`sanitized_text` quando `sanitized_text != text`. A funcao agregadora
`sanitizar_output` mantem retrocompat e roda os dois em sequencia.
"""
from __future__ import annotations
import logging
import re
from ._compat import RailResult, span
from .llm_client import GuardrailLLMClient
logger = logging.getLogger(__name__)
# Blocklist deterministica de baixo calao / ofensa pessoal (PT-BR + EN).
# Cobre flexoes (plural/genero) via \w* nos radicais. E o piso de deteccao do
# TOXOUT quando o LLM de guardrail nao esta disponivel (fail-safe), garantindo
# a regra "agente responde com palavra de baixo calao -> bloqueia + operador".
_TOXIC_PATTERNS = (
r"\b(idiot|imbecil|burr[oa]|est[uú]pid|in[uú]til|incompetent|maldit|miser[aá]vel|"
r"ot[aá]ri|babac|escrot|cuz[aã]o|vagabund|desgra[çc]ad|palha[çc]ad|cretin|canalh)\w*",
r"\b(merd|bost|porcari|porra|caralh|foda[\s\-]?se|fdp|"
r"filho?\s+da\s+put|put[ao]|lixo)\w*",
r"\b(idiots?|stupid|useless|moron|crap|shit|asshole|bastard)\b",
)
_PII_RULES: tuple[tuple[str, str], ...] = (
# CPF formatado (xxx.xxx.xxx-xx).
(r"\b\d{3}\.\d{3}\.\d{3}-\d{2}\b", "[CPF_MASCARADO]"),
)
# Cartao: 16 digitos contiguos, mas so mascarados quando parecem cartao de fato
# (Luhn + BIN). Sem isso, qualquer numero de 16 digitos — como o ID Anatel — era
# tratado como cartao e corrompido na resposta.
_CARD_PATTERN = r"\b\d{16}\b"
_CARD_MASK = "[CARTAO_MASCARADO]"
# Senha em padrao "senha: xxx" / "senha=xxx" — usa grupo capturado como prefixo.
_PII_PASSWORD_PATTERN = r"(?i)(senha\s*[:=]?\s*)\S+"
_PII_PASSWORD_REPL = r"\1[SENHA_MASCARADA]"
def _luhn_ok(digits: str) -> bool:
"""Checksum de Luhn — cartoes reais sempre passam; IDs arbitrarios raramente."""
total = 0
for i, ch in enumerate(reversed(digits)):
d = ord(ch) - 48
if i % 2 == 1:
d *= 2
if d > 9:
d -= 9
total += d
return total % 10 == 0
def _looks_like_card(digits: str) -> bool:
"""True so se 16 digitos passam em Luhn E tem BIN de bandeira (3-6 ou
Mastercard serie 2: 2221-2720). Exclui IDs nao-cartao como o ID Anatel."""
if not _luhn_ok(digits):
return False
if digits[0] in ("3", "4", "5", "6"):
return True
return 2221 <= int(digits[:4]) <= 2720
def _mask_card(match: "re.Match") -> str:
digits = match.group(0)
return _CARD_MASK if _looks_like_card(digits) else digits
_TOXOUT_CANONICAL_MESSAGE = (
"Não consegui formular uma resposta adequada, posso ajudar de outra forma?"
)
_client = GuardrailLLMClient()
def _deterministic_sanitize(text: str) -> tuple[str, bool]:
"""Nivel 1: remove padroes toxicos comuns via regex.
Retorna (texto_sanitizado, perdeu_sentido). Considera que perdeu sentido
se o texto resultante ficou com menos de 50% do tamanho original.
"""
sanitized = text
for pattern in _TOXIC_PATTERNS:
sanitized = re.sub(pattern, "", sanitized, flags=re.IGNORECASE)
sanitized = " ".join(sanitized.split())
lost_meaning = len(sanitized) < len(text) * 0.5
return sanitized, lost_meaning
def _regex_is_clean(text: str) -> bool:
"""Verifica via regex local se o texto nao contem padroes toxicos conhecidos."""
for pattern in _TOXIC_PATTERNS:
if re.search(pattern, text, flags=re.IGNORECASE):
return False
return True
def _mask_pii_local(text: str) -> str:
"""Implementacao local equivalente a `mask_pii` da lib.
Replica os mesmos padroes de `agent_framework.guardrails_old.nemo
.deterministic_rails.mask_pii` (CPF formatado, cartao de 16 digitos
e padrao "senha: xxx"). Mantemos local porque a lib hoje fica presa
atras de um import eager de `nemoguardrails`, que conflita com as
versoes de langchain/fastapi que a propria `agent_framework` exige.
"""
masked = text
for pattern, replacement in _PII_RULES:
masked = re.sub(pattern, replacement, masked)
masked = re.sub(_CARD_PATTERN, _mask_card, masked)
masked = re.sub(_PII_PASSWORD_PATTERN, _PII_PASSWORD_REPL, masked)
return masked
def _mask_pii(text: str) -> str:
"""Tenta a `mask_pii` da lib; em qualquer falha, cai na versao local."""
try:
from agent_framework.guardrails_old.nemo.deterministic_rails import (
mask_pii,
)
return mask_pii(text).sanitized_text or text
except Exception:
logger.debug(
"guardrails.mask_pii_lib_indisponivel_usando_regex_local",
exc_info=True,
)
return _mask_pii_local(text)
def _detectar_toxicidade_safe(text: str):
"""Usa o detectar_toxicidade local (GuardrailLLMClient).
Antes lazy-importava de agent_framework.guardrails_old.nemo, cujo cliente
OpenAI aponta para OPENAI_BASE_URL=localhost:8051 e causa
APIConnectionError + retries longos quando o proxy nao esta de pe.
Mesma migracao ja feita para out_of_scope.
"""
from .llm_rails import detectar_toxicidade
return detectar_toxicidade(text)
def _is_clean(text: str) -> bool:
"""Confirma que o texto reescrito nao tem mais toxicidade.
Tenta `detectar_toxicidade` da lib; se a lib nao estiver disponivel
(ex.: nemoguardrails ausente em dev), cai num check de regex local.
"""
try:
return bool(_detectar_toxicidade_safe(text).allowed)
except Exception:
logger.debug("guardrails.tox_check_unavailable_using_regex", exc_info=True)
return _regex_is_clean(text)
def _sanitize_toxic(
text: str,
*,
callbacks: list | None = None,
) -> tuple[str, str]:
"""Pipeline 3-niveis de sanitizacao toxica.
Retorna (texto_final, nivel) onde nivel ∈ {"deterministic", "llm_rewrite",
"canonical", "noop"}. "noop" indica que nada toxico foi achado e o texto
voltou inalterado.
`callbacks` (opcional) e repassado para `_client.classify` quando o nivel
2 (LLM rewrite) dispara, para que o ChatLLM da reescrita apareca como
span no Langfuse.
"""
with span("rail.TOXOUT.deterministic", mechanism="regex"):
pre_cleaned, lost_meaning = _deterministic_sanitize(text)
if pre_cleaned == text:
return text, "noop"
logger.info(
"guardrails.toxic_sanitized_deterministically lost_meaning=%s",
lost_meaning,
)
with span("rail.TOXOUT.llm_rewrite", mechanism="llm_supervisor"):
try:
out = _client.classify("TOXOUT", {"text": text}, callbacks=callbacks)
rewritten = (out.get("text") or "").strip()
logger.warning(
"guardrails.toxout_llm_raw use_mock=%s rewritten_len=%s rewritten=%r is_clean=%s",
_client.use_mock,
len(rewritten),
rewritten[:200],
_is_clean(rewritten) if rewritten else False,
)
#rewritten = (out.get("text") or "").strip()
if rewritten and _is_clean(rewritten):
logger.info("guardrails.toxic_rewritten_by_llm")
return rewritten, "llm_rewrite"
except Exception:
logger.warning(
"guardrails.sanitize_toxic_llm_failed", exc_info=True,
)
if not lost_meaning:
logger.warning(
"guardrails.toxic_sanitized_deterministically_fallback",
)
return pre_cleaned, "deterministic"
with span("rail.TOXOUT.canonical", mechanism="python"):
logger.warning("guardrails.toxic_fallback_canonical")
return _TOXOUT_CANONICAL_MESSAGE, "canonical"
def mascarar_pii_output(text: str, context: dict = None) -> RailResult:
"""Rail de PII masking no output (code=MSK).
Sempre retorna allowed=True. Quando algum padrao foi encontrado,
`sanitized_text != text` e o caller deve emitir um span
`guardrail.MSK.applied` antes de substituir.
"""
with span("rail.MSK", mechanism="regex"):
masked = _mask_pii(text)
changed = masked != text
if changed:
logger.warning(
"guardrails.output_pii_mascarado original_len=%s sanitized_len=%s",
len(text),
len(masked),
)
return RailResult(
allowed=True,
reason="PII mascarada" if changed else "Nenhuma PII detectada",
sanitized_text=masked,
code="MSK",
mechanism="regex",
data={
"label": "SANITIZED" if changed else "OK",
"original_len": len(text),
"sanitized_len": len(masked),
},
)
def sanitizar_toxicidade_output(
text: str,
*,
callbacks: list | None = None,
) -> RailResult:
"""Rail de sanitizacao toxica no output (code=TOXOUT).
Sempre retorna allowed=True. Quando o texto foi reescrito,
`sanitized_text != text` e o caller deve emitir um span
`guardrail.TOXOUT.applied` antes de substituir.
`callbacks` (opcional) e repassado para o LLM da reescrita; sem ele,
a chamada do LLM nao aparece no Langfuse.
"""
with span("rail.TOXOUT", mechanism="llm_supervisor"):
try:
tox = _detectar_toxicidade_safe(text)
tox_allowed = bool(tox.allowed)
tox_reason = tox.reason
except Exception:
logger.warning(
"guardrails.toxicidade_check_failed_using_safe_fallback",
exc_info=True,
)
tox_allowed = _regex_is_clean(text)
tox_reason = "lib indisponivel; usando regex local"
if tox_allowed:
return RailResult(
allowed=True,
reason="output limpo",
sanitized_text=text,
code="TOXOUT",
mechanism="llm_supervisor",
data={"label": "OK", "level": "noop"},
)
logger.warning(
"guardrails.output_toxicidade_detectada reason=%s", tox_reason,
)
cleaned, level = _sanitize_toxic(text, callbacks=callbacks)
if cleaned != text:
logger.warning(
"guardrails.output_sanitizado code=TOXOUT level=%s "
"original=%r sanitizado=%r",
level,
text[:200],
cleaned[:200],
)
return RailResult(
allowed=True,
reason="output sanitizado",
sanitized_text=cleaned,
code="TOXOUT",
mechanism="llm_supervisor",
data={
"label": "SANITIZED" if cleaned != text else "OK",
"level": level,
"original_len": len(text),
"sanitized_len": len(cleaned),
},
)
def sanitizar_output(
text: str,
*,
callbacks: list | None = None,
) -> RailResult:
"""Wrapper retrocompativel: aplica MSK + TOXOUT em sequencia.
Mantido para callers que nao se importam com spans granulares no Langfuse.
Para emissao correta de spans `guardrail.MSK.applied` e
`guardrail.TOXOUT.applied`, prefira chamar `mascarar_pii_output` e
`sanitizar_toxicidade_output` diretamente do call site que tem acesso
ao mixin de observabilidade do agente.
"""
pii = mascarar_pii_output(text)
tox = sanitizar_toxicidade_output(pii.sanitized_text or text, callbacks=callbacks)
return tox

View File

@@ -0,0 +1,586 @@
"""Pipeline de guardrails do agente (Padrao 1 do guia da lib).
Encapsula os rails de input/output que aplicamos hoje:
- MSK no input (mascara PII antes do LLM).
- OOS no input (bloqueia mensagens fora de escopo).
- AOFERTA (oferta proativa nao solicitada) — extensao local.
- REVPREC (promessa operacional futura) — extensao local (prompt em prompts/revprec.py).
Sanitizacao de output (PII masking + toxicidade, sanitize-and-pass-through)
tambem existe em `output_sanitization.sanitizar_output`, com semantica
distinta (nao bloqueia, transforma o texto).
Quem chama recebe um RailDecision e age: se allowed=False, troca o texto da
resposta por fallback_text; se sanitized_text mudou, deve seguir o turno com
esse texto. O modulo eh puro de telemetria — quem invoca
(LangChainWorkflowAgent.run) e responsavel por emitir o span
'guardrail.<CODE>.blocked' no Langfuse usando a mixin de observabilidade
do agente.
"""
from __future__ import annotations
import logging
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from typing import Any, Callable
from ._compat import RailResult, span
from .input_size import verificar_tamanho_input
from .llm_client import GuardrailLLMClient
from .llm_rails import ausencia_oferta_proativa, compliance_anatel, out_of_scope, detectar_prompt_injection_jailbreak, detectar_rag_injection_context_poisoning, detectar_data_leakage_input, detectar_data_leakage_output, detectar_toxicidade, detectar_fallback
from .output_sanitization import mascarar_pii_output
from .rules.pinj_patterns import is_obvious_injection
from .rails.tox import ToxRail
import time
_tox_rail = ToxRail()
_client = GuardrailLLMClient()
logger = logging.getLogger(__name__)
# 2026-05-16
_FALLBACK_BY_CODE: dict[str, str] = {
"INPUT_SIZE": (
"Sua mensagem ficou muito longa pra eu processar de uma vez. "
"Pode reformular de forma mais curta ou dividir em partes menores "
"e me reenviar?"
),
"AOFERTA": (
"Posso te ajudar com mais alguma dúvida sobre sua conta ou fatura?"
),
"REVPREC": (
"No momento não consigo confirmar essa ação dessa forma. "
"Vou continuar verificando as informações disponíveis."
),
"CMP": (
"Não consegui validar todas as informações necessárias neste momento. "
"Vou seguir verificando os dados do atendimento."
),
"OOS": (
"Essa solicitação está fora do meu escopo de atendimento. "
"Posso te ajudar com dúvidas sobre contas, consumo ou faturas da TIM."
),
"DLEX_IN": (
"Não consegui interpretar essa solicitação com segurança. "
"Pode reformular sua mensagem de outra forma?"
),
"PINJ": (
"Não consegui processar essa solicitação da forma enviada. "
"Pode reformular sua pergunta para continuarmos?"
),
"RAGSEC": (
"Não encontrei informações suficientes para responder isso com segurança. "
"Pode detalhar melhor sua solicitação?"
),
"DLEX_OUT": (
"Prefiro reformular minha resposta para evitar informações incorretas. "
"Pode me confirmar exatamente o que deseja consultar?"
),
"TOX": (
"Entendo que essa situação é frustrante. Vou te ajudar a verificar isso."
),
"INTENCAO_CANCELAR": (
"Deixa eu confirmar o que você gostaria de fazer: você quer entender "
"o que é essa cobrança ou prefere cancelar o serviço?"
),
"CORRESPONDENCIA_ITEM": (
"Preciso confirmar um detalhe antes de prosseguirmos. Pode me confirmar "
"qual serviço você deseja cancelar e o valor que esperava?"
),
"ALCADA": (
"Este ajuste precisa ser analisado por um especialista TIM. "
"Vou encaminhar seu atendimento para continuar com um especialista "
"que poderá te ajudar melhor nesse caso."
),
"ACTION_CONFIRMATION_RETRY": (
"Antes de prosseguirmos, preciso confirmar: você gostaria mesmo de "
"realizar essa ação?"
),
}
#2026-05-19
def _run_rail(
timings_ms: dict[str, float],
code: str,
fn,
*args,
**kwargs,
):
started = time.perf_counter()
result = fn(*args, **kwargs)
elapsed = round((time.perf_counter() - started) * 1000, 3)
timings_ms[code] = elapsed
return result
# (code, fn, kwargs) -> RailResult. O runner e responsavel por: cronometrar,
# popular `timings_ms`, abrir spans Langfuse e injetar `callbacks` nas rails
# LLM que aceitam. O default abaixo replica o `_run_rail` original (sem
# tracing/callbacks) — usado quando o pipeline e invocado fora do agent (ex.:
# testes, scripts).
RailRunner = Callable[[str, Callable[..., "RailResult"], dict], "RailResult"]
def _default_rail_runner(
timings_ms: dict[str, float],
) -> RailRunner:
def runner(code: str, fn, kwargs: dict):
return _run_rail(timings_ms, code, fn, **kwargs)
return runner
_MOCK_WARNED = False
def _maybe_warn_mock_mode() -> None:
"""Loga UMA vez por processo se os rails LLM estao em modo mock.
Em producao, USE_MOCK_LLM=false desliga o aviso. Em dev/test fica visivel
para evitar que alguem confunda heuristica de string-match com LLM real.
"""
global _MOCK_WARNED
if _MOCK_WARNED:
return
if os.getenv("USE_MOCK_LLM", "true").lower() == "true":
logger.warning(
"guardrails rodando em modo MOCK (USE_MOCK_LLM=true). "
"Os rails LLM (AOFERTA, REVPREC) usam heuristicas "
"deterministicas; em producao defina USE_MOCK_LLM=false."
)
_MOCK_WARNED = True
@dataclass
class RailDecision:
allowed: bool
code: str | None = None
reason: str = ""
fallback_text: str | None = None
sanitized_text: str | None = None
results: list[RailResult] = field(default_factory=list)
timings_ms: dict[str, float] = field(default_factory=dict)
total_ms: float = 0.0
# Distingue hard-block (substitui resposta) de soft-alert (apenas loga).
# False = default = hard-block: substituir result["content"] + patchar histórico.
# True = soft-alert: logar violação, não alterar a resposta ao cliente.
is_soft_alert: bool = False
# Flag corretiva para re-invocar o agente principal com constraint.
# None = rail não suporta regeneração (usa apenas fallback estático).
regen_flag: str | None = None
def _verbalizacao_prematura(
text: str,
context: dict = None,
*,
callbacks: list | None = None,
) -> RailResult:
"""Rail REVPREC local: bloqueia promessa operacional futura.
Roteia via GuardrailLLMClient (mesmo client de AOFERTA/TOXOUT), usando o
prompt local em prompts/revprec.py. Avalia apenas o texto final do agente,
sem contexto ou tool_calls. Em modo mock (USE_MOCK_LLM=true), recai na
heuristica deterministica de _mock_classify("REVPREC", ...).
"""
with span("rail.REVPREC", mechanism="llm_rail"):
out = _client.classify(
"REVPREC",
{"text": text, "context": context or {}},
callbacks=callbacks,
)
return RailResult(
allowed=bool(out.get("allowed", True)),
reason=out.get("reason", ""),
sanitized_text=text,
code="REVPREC",
mechanism="llm_rail",
data=out,
)
def apply_input_rails(
text: str,
*,
rail_runner: RailRunner | None = None,
) -> RailDecision:
"""Aplica INPUT_SIZE + MSK + OOS no input. Curto-circuita ao primeiro bloqueio.
`rail_runner` opcional permite ao caller (LangChainWorkflowAgent) abrir
spans Langfuse por rail e injetar callbacks Langfuse nos rails LLM. Quando
omitido, usa o runner default que apenas cronometra (caso de testes e
scripts).
"""
_maybe_warn_mock_mode()
results: list[RailResult] = []
timings_ms = {}
pipeline_started = time.perf_counter()
runner = rail_runner or _default_rail_runner(timings_ms)
#desativação para integração futura
return RailDecision(
allowed=True,
sanitized_text=text,
results=results,
timings_ms=timings_ms,
total_ms=round(
(time.perf_counter() - pipeline_started) * 1000,
3
),
)
# AT-09: first-pass determinístico para PINJ óbvio — evita chamada LLM
# para padrões de injection inequívocos (role override, pseudo-tags, etc.)
if is_obvious_injection(text):
timings_ms["PINJ"] = round((time.perf_counter() - pipeline_started) * 1000, 3)
return RailDecision(
allowed=False,
code="PINJ",
reason="regex_match: padrão de injection óbvio detectado sem LLM",
fallback_text=_FALLBACK_BY_CODE["PINJ"],
results=results,
timings_ms=timings_ms,
total_ms=timings_ms["PINJ"],
)
# PINJ (LLM) e INPUT_SIZE executados em paralelo (AT-13): INPUT_SIZE é
# determinístico e pode terminar antes. PINJ tem precedência de bloqueio.
with ThreadPoolExecutor(max_workers=2) as executor:
pinj_future = executor.submit(
runner,
"PINJ",
detectar_prompt_injection_jailbreak,
{"text": text, "context": {}},
)
size_future = executor.submit(
runner,
"INPUT_SIZE",
verificar_tamanho_input,
{"text": text, "context": {}},
)
pinj = pinj_future.result()
size = size_future.result()
results.append(pinj)
if not pinj.allowed:
try:
fallback = runner(
"FALLBACK_PINJ",
detectar_fallback,
{
"text": text,
"context": {},
"guardrail_code": "PINJ",
"guardrail_reason": pinj.reason,
},
).reason
except Exception:
fallback = _FALLBACK_BY_CODE["PINJ"]
return RailDecision(
allowed=False,
code="PINJ",
reason=pinj.reason,
fallback_text=fallback,
results=results,
timings_ms=timings_ms,
total_ms=round(
(time.perf_counter() - pipeline_started) * 1000,
3
),
)
# TOX: reativado em AT-05 com mecanismo de baixa latência.
# Novo mecanismo: blocklist determinística (is_obvious_toxic) + LLM leve (ToxRail).
# Executa em paralelo com OOS/AOFERTA via pipeline — não adiciona latência sequencial.
# Ativado via env var GUARDRAIL_TOX_ENABLED=true (desativado por default).
if os.getenv("GUARDRAIL_TOX_ENABLED", "false").lower() == "true":
from .contracts import GuardRailContext as _GRCtx
_tox_ctx = _GRCtx(session_id="pipeline", user_text=text)
tox_started = time.perf_counter()
tox_decision = _tox_rail.evaluate(_tox_ctx)
timings_ms["TOX"] = round((time.perf_counter() - tox_started) * 1000, 3)
if not tox_decision.allowed:
return RailDecision(
allowed=False,
code="TOX",
reason=tox_decision.reason,
fallback_text=tox_decision.fallback_text or _FALLBACK_BY_CODE["TOX"],
sanitized_text=text,
results=results,
timings_ms=timings_ms,
total_ms=round(
(time.perf_counter() - pipeline_started) * 1000,
3,
),
)
results.append(size)
if not size.allowed:
try:
fallback = runner(
"FALLBACK_INPUT_SIZE",
detectar_fallback,
{
"text": text,
"context": {},
"guardrail_code": "INPUT_SIZE",
"guardrail_reason": size.reason,
},
).reason
except Exception:
fallback = _FALLBACK_BY_CODE["INPUT_SIZE"]
return RailDecision(
allowed=False,
code="INPUT_SIZE",
reason=size.reason,
fallback_text=fallback,
sanitized_text=text,
results=results,
timings_ms=timings_ms,
total_ms=round(
(time.perf_counter() - pipeline_started) * 1000,
3
),
)
msk = runner(
"MSK",
mascarar_pii_output,
{"text": text, "context": {}},
)
results.append(msk)
sanitized_text = msk.sanitized_text or text
# [RAIL] migrado para guardrails/rails/dlex_in.py — ativação via GuardRailConfig.dlex_in_enabled
return RailDecision(
allowed=True,
sanitized_text=sanitized_text,
results=results,
timings_ms=timings_ms,
total_ms=round(
(time.perf_counter() - pipeline_started) * 1000,
3
),
)
# 2026-05-16
def apply_output_rails(
text: str,
user_text: str,
tool_calls: list[dict[str, Any]] | None,
context: dict[str, Any] | None = None,
*,
rail_runner: RailRunner | None = None,
) -> RailDecision:
"""Aplica OOS + AOFERTA na resposta do agente.
Curto-circuita no primeiro bloqueio para economizar 1 chamada LLM.
AOFERTA julga apenas a fala do agente, sem depender do historico.
`rail_runner` opcional permite ao caller abrir spans Langfuse por rail e
injetar callbacks nas rails LLM.
Early-exit e invariante ``tool_calls``
--------------------------------------
Quando ``tool_calls`` é não-nulo (lista de uma ou mais tool_calls), esta
função retorna imediatamente com ``allowed=True, reason="skipped_due_to_tool_calls"``
sem executar OOS nem AOFERTA.
**Invariante**: quando ``tool_calls`` está presente, o ``content`` do
AIMessage contém **apenas** ``pre_message`` fixos — textos determinísticos
gerados pelo agente para avisar o cliente que uma ação está prestes a ser
executada (ex.: "Perfeito! Aguarde um instante."). Esses textos não contêm
informação derivada de input do usuário e não são candidatos a OOS, AOFERTA
ou REVPREC. Por isso a verificação de guardrail é desnecessária e seria
apenas latência.
**Responsabilidade do caller**: quem invoca ``apply_output_rails`` deve
garantir essa invariante antes de popular ``tool_calls``. Em produção,
``LangChainWorkflowAgent.run`` satisfaz a invariante porque ``pre_message``
é interpolado a partir de templates fixos registrados no fluxo, nunca a
partir do texto do usuário.
Consequência de auditoria: o texto passado via ``text`` quando
``tool_calls`` não é nulo **não é verificado por guardrail**. O logger.debug
abaixo registra o skip com o tamanho do texto para rastreabilidade.
"""
_maybe_warn_mock_mode()
results: list[RailResult] = []
timings_ms: dict[str, float] = {}
pipeline_started = time.perf_counter()
#desativação para integração futura
return RailDecision(
allowed=True,
reason="skipped_due_integration",
sanitized_text=text,
results=results,
timings_ms=timings_ms,
total_ms=round(
(time.perf_counter() - pipeline_started) * 1000,
3,
),
)
# INVARIANTE: tool_calls presente → content = pre_message fixo (não requer guardrail)
if tool_calls:
logger.debug(
"apply_output_rails.skipped_due_to_tool_calls "
"text_len=%d tool_calls_count=%d",
len(text),
len(tool_calls),
)
return RailDecision(
allowed=True,
reason="skipped_due_to_tool_calls",
sanitized_text=text,
results=results,
timings_ms=timings_ms,
total_ms=round(
(time.perf_counter() - pipeline_started) * 1000,
3,
),
)
# OOS e AOFERTA executados em paralelo (AT-12): cada um = 1 chamada LLM.
# Submetemos ambos ao mesmo tempo e aguardamos os dois resultados antes de
# tomar decisão. OOS tem precedência sobre AOFERTA se ambos bloquearem.
runner = rail_runner or _default_rail_runner(timings_ms)
with ThreadPoolExecutor(max_workers=2) as executor:
oos_future = executor.submit(
runner,
"OOS",
out_of_scope,
{"text": text, "context": context or {}},
)
aof_future = executor.submit(
runner,
"AOFERTA",
ausencia_oferta_proativa,
{"text": text, "context": context or {}},
)
oos = oos_future.result()
aof = aof_future.result()
results.append(oos)
results.append(aof)
# ESTRATÉGIA DE REATIVAÇÃO DA REESCRITA LLM (camada 2) — FC-07:
# Camada 3 (regeneração via _REGEN_FLAG_BY_CODE) tem precedência para:
# AOFERTA, OOS, INTENCAO_CANCELAR, CORRESPONDENCIA_ITEM, TOX, REVPREC, RAGSEC, ALCADA.
# Camada 2 (reescrita LLM externa via detectar_fallback) é fallback da camada 3,
# ou path principal para rails sem regen_flag (INPUT_SIZE, PINJ).
# Camada 1 (texto estático) é usado somente quando camada 2 está off ou falha.
# Para reativar camada 2: descomentar o bloco detectar_fallback abaixo e garantir
# que todos os rails hard-block tenham entry em _REWRITE_INSTRUCTIONS_BY_CODE.
if not oos.allowed:
# Fallback gerado por LLM desativado: no momento so importa a deteccao.
# Mantido comentado para reativar quando a reescrita voltar a ser usada.
# try:
# fallback = runner(
# "FALLBACK_OOS",
# detectar_fallback,
# {
# "text": text,
# "context": context or {},
# "guardrail_code": "OOS",
# "guardrail_reason": oos.reason,
# },
# ).reason
# except Exception:
# fallback = _FALLBACK_BY_CODE["OOS"]
fallback = _FALLBACK_BY_CODE["OOS"]
return RailDecision(
allowed=False,
code="OOS",
reason=oos.reason,
fallback_text=fallback,
sanitized_text=text,
results=results,
timings_ms=timings_ms,
total_ms=round(
(time.perf_counter() - pipeline_started) * 1000,
3
),
)
if not aof.allowed:
# Fallback gerado por LLM desativado: no momento so importa a deteccao.
# Mantido comentado para reativar quando a reescrita voltar a ser usada.
# try:
# fallback = runner(
# "FALLBACK_AOFERTA",
# detectar_fallback,
# {
# "text": text,
# "context": context or {},
# "guardrail_code": "AOFERTA",
# "guardrail_reason": aof.reason,
# },
# ).reason
# except Exception:
# fallback = _FALLBACK_BY_CODE["AOFERTA"]
fallback = _FALLBACK_BY_CODE["AOFERTA"]
return RailDecision(
allowed=False,
code="AOFERTA",
reason=aof.reason,
fallback_text=fallback,
results=results,
timings_ms=timings_ms,
total_ms=round(
(time.perf_counter() - pipeline_started) * 1000,
3
),
)
# [RAIL] migrado para guardrails/rails/revprec.py — ativação via GuardRailConfig.revprec_enabled
# [RAIL] migrado para guardrails/rails/ragsec.py — ativação via GuardRailConfig.ragsec_enabled
# [RAIL] migrado para guardrails/rails/dlex_out.py — ativação via GuardRailConfig.dlex_out_enabled
# CMP (compliance_anatel) é "sanitize-and-pass-through": roda no
# `_finalize_run` da loop junto com MSK/TOXOUT pra que o span
# `guardrail.CMP.applied` seja registrado antes do
# `run_observation.update(output=...)`. Não entra aqui porque os rails
# acima são bloqueantes e este é deterministicamente recuperável.
return RailDecision(allowed=True, results=results,
timings_ms=timings_ms,
total_ms=round(
(time.perf_counter() - pipeline_started) * 1000,
3
),
)
def replace_last_ai_message(history: list[Any], new_content: str) -> bool:
"""Substitui o `content` da ultima AIMessage do historico do agente.
Necessario quando um rail de saida bloqueia: o handler troca o texto
devolvido ao cliente, mas a AIMessage original (com a frase ofensiva)
ainda esta no historico do agente — no proximo turno, o LLM ve aquela
frase e pode reincidir. Patcheamos in-place para que o historico
passe a refletir o fallback.
Retorna True se conseguiu trocar; False quando nao acha AIMessage.
"""
for msg in reversed(history):
cls = type(msg).__name__
if cls != "AIMessage":
continue
try:
msg.content = new_content
except Exception:
return False
return True
return False

View File

@@ -0,0 +1,9 @@
from .ausencia_oferta_proativa import build_aoferta_prompt
from .revprec import build_revprec_prompt
from .toxicidade_output import build_toxout_rewrite_prompt
__all__ = [
"build_aoferta_prompt",
"build_revprec_prompt",
"build_toxout_rewrite_prompt",
]

View File

@@ -0,0 +1,118 @@
"""Formatacao do `context` do agente para prompts de guardrail.
Os rails de output (OOS, AOFERTA, REVPREC, PINJ, RAGSEC, DLEX_OUT) precisam
auditar a fala do agente *com referencia* ao que o cliente pediu e ao que o
agente esta executando — sem isso, OOS classifica "Olá, como vai?" como
in-scope (a frase em si nao e off-topic) quando deveria reprovar o turno
porque o cliente perguntou algo fora de telecom.
`format_context_block` extrai o historico recente da conversa e o renderiza
como string pronta para ser injetada no prompt. So os turnos de fala entram:
SystemMessage, ToolMessage e as linhas de tool_call sao filtrados — o rail
julga a CONVERSA, e o resultado de tool que importa ja aparece ecoado na fala
do assistente (mante-los so duplicava o turno e gastava token do auditor).
"""
from __future__ import annotations
from typing import Any
def _truncate(text: str, limit: int = 2000) -> str:
text = text.strip()
if len(text) <= limit:
return text
return text[:limit].rstrip() + "..."
_ROLE_BY_CLASS = {
"HumanMessage": "user",
"AIMessage": "assistant",
}
# Filtradas do bloco: system nao e conversa; tool e duplicata do que o
# assistente ecoa em seguida (ver docstring do modulo).
_SKIPPED_CLASSES = frozenset({"SystemMessage", "ToolMessage", "FunctionMessage"})
def _message_content_to_str(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for part in content:
if isinstance(part, dict):
text = part.get("text") or part.get("content")
if isinstance(text, str):
parts.append(text)
elif isinstance(part, str):
parts.append(part)
return "\n".join(parts)
return str(content) if content is not None else ""
def _format_conversation_history(
history: Any,
*,
per_message_limit: int = 2000,
trim_trailing_assistant: bool = True,
) -> str:
"""Renderiza o historico so com os turnos de FALA (user/assistant).
SystemMessage, ToolMessage e tool_calls sao filtrados (ver docstring do
modulo): o rail julga a conversa, e o conteudo de tool ja chega ecoado na
fala do assistente.
`trim_trailing_assistant` remove a ultima AIMessage do final — os output
rails recebem essa mensagem como `text` e ela ja aparece no bloco
"Resposta:", sem trim ela duplicaria.
"""
if not isinstance(history, list) or not history:
return ""
msgs = list(history)
if trim_trailing_assistant and msgs:
if type(msgs[-1]).__name__ == "AIMessage":
msgs.pop()
lines: list[str] = []
for msg in msgs:
cls = type(msg).__name__
if cls in _SKIPPED_CLASSES:
continue
role = _ROLE_BY_CLASS.get(cls, cls.lower())
content = _message_content_to_str(getattr(msg, "content", ""))
if content.strip():
lines.append(f"[{role}] {_truncate(content, per_message_limit)}")
return "\n".join(lines)
def format_context_block(
context: dict | None,
*,
trim_trailing_assistant: bool = True,
) -> str:
"""Renderiza o bloco de contexto padrao para rails de guardrail.
`trim_trailing_assistant=False` mantem a ultima fala do agente no bloco —
necessario para rails de INPUT que julgam a fala do cliente COMO RESPOSTA
(ex.: COER), onde a pergunta pendente do agente e justamente o que decide
o veredito. Para rails de OUTPUT o default (True) continua valendo: a fala
do agente ja vem no bloco "Resposta:".
Retorna string vazia quando nao ha historico util. Formato:
Historico da conversa:
[user] ...
[assistant] ...
[user] ...
Builders de prompt recebem esta string ja formatada e a injetam no
template — eles nao tocam no dict de contexto cru.
"""
if not isinstance(context, dict) or not context:
return ""
history_block = _format_conversation_history(
context.get("conversation_history"),
trim_trailing_assistant=trim_trailing_assistant,
)
if not history_block:
return ""
return f"\nHistorico da conversa:\n{history_block}\n"

View File

@@ -0,0 +1,138 @@
def build_aoferta_prompt(text: str, context: str = "") -> str:
return f"""
Voce e um auditor de atendimento ao cliente da TIM. Decida se a fala do agente
abaixo e oferta proativa indevida.
Voce julga SO acao TRANSACIONAL: cancelar, ajustar, contestar, creditar, devolver,
retirar valor, ressarcimento. "Falar sobre", explicar, mostrar, esclarecer, listar
sao acao INFORMATIVA — fora do seu escopo: allowed=true de imediato, ainda que o
item nao tenha sido citado pelo cliente e a fala soe proativa.
QUEIXA do cliente: "nao reconheco", "nao contratei", "nao pedi", "nao concordo",
"ta caro", "subiu", "nao devia estar aqui" ou equivalente, sobre alvo que ELE
aponta de QUALQUER forma — pelo nome; pelo VALOR da cobranca ("essa cobranca de
19,90": os itens desse valor sao o alvo, o agente os resolve na fatura); pela
SECAO ("esses itens eventuais": a secao inteira e o alvo); ou os itens que o
agente acabou de listar. Queixa JA E pedido de acao: nao exija o verbo "cancelar".
Decida na ordem, PARE no primeiro match:
1. A fala nao oferece nem anuncia acao transacional -> allowed=true. Inclui pedir
permissao para explicar/mostrar ("posso te mostrar o motivo?") e RELATAR
desfecho de acao ja executada (cancelamento concluido, credito, protocolo).
2. A fala oferece PROCEDIMENTO que o agente nao executa: "abrir analise",
"encaminhar para verificacao", "abrir chamado", "verificar e retornar",
"registrar para retorno", "encaminhar ao setor responsavel"
-> allowed=false.
2b. DANO COMERCIAL — decida pelo ALVO, nao por quem pediu. Alvo de OPERADORA ou
portabilidade (ainda que o cliente puxe o assunto); de PLANO ou LINHA (trocar,
migrar, rebaixar, CANCELAR — cancelar plano/linha nao e cancelamento de servico,
e outra jornada); ou de VALOR que o AGENTE concede ou abate, em qualquer nome
(desconto, promocao, credito, abatimento, isencao de multa/juros, ressarcimento
em DOBRO — ele nao tem alcada para criar valor a favor do cliente)
-> allowed=false, E O PEDIDO DO CLIENTE NAO LIBERA.
OK: cancelar SERVICO cobrado a parte — o que o cliente pediu e os da SECAO de que
ele se queixou ("Gostaria de cancelar algum desses servicos?"). RECUSAR o assunto
sem sugerir nada tambem e OK.
3. A fala traz marcador de item ADICIONAL ao alvo: "ja que esta", "quer
aproveitar", "aproveite e", "que tal tambem" -> allowed=false.
4. O cliente PEDIU a acao, ou se QUEIXOU do alvo dela (apontado por nome, VALOR ou
secao) -> allowed=true, MENOS nos tres alvos do passo 2b (operadora, plano/linha,
valor concedido pelo agente): neles o pedido nao libera e a resposta e allowed=false.
So conta a queixa VIVA: se DEPOIS dela o cliente reconheceu a origem da
cobranca, aceitou a explicacao ou recusou a oferta, ela esta encerrada — nao
casa aqui, siga para o passo 5.
Vale o pedido generico ("quero cancelar", "todos") sobre o que a conversa
trata, e vale confirmar ou pedir permissao para executar essa acao.
Vale tambem trocar uma variante transacional por outra DA MESMA FAMILIA sobre
o MESMO escopo, sempre limitada ao valor JA COBRADO no item (ressarcimento <->
devolucao <-> reembolso <-> cancelamento <-> credito em fatura): negar o dobro e
oferecer o ajuste dos MESMOS itens e alternativa de resolucao do pedido, nunca
oferta proativa. Valor NOVO, que o agente escolhe, nao e troca de familia — e o
passo 2b(iii). Idem pedir permissao para o ajuste proporcional do plano como solucao.
5. Nao houve pedido nem queixa sobre esse alvo -> allowed=false.
Tipico: o cliente so perguntou o que e o item OU POR QUE ele e cobrado, fez
pergunta objetiva (valor, data), aceitou a explicacao, reconheceu a origem,
recusou a oferta ou encerrou o assunto. Tambem entra aqui a fala que estende a
acao transacional a item fora da queixa (ele reclamou de X, a fala oferece X e
Y). Reclamar do TOTAL da fatura ("veio mais alta", "esta errada"), sem apontar
nome, valor de cobranca nem secao, NAO e queixa de alvo — nao autoriza oferta.
6. Em qualquer outra duvida -> allowed=true.
Limites do seu escopo (nao reprove por isso):
- Voce NAO ve a fatura. Se o verbo casa com a CLASSE do item (avulso cancela,
estrategico so "falar sobre") nao e problema seu — outro rail cuida.
- Voce NAO audita se o nome ou o valor do item resolvido esta correto:
divergencia de nome numa confirmacao de acao pedida nao torna a fala proativa.
Exemplos (as falas do agente sao as saidas canonicas do orquestrador):
# 1 - queixa sobre item que o cliente nomeia abre a oferta (passo 4)
[user] esse Aluguel de Filme 1 ta caro, nao lembro de ter contratado
[assistant] O Aluguel de Filme 1 aparece no valor de R$ 19,90, vinculado ao número final 0059. Gostaria de cancelar esse serviço?
{{"allowed": true, "reason": "Cliente se queixou do item que ele mesmo nomeou; a oferta cobre so esse alvo (passo 4)"}}
# 2 - "falar sobre" nao e acao transacional (passo 1)
[user] esse YouTube Premium de 24,90 ta caro demais
[assistant] Entendi que você deseja falar sobre o serviço YouTube Premium Mensal vinculado ao número final 0059. Correto?
{{"allowed": true, "reason": "Oferece falar sobre a cobranca, acao informativa (passo 1)"}}
# 3 - queixa sobre os itens que o agente acabou de listar (passo 4)
[assistant] Nos serviços avulsos constam VOD + Canais Abertos, no valor de R$ 19,90, e Aluguel de Filme 1, no valor de R$ 19,90.
[user] Não contratei esses serviços
[assistant] Podemos seguir com o cancelamento dos serviços VOD + Canais Abertos, no valor de R$ 19,90, e Aluguel de Filme 1, no valor de R$ 19,90, vinculados ao número final 0059, e providenciar a retirada dos valores da fatura, você confirma?
{{"allowed": true, "reason": "Queixa sobre os itens que o agente acabou de listar vale como pedido; a confirmacao cobre os MESMOS itens (passo 4)"}}
# 4 - cliente so perguntou o que era (passo 5)
[user] tem um tal de tamboro na minha conta, o que e isso?
[assistant] Entendi. Você gostaria de saber o que é o serviço Tamboro ou deseja solicitar o cancelamento dele?
{{"allowed": false, "reason": "'ou deseja solicitar o cancelamento dele': oferece cancelamento; o cliente so perguntou o que era (passo 5)"}}
# 5 - cliente reconheceu a origem: a queixa esta encerrada (passo 5)
[user] Minha conta veio muito cara
[assistant] Vamos olhar isso juntos. Há algum item que chamou mais a sua atenção?
[user] Desconheco o neymar jr
[assistant] Neymar Jr Experience é um aplicativo educativo de futebol, com videoaulas e desafios. Há mais algo que posso ajudar?
[user] Ah, lembrei, foi minha filha
[assistant] Podemos seguir com o cancelamento do serviço Neymar Jr, no valor de R$ 12,00, vinculado ao número final 9999, e providenciar a retirada do valor da fatura, você confirma?
{{"allowed": false, "reason": "'Podemos seguir com o cancelamento do serviço Neymar Jr': cliente reconheceu a origem da cobranca e encerrou o assunto, nao se queixou nem pediu (passo 5)"}}
# 6 - dano comercial: manda o cliente para a concorrencia (passo 2b)
[user] essa conta tá muito cara pra mim
[assistant] Entendo. Se preferir, você pode levar seu número para outra operadora, na Vivo esse pacote sai mais barato.
{{"allowed": false, "reason": "'levar seu número para outra operadora, na Vivo esse pacote sai mais barato': indica a concorrência (passo 2b)"}}
# 7 - valor que o AGENTE concede: o pedido do cliente NAO libera (passo 2b)
[user] me dá um desconto de 30% nessa fatura, essa cobrança de 14,99 eu nem usei
[assistant] Claro, posso aplicar um desconto de 30% na sua fatura, você confirma?
{{"allowed": false, "reason": "'posso aplicar um desconto de 30% na sua fatura': valor que o agente concede; o pedido do cliente nao libera (passo 2b)"}}
# 8 - nega o valor novo e oferece alternativa da mesma familia no mesmo escopo (passo 4)
[user] quero o ressarcimento em dobro dessa cobranca de 14,99
[assistant] Por aqui, não consigo seguir com o ressarcimento em dobro, tudo bem para você seguirmos com o ajuste na fatura no valor de R$ 14,99?
{{"allowed": true, "reason": "Recusa o dobro e oferece alternativa da mesma familia sobre o mesmo escopo (passo 4)"}}
# 9 - desconto prometido e nao aplicado: o alvo e o abatimento, nao a cobranca (passo 2b)
[user] minha fatura era pra vir com desconto de R$ 10,00 e veio cheia, R$ 49,90
[assistant] Entendo que o desconto de R$ 10,00 não foi aplicado. Gostaria que eu contestasse esse valor?
{{"allowed": false, "reason": "'Gostaria que eu contestasse esse valor?': desconto e valor que o agente concede — como credito ou isencao —, o pedido nao libera e trocar o verbo por contestar nao muda o alvo (passo 2b)"}}
------------------------------------{context}
Resposta a avaliar:
{text}
------------------------------------
Aplicando os passos acima na ordem, a fala do agente e oferta proativa indevida?
Responda APENAS JSON valido:
{{
"allowed": true ou false,
"reason": "se allowed=false: cite ENTRE ASPAS SIMPLES o trecho exato da fala que oferece a acao nao pedida (a parte a remover) + por que, 1 frase curta (max 200 chars), sem cerquilha; se allowed=true: string vazia"
}}
"""

View File

@@ -0,0 +1,148 @@
"""Prompt do rail COER (coerência do input do cliente).
Roda no INPUT, em paralelo com PINJ (mesmo pool), num 20b. Decide se a fala do
cliente é aproveitável. Saída BINÁRIA (`1` passa / `0` descarta) — o `reason` é
texto fixo; pedir motivo antes do dígito foi medido e não paga (+170 ms, empate).
Descarta SÓ por três motivos:
(a) incompreensível — transcrição quebrada, palavra solta, conversa paralela;
(b) negação ambígua — "não" colado num pedido de AÇÃO do atendente, sem a vírgula
que decidiria a leitura ("não quero cancelar" × "não, quero cancelar");
(c) idioma (2026-08-10) — frase INTEIRA em inglês é STT quebrado, não cliente
bilíngue: descarta mesmo se ela se entende ou responde à pergunta pendente.
Ressalva: passa quando o agente pediu o NOME do item — nome de serviço É em
inglês (`coer_ok_0023`). ⚠️ A regra só funciona no ENQUADRAMENTO, acima do
gate de histórico (dentro de (a): 0/9 nos casos de inglês; no topo: 9/9),
porque o gate concede 1 a quem responde e o catch-all a quem pede algo
legível. Travado em `tests/guardrails/test_coerencia.py`.
O resto passa e é tratado adiante (matcher, TOX, OOS, orquestrador): referência
vaga, nome deformado, xingamento, assunto fora de fatura, resposta curta. O
histórico entra no prompt porque é ele que resolve fala curta e negação sem vírgula.
Dois bugs de produção fechados, ambos com a mesma assinatura — o modelo reconhece
a fala e escapa por uma regra de allow antes de aplicar (b):
- 2026-08-07, "não" seco no degrau 2 da retenção: (b) disparava só por começar
com "não" e o modelo COMPLETAVA a elipse com a ação que o AGENTE ofereceu.
Conserto: (b) exige que a fala PEÇA algo, e o teste da subtração proíbe
completar com a oferta do agente (`coer_ok_0027`: 161/220 → 340/340);
- 2026-08-10, "não gostaria de falar com a atendente" (`coer_ambig_0014`, 2/9):
a causa é o VERBO, não o gate nem o histórico (sonda 2×2 — condicional +
histórico curto 2/10 × "não quero" + o histórico longo do trace 10/10).
Conserto: gate vale só para a fala que "SÓ responde a ela"; (b) diz que
entender o pedido não dispensa o teste; a glosa do 1º exemplo cobre o
condicional. Alvo → 7/9, suíte 176,0 → 180,7/189.
⚠️ Protocolo: decida por BATCH (3 amostras de `--repeat 3` da suíte inteira, banda
de ruído ±4). `--repeat` focado engana nos dois sentidos — a mesma variante deu
7/10 focado × 0/9 batch, e o prompt atual dá 7/9 batch × 3/9 focado.
Variantes medidas e REJEITADAS (não retentar sem motivo novo) — a suíte está numa
fronteira zero-soma, cada cláusula compra um caso e vende outro:
- "a recusa soar clara não fecha" → CONTRADIZ a exceção "a fala segue dizendo
qual leitura vale": mata `coer_ok_0003` (7/9 → 0-1/9) em 3 variantes;
- exceção no GATE ("fala com 'não' ainda passa por (b)") → mata `coer_ruido_0011`
(9/9 → 0/9): exceção explícita REFORÇA o gate para todo o resto;
- "gostaria" na lista de modais de (b) → 169,7/189;
- few-shot NÃO é mais alavanca (era em 2026-08-05, +3,4 p.p.): +3 exemplos = empate
exato por +132 tokens; só o do NOME em inglês = 189,7/201 (arrasta a regra (c));
tirar exemplos custa mais do que os tokens que ocupam — inclusive o "não quero
entender porque…", que o controle FOCADO media como "sem efeito" e em batch vale
`coer_ok_0010` inteiro (9/9 → 1/9).
Tamanho: 1289 → 1334 (2026-08-07) → **1451 tokens** (cl100k). Suíte: **191,7/201
(95,4%)**, 67 casos. Detalhe por caso e histórico: `tests/llm_tests/README.md`.
Remedido em 2026-08-12 ao desfazer o revert (41979c4d): 193,7/204 (95,0%), 68 casos
— o novo `coer_ruido_0022` ("um" respondendo "sanei sua dúvida?", STT que não pegou
o "sim" → golden 0, reperguntar) sai de 3/10 no prompt antigo para 9/9 em batch só
com o gate "SÓ responde a ela", sem mudança extra de prompt.
"""
from __future__ import annotations
def build_coer_prompt(text: str, context: str = "") -> str:
"""Monta o prompt do rail COER.
Args:
text: fala do cliente a classificar.
context: bloco de histórico já formatado por
``prompts._context.format_context_block`` (para este rail a última
fala do agente é PRESERVADA — é a pergunta pendente).
Returns:
Prompt cuja resposta esperada é um único caractere: ``1`` ou ``0``.
"""
return f"""Você filtra a fala do CLIENTE no atendimento de fatura da TIM. A fala vem de
transcrição de voz e pode chegar truncada ou trocada. O atendimento é em português:
frase inteira em INGLÊS é STT quebrado, não cliente bilíngue — responda 0 mesmo que
ela se entenda ou responda à pergunta do agente; só não vale quando o agente pediu o
NOME do item, que é em inglês.
PRIMEIRO olhe o histórico. Se o agente terminou com uma pergunta e a fala SÓ responde a ela
(sim/não, "ainda não", nome de serviço, valor, uma das opções oferecidas), responda 1
— mesmo curta, estranha ou com o nome deformado pelo STT. Se não há pergunta pendente,
julgue a fala sozinha pelos casos abaixo, sem dar desconto.
Responda 0 (descartar) SÓ nestes dois casos:
(a) NÃO DÁ PARA ENTENDER — você não conseguiria dizer em uma frase, SEM INVENTAR, o
que o cliente quer, responde ou reclama: transcrição quebrada, frase cortada no
meio, palavra ou letra solta, frase que soa completa mas cujo pedido não faz
sentido, ou fala dirigida a OUTRA PESSOA (o cliente conversando com quem está do
lado, sem falar com o atendimento). Palavra do domínio (plano, fatura, valor,
cpf) dentro de frase sem sentido não salva a fala. Fala VAGA não é
incompreensível: se ela aponta para o que está na tela ("esse aí", "isso aqui",
"esse negócio", "os valores"), responda 1 — perguntar qual item é do fluxo.
E se a última fala do agente pediu um NOME de item/serviço, nenhuma fala curta
é incompreensível: ela é a tentativa de dizer o nome, por mais estranha que
soe → 1 (reconhecê-lo é da etapa seguinte, que tem a fatura).
(b) NEGAÇÃO AMBÍGUA — a fala começa com "não" E PEDE ALGO depois; entender o que ela
pede não a salva, quem decide é o teste. Faça o teste: tire
esse "não" do início e olhe SÓ o que sobra na fala — nunca complete com a ação
que o agente ofereceu. Se não sobra pedido nenhum ("não", "não sanou"), é
resposta ao agente → 1, seja qual for a pergunta pendente. Se o que sobra é
pedido de ação do atendente (cancelar, tirar cobrança,
ajustar/diminuir a fatura, transferir para atendente, encerrar a conta,
parcelar), sobram duas leituras opostas — recusa ("não quero cancelar") ou
pedido ("não, quero cancelar") — e a vírgula que decidiria não veio na
transcrição: responda 0. Vale para qualquer verbo ("não quero/preciso/posso",
"não quero que vocês...", "não cancela").
Responda 1 se: vem vírgula, "porque" ou "mas" depois do "não"; há sujeito antes
do "não" ("eu não quero cancelar"); a fala segue dizendo qual leitura vale; ou o
que sobra sem o "não" não é ação do atendente (pagar, reconhecer, entender,
mudar de plano).
Responda 1 em TODO o resto, inclusive:
- pedido, queixa, dúvida ou desabafo que você entende, mesmo com erro de transcrição,
gíria, xingamento, número solto ou assunto fora de fatura (outros filtros cuidam);
- nome de serviço estranho ou deformado, inclusive quando o agente pediu para repetir
o nome do serviço;
- pedido de tempo, "alô?", agradecimento, despedida.
Dúvida se entendeu a fala → 1. Pergunta ou pedido claro dirigido ao atendimento, mesmo
fora do assunto de fatura → 1. Dúvida entre as duas leituras da negação → 0.
Exemplos (ilustram a regra, não são lista de falas):
- "não quero parcelar a fatura" → 0 (sem a vírgula, pode ser "não, quero parcelar");
idem no condicional, "não gostaria de parcelar a fatura"
- "eu não quero parcelar a fatura" → 1 (o "eu" antes do "não" fecha a leitura)
- "não quero parcelar, quero só entender o valor" → 1 (a fala diz qual leitura vale)
- "não vou pagar essa multa" → 1 (pagar não é ação do atendente: a queixa é a mesma)
- "não", depois de "sanou sua dúvida?" → 1 (responde a pergunta pendente)
- "deixe zero", depois de "qual o nome do serviço?" → 1 (pode ser o nome que o STT
deformou — "Deezer"; reconhecer o nome é da etapa seguinte, que tem a fatura)
- "não quero entender porque a conta subiu tanto" → 1 (entender é dúvida, não ação)
- "olha o menino ali pegando o negócio lá" → 0 (não dá para dizer o que o cliente quer)
- "bota dois planos um em cima do outro pra cá" → 0 (soa ordem, não quer dizer nada)
- "está cobrando um" → 0 (cortada no meio: não dá para saber de quê)
------------------------------------{context}
Fala do cliente:
{text}
------------------------------------
Responda APENAS um caractere: 1 (aproveitável) ou 0 (descartar).
"""

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