Projeto do Agent Contas ORACLE
This commit is contained in:
@@ -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:
|
||||
@@ -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: []
|
||||
28
agent_framework_oci/libs/agent_framework/config/judges.yaml
Normal file
28
agent_framework_oci/libs/agent_framework/config/judges.yaml
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
90
agent_framework_oci/libs/agent_framework/config/tools.yaml
Normal file
90
agent_framework_oci/libs/agent_framework/config/tools.yaml
Normal 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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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'
|
||||
```
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
207
agent_framework_oci/libs/agent_framework/docs/MCP_CACHE.md
Normal file
207
agent_framework_oci/libs/agent_framework/docs/MCP_CACHE.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
35
agent_framework_oci/libs/agent_framework/pyproject.toml
Normal file
35
agent_framework_oci/libs/agent_framework/pyproject.toml
Normal 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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
agent_framework
|
||||
@@ -0,0 +1,4 @@
|
||||
__all__ = ['settings']
|
||||
from .config.settings import settings
|
||||
|
||||
from .idempotency import IdempotencyStore, InMemoryIdempotencyStore, create_idempotency_store
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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])
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()))
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
from .usage_repository import UsageRecord, UsageRepository, SQLiteUsageRepository, OracleUsageRepository, create_usage_repository
|
||||
@@ -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)
|
||||
0
agent_framework_oci/libs/agent_framework/src/agent_framework/cache/__init__.py
vendored
Normal file
0
agent_framework_oci/libs/agent_framework/src/agent_framework/cache/__init__.py
vendored
Normal file
184
agent_framework_oci/libs/agent_framework/src/agent_framework/cache/cache.py
vendored
Normal file
184
agent_framework_oci/libs/agent_framework/src/agent_framework/cache/cache.py
vendored
Normal 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))
|
||||
@@ -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}
|
||||
@@ -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: ...
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
from .mcp_gateway_client import MCPGatewayClient
|
||||
|
||||
__all__ = ["MCPGatewayClient"]
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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]
|
||||
@@ -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()],
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}}
|
||||
"""
|
||||
@@ -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
Reference in New Issue
Block a user