first commit

This commit is contained in:
2026-06-13 08:23:21 -03:00
commit 89c23fb0ed
439 changed files with 32801 additions and 0 deletions

BIN
app/.DS_Store vendored Normal file

Binary file not shown.

0
app/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

15
app/agents/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Agentes do Template Backend Enterprise
Os arquivos desta pasta preservam a estrutura real esperada pelo workflow, mas
não executam lógica de negócio pronta.
Cada agente mostra:
- como emitir IC;
- como emitir NOC;
- como emitir GRL;
- como coletar MCP via `_collect_tool_context()`;
- como recuperar RAG via `_retrieve_rag_context()`;
- onde chamar LLM/cache.
A implementação original do exemplo está comentada no fim de cada arquivo.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,421 @@
from __future__ import annotations
from typing import Any
from app.agents.prompting import apply_agent_profile_prompt
from app.identity_extraction import extract_identity_from_text
from app.agents.runtime import AgentRuntimeMixin
class BackofficeAgent(AgentRuntimeMixin):
"""Agente Backoffice/ANATEL usando somente contratos nativos do framework.
Este agente não contém grafo próprio, node legado ou orquestração paralela ao
framework. A execução segue o padrão uniforme:
1. LangGraph do framework faz guardrails de entrada, roteamento e checkpoint.
2. EnterpriseRouter injeta intent, route e mcp_tools a partir de YAML.
3. AgentRuntimeMixin executa MCP Tool Router, RAG, cache, IC/NOC/GRL.
4. LLM recebe contexto já normalizado e gera a resposta.
5. Workflow do framework aplica OutputSupervisor, output guardrails, judges e persistência.
Regras específicas do domínio ficam em prompt_policy/routing/tools/mcp mapping,
não em um fluxo particular dentro do agente.
"""
name = "backoffice_agent"
_domain_graph_cache: dict[str, Any] = {}
DEFAULT_TOOLS_BY_INTENT: dict[str, list[str]] = {
"backoffice_anatel_triage": [
"consultar_reclamacao",
"consultar_cliente_backoffice",
"consultar_siebel_caso",
"consultar_imdb_cliente",
"consultar_speech_analytics",
"consultar_tais_kb",
"consultar_abrt",
"consultar_portabilidade",
],
"backoffice_customer_lookup": [
"consultar_cliente_backoffice",
"consultar_imdb_cliente",
"consultar_portabilidade",
],
"backoffice_action_register": [
"consultar_reclamacao",
"consultar_siebel_caso",
"registrar_acao_backoffice",
"registrar_acao_siebel",
],
"backoffice_response_emulator": [
"consultar_reclamacao",
"buscar_templates_emulador",
"gerar_rascunho_emulador",
],
}
def __init__(self, llm, telemetry=None, tool_router=None, rag_service=None, cache=None, settings=None, observer=None):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
async def run(self, state: dict[str, Any]) -> dict[str, Any]:
await self._emit_ic(
"AGA.001",
state,
{
"status": "Entrada recebida pelo agente nativo do framework",
"framework_native": True,
"business_component": "backoffice_anatel",
},
component="agent.backoffice.native.start",
)
normalized_state = self._normalize_state_tools(state)
await self._emit_ic(
"AGA.018",
normalized_state,
{
"status": "Contexto canônico validado pelo framework",
"missing_fields": self._missing_required_context(normalized_state),
"framework_native": True,
},
component="agent.backoffice.native.context",
)
if self._has_original_ticket_context(normalized_state):
await self._emit_ic(
"BACKOFFICE_TICKET_CONTEXT_DETECTED",
normalized_state,
{
"status": "Contexto de ticket detectado; execução checklist deve ocorrer pelo BackofficeNativeRuntime nas rotas /agent/*",
"framework_native": True,
"reason": "o agente conversacional não compila nem executa grafos de domínio",
},
component="agent.backoffice.native.ticket_context",
)
rag_context, rag_metadata = await self._retrieve_rag_context(normalized_state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"AGA.012",
normalized_state,
{
"status": "RAG/TAIS KB consultado pelo serviço nativo do framework",
"document_count": rag_metadata.get("document_count", 0),
"top_document_ids": rag_metadata.get("top_document_ids", []),
},
component="agent.backoffice.native.rag",
)
mcp_results = await self._execute_tools_by_intent(normalized_state)
await self._emit_ic(
"AGA.014",
normalized_state,
{
"status": "Ferramentas MCP selecionadas pelo roteador do framework",
"intent": normalized_state.get("intent"),
"tool_count": len(mcp_results),
"tools": [r.get("tool_name") or r.get("tool") for r in mcp_results],
},
component="agent.backoffice.native.tools",
)
answer = await self._generate_answer(normalized_state, rag_context, rag_metadata, mcp_results)
await self._emit_ic(
"AGA.043",
normalized_state,
{
"status": "Resposta produzida pelo agente nativo e entregue ao workflow do framework",
"answer_chars": len(answer or ""),
"framework_native": True,
},
component="agent.backoffice.native.completed",
)
await self._emit_noc(
"001",
normalized_state,
{"message": "Backoffice native agent completed", "type": "INFO"},
component="agent.backoffice.native.completed",
)
return {
"answer": answer,
"next_state": self._next_state(normalized_state, mcp_results),
"mcp_results": mcp_results,
"rag_metadata": rag_metadata,
"framework_native": {
"agent": self.name,
"intent": normalized_state.get("intent"),
"route": normalized_state.get("route"),
"tools": normalized_state.get("mcp_tools") or [],
},
}
def _normalize_state_tools(self, state: dict[str, Any]) -> dict[str, Any]:
intent = state.get("intent") or "backoffice_anatel_triage"
configured_tools = list(state.get("mcp_tools") or [])
default_tools = self.DEFAULT_TOOLS_BY_INTENT.get(intent, self.DEFAULT_TOOLS_BY_INTENT["backoffice_anatel_triage"])
tools = configured_tools or default_tools
# Garante ordem estável e remove duplicidade sem perder a configuração do roteador.
seen: set[str] = set()
deduped = []
for tool in tools:
if tool and tool not in seen:
seen.add(tool)
deduped.append(tool)
return {
**state,
"route": state.get("route") or self.name,
"active_agent": self.name,
"intent": intent,
"mcp_tools": deduped,
}
def _missing_required_context(self, state: dict[str, Any]) -> list[str]:
ctx = state.get("context") or {}
bc = ctx.get("business_context") or state.get("business_context") or {}
interaction = (bc.get("interaction_key") if isinstance(bc, dict) else None) or ctx.get("interaction_key")
if not interaction:
interaction = (
ctx.get("protocol_id")
or ctx.get("protocolo")
or ctx.get("complaint_id")
or ctx.get("complaintProtocol")
or ctx.get("transactionId")
or ctx.get("transaction_id")
or ctx.get("ticket_id")
)
return [] if interaction else ["interaction_key"]
async def _execute_tools_by_intent(self, state: dict[str, Any]) -> list[dict[str, Any]]:
intent = state.get("intent") or "backoffice_anatel_triage"
tools = list(state.get("mcp_tools") or [])
results: list[dict[str, Any]] = []
for tool in tools:
arguments = self._tool_arguments(tool, intent, state)
if tool.startswith("registrar_") and not arguments.get("action_text"):
# O framework não deve registrar ação operacional sem texto explícito.
results.append({
"ok": False,
"tool_name": tool,
"skipped": True,
"reason": "action_text ausente; registro não executado por segurança operacional",
})
await self._emit_ic(
"AGA.008",
state,
{"status": "Registro operacional não executado sem action_text", "tool_name": tool},
component="agent.backoffice.native.tool.skip",
)
continue
result = await self._call_mcp_tool(tool, arguments, state)
results.append(result)
if tool in {"consultar_speech_analytics"}:
await self._emit_ic("AGA.010", state, {"status": "Speech Analytics consultado", "tool_ok": result.get("ok")}, component="agent.backoffice.native.speech")
elif tool in {"consultar_imdb_cliente", "consultar_cliente_backoffice"}:
await self._emit_ic("AGA.011", state, {"status": "Cliente/IMDB consultado", "tool_ok": result.get("ok")}, component="agent.backoffice.native.customer")
elif tool in {"consultar_tais_kb", "buscar_templates_emulador"}:
await self._emit_ic("AGA.020", state, {"status": "Templates/TAIS consultados", "tool_ok": result.get("ok")}, component="agent.backoffice.native.templates")
elif tool in {"registrar_acao_backoffice", "registrar_acao_siebel"}:
await self._emit_ic("AGA.006", state, {"status": "Ação operacional solicitada", "tool_ok": result.get("ok")}, component="agent.backoffice.native.action")
return results
def _tool_arguments(self, tool: str, intent: str, state: dict[str, Any]) -> dict[str, Any]:
ctx = state.get("context") or {}
# Para query/prompt usamos o texto sanitizado; para extração de CPF/CNPJ
# precisamos do texto original, pois o guardrail MSK mascara o documento.
text = state.get("sanitized_input") or state.get("user_text") or ""
original_text = (
ctx.get("message")
or ctx.get("text")
or ctx.get("query")
or state.get("user_text")
or text
or ""
)
bc = ctx.get("business_context") or state.get("business_context") or {}
explicit = ctx.get("tool_arguments") or state.get("tool_arguments") or {}
def pick(*names: str) -> Any:
for name in names:
cur: Any = None
if name in explicit:
cur = explicit.get(name)
elif isinstance(bc, dict) and name in bc:
cur = bc.get(name)
elif name in ctx:
cur = ctx.get(name)
elif name in state:
cur = state.get(name)
if cur not in (None, "", {}, []):
return cur
return None
protocol_id = pick(
"protocol_id",
"protocolo",
"interaction_key",
"complaint_id",
"complaintProtocol",
"transactionId",
"transaction_id",
"ticket_id",
)
extracted_identity = extract_identity_from_text(str(original_text))
# Identificador explicitamente digitado na mensagem atual prevalece sobre
# defaults da sessão/front (ex.: user_id/msisdn 11999999999).
customer_key = extracted_identity.get("customer_key") or pick("customer_key", "cpf", "cnpj", "document", "msisdn", "customer_id", "cpf_hash", "document_hash", "user_id")
if not protocol_id:
protocol_id = extracted_identity.get("protocol_id") or extracted_identity.get("interaction_key")
contract_key = pick("contract_key", "contract_id", "account_id", "asset_id")
session_key = pick("session_key", "conversation_key", "session_id")
# Extra args são passados ao MCPToolRouter. O mapper do framework ainda
# aplica mcp_parameter_mapping.yaml, mas estes aliases tornam a chamada
# resiliente para tools que exigem protocol_id diretamente.
common = {
"query": text,
"operator_instructions": text,
"selected_actions": explicit.get("selected_actions") or [],
}
if protocol_id:
common["protocol_id"] = str(protocol_id)
common["interaction_key"] = str(protocol_id)
if customer_key:
common["customer_key"] = str(customer_key)
# Mantém aliases documentais para tools TIM que aceitam CPF/CNPJ/document.
for identity_key in ("cpf", "cnpj", "document", "document_type"):
if extracted_identity.get(identity_key):
common[identity_key] = str(extracted_identity[identity_key])
if contract_key:
common["contract_key"] = str(contract_key)
if session_key:
common["session_key"] = str(session_key)
common["operator_session"] = str(session_key)
if tool.startswith("registrar_"):
action_text = explicit.get("action_text")
if not action_text and intent == "backoffice_action_register":
action_text = text
common["action_text"] = action_text
return common
async def _generate_answer(
self,
state: dict[str, Any],
rag_context: str,
rag_metadata: dict[str, Any],
mcp_results: list[dict[str, Any]],
) -> str:
missing = self._missing_required_context(state)
if missing:
return (
"[BackofficeAgent] Para seguir no padrão do framework, preciso do identificador canônico "
f"{', '.join(missing)}. Informe o protocolo/reclamação/chamado ou configure o mapping em identity.yaml."
)
system_prompt = apply_agent_profile_prompt(state, self._system_prompt())
messages = [
{"role": "system", "content": system_prompt},
{
"role": "user",
"content": (
"Mensagem do usuário:\n"
f"{state.get('sanitized_input') or state.get('user_text') or ''}\n\n"
"Intent/rota escolhidos pelo framework:\n"
f"intent={state.get('intent')} route={state.get('route')}\n\n"
"Resultados MCP normalizados pelo framework:\n"
f"{mcp_results}\n\n"
"Contexto RAG nativo do framework:\n"
f"{rag_context or '[sem contexto RAG]'}\n\n"
"Metadados RAG:\n"
f"{rag_metadata}"
),
},
]
try:
generated = await self._invoke_llm_cached(state, "BackofficeNativeAgent", messages)
return f"[BackofficeAgent] {generated}"
except Exception as exc:
await self._emit_noc(
"005",
state,
{"message": f"LLM failed in native backoffice agent: {type(exc).__name__}: {exc}", "type": "FAILURE"},
component="agent.backoffice.native.llm",
)
return self._fallback_answer(state, mcp_results)
def _has_original_ticket_context(self, state: dict[str, Any]) -> bool:
ctx = state.get("context") or {}
rc = ctx.get("request_context") or ctx
return bool(
rc.get("transactionId")
and isinstance(rc.get("complaint") or {}, dict)
and isinstance(rc.get("customer") or {}, dict)
)
async def _run_original_checklist_workflow(self, state: dict[str, Any]) -> dict[str, Any]:
"""Deprecated compatibility stub.
A migração framework-native não permite que o agente conversacional
compile/execute ``src.agent.graphs`` diretamente. Os fluxos checklist e
response emulator são executados pelo BackofficeNativeRuntime chamado
pelas rotas/adapters do backend.
"""
return {
"executed": False,
"error": {
"type": "DeprecatedDirectGraphExecution",
"message": "Use BackofficeNativeRuntime.execute_workflow('backoffice_checklist')",
},
}
def _system_prompt(self) -> str:
return """
Você é o agente Backoffice/ANATEL executando exclusivamente pelo framework corporativo.
Use apenas estes insumos:
- BusinessContext canônico do framework.
- Ferramentas MCP selecionadas pelo EnterpriseRouter.
- Contexto RAG retornado pelo RagService do framework.
- Memória/checkpoint já carregados pelo workflow.
Regras obrigatórias:
1. Não invente protocolo, cliente, contrato, status, SLA, parecer ou ação Siebel.
2. Se uma ferramenta MCP retornar erro ou ausência de dados, diga exatamente que a evidência não foi encontrada.
3. Não confirme registro de ação sem retorno ok/registered da tool correspondente.
4. Se faltar protocolo/chamado/reclamação, peça somente esse identificador.
5. Responda de forma operacional, curta e rastreável.
6. A resposta será validada por OutputSupervisor, guardrails de saída e judges do framework.
""".strip()
def _fallback_answer(self, state: dict[str, Any], mcp_results: list[dict[str, Any]]) -> str:
ok_tools = [r.get("tool_name") or r.get("tool") for r in mcp_results if r.get("ok")]
failed_tools = [r.get("tool_name") or r.get("tool") for r in mcp_results if not r.get("ok")]
return (
"[BackofficeAgent] Fluxo nativo executado pelo framework. "
f"Intent: {state.get('intent')}. "
f"Tools com sucesso: {ok_tools or 'nenhuma'}. "
f"Tools pendentes/erro: {failed_tools or 'nenhuma'}. "
"A resposta final não foi enriquecida pelo LLM porque houve falha controlada nessa etapa."
)
def _next_state(self, state: dict[str, Any], mcp_results: list[dict[str, Any]]) -> str:
if self._missing_required_context(state):
return "WAITING_BACKOFFICE_IDENTIFIER"
if any(r.get("skipped") for r in mcp_results):
return "BACKOFFICE_WAITING_ACTION_TEXT"
return "BACKOFFICE_ACTIVE"

View File

@@ -0,0 +1,70 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.runtime import AgentRuntimeMixin
class BillingAgent(AgentRuntimeMixin):
name = "billingAgent"
def __init__(self, llm, telemetry=None, tool_router=None, rag_service=None, cache=None, settings=None, observer=None):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
async def run(self, state):
await self._emit_ic(
"IC.BILLING_AGENT_STARTED",
state,
{"business_component": "faturas"},
component="agent.billing.start",
)
session = (state.get("context") or {}).get("session", {})
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.BILLING_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.billing.mcp",
)
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.BILLING_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.billing.rag",
)
messages = [
{"role": "system", "content": apply_agent_profile_prompt(state, "Você é um agente especialista em faturas. Responda com clareza, objetividade e sem sugerir ações não solicitadas. Use dados MCP quando disponíveis.")},
{"role": "user", "content": (
f"Mensagem: {state.get('sanitized_input') or state['user_text']}\n"
f"Contexto de sessão: {session}\n"
f"Intent: {state.get('intent')}\n"
f"Dados MCP: {tool_context}\n"
f"Contexto RAG: {rag_context}"
)},
]
answer = await self._invoke_llm_cached(state, "BillingAgent", messages)
result = {"answer": f"[BillingAgent] {answer}", "next_state": "BILLING_ACTIVE", "mcp_results": tool_context, "rag": rag_metadata}
await self._emit_ic(
"IC.BILLING_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
},
component="agent.billing.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

View File

@@ -0,0 +1,69 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.runtime import AgentRuntimeMixin
class OrdersAgent(AgentRuntimeMixin):
name = "orders_agent"
def __init__(self, llm, telemetry=None, tool_router=None, rag_service=None, cache=None, settings=None, observer=None):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
async def run(self, state):
await self._emit_ic(
"IC.ORDERS_AGENT_STARTED",
state,
{"business_component": "pedidos"},
component="agent.orders.start",
)
session = (state.get("context") or {}).get("session", {})
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.ORDERS_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.orders.mcp",
)
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.ORDERS_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.orders.rag",
)
messages = [
{"role": "system", "content": apply_agent_profile_prompt(state, "Você é um agente de pedidos de varejo. Use dados de tools quando disponíveis.")},
{"role": "user", "content": (
f"Mensagem: {state.get('sanitized_input') or state['user_text']}\n"
f"Sessão: {session}\n"
f"Intent: {state.get('intent')}\n"
f"Dados MCP: {tool_context}\n"
f"Contexto RAG: {rag_context}"
)},
]
answer = await self._invoke_llm_cached(state, "OrdersAgent", messages)
result = {"answer": f"[OrdersAgent] {answer}", "next_state": "ORDER_ACTIVE", "mcp_results": tool_context, "rag": rag_metadata}
await self._emit_ic(
"IC.ORDERS_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
},
component="agent.orders.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

View File

@@ -0,0 +1,70 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.runtime import AgentRuntimeMixin
class ProductAgent(AgentRuntimeMixin):
name = "productAgent"
def __init__(self, llm, telemetry=None, tool_router=None, rag_service=None, cache=None, settings=None, observer=None):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
async def run(self, state):
await self._emit_ic(
"IC.PRODUCT_AGENT_STARTED",
state,
{"business_component": "produtos"},
component="agent.product.start",
)
session = (state.get("context") or {}).get("session", {})
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.PRODUCT_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.product.mcp",
)
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.PRODUCT_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.product.rag",
)
messages = [
{"role": "system", "content": apply_agent_profile_prompt(state, "Você é um agente especialista em produtos, planos e serviços. Explique sem fazer oferta proativa e sem executar ações sem confirmação. Use dados MCP quando disponíveis.")},
{"role": "user", "content": (
f"Mensagem: {state.get('sanitized_input') or state['user_text']}\n"
f"Contexto de sessão: {session}\n"
f"Intent: {state.get('intent')}\n"
f"Dados MCP: {tool_context}\n"
f"Contexto RAG: {rag_context}"
)},
]
answer = await self._invoke_llm_cached(state, "ProductAgent", messages)
result = {"answer": f"[ProductAgent] {answer}", "next_state": "PRODUCT_ACTIVE", "mcp_results": tool_context, "rag": rag_metadata}
await self._emit_ic(
"IC.PRODUCT_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
},
component="agent.product.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

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

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

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

@@ -0,0 +1,267 @@
from __future__ import annotations
import hashlib
from typing import Any
class AgentRuntimeMixin:
"""Mixin operacional para agentes.
Integra RAG, cache, telemetria e chamadas MCP usando BusinessContext.
Os agentes não precisam conhecer nomes reais de parâmetros do domínio
(msisdn, invoice_id, order_id etc.); eles repassam as chaves canônicas e
o MCPParameterMapper converte para cada tool configurada.
"""
async def _emit_ic(self, code: str, state: dict[str, Any], payload: dict[str, Any] | None = None, component: str | None = None) -> None:
"""Emite Item de Controle (IC) sem impactar a execução do agente.
Este helper é intencionalmente fail-open: erro de observabilidade não
pode quebrar a jornada de negócio do agente. O desenvolvedor pode usar
o mesmo padrão para ICs específicos da sua squad.
"""
observer = getattr(self, "observer", None)
if not observer:
return
ctx = state.get("context") or {}
base = {
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"message_id": ctx.get("message_id"),
"channel_id": ctx.get("channel"),
}
base.update(payload or {})
try:
await observer.emit_ic(code, base, component=component or f"agent.{getattr(self, 'name', 'unknown')}")
except Exception:
return
async def _emit_noc(self, code: str, state: dict[str, Any], payload: dict[str, Any] | None = None, component: str | None = None) -> None:
"""Emite evento NOC sem acoplar a lógica de negócio à observabilidade."""
observer = getattr(self, "observer", None)
if not observer:
return
ctx = state.get("context") or {}
base = {
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"message_id": ctx.get("message_id"),
"channel_id": ctx.get("channel"),
}
base.update(payload or {})
try:
await observer.emit_noc(code, base, component=component or f"agent.{getattr(self, 'name', 'unknown')}")
except Exception:
return
async def _emit_grl(self, code: str, state: dict[str, Any], payload: dict[str, Any] | None = None, component: str | None = None) -> None:
"""Emite evento GRL opcional para custom rails implementados no backend."""
observer = getattr(self, "observer", None)
if not observer:
return
ctx = state.get("context") or {}
base = {
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"message_id": ctx.get("message_id"),
"channel_id": ctx.get("channel"),
}
base.update(payload or {})
try:
await observer.emit_grl(code, base, component=component or f"agent.{getattr(self, 'name', 'unknown')}")
except Exception:
return
async def _retrieve_rag_context(self, state: dict[str, Any]) -> tuple[str, dict[str, Any]]:
rag_service = getattr(self, "rag_service", None)
if not rag_service:
return "", {"enabled": False}
text = state.get("sanitized_input") or state.get("user_text") or ""
namespace = (
(state.get("agent_profile") or {}).get("rag_namespace")
or state.get("agent_id")
or state.get("route")
or "default"
)
ctx = state.get("context") or {}
business_context = ctx.get("business_context") or {}
graph_node = (
ctx.get("graph_node")
or business_context.get("customer_key")
or business_context.get("contract_key")
or ctx.get("customer_id")
)
result = await rag_service.retrieve(text, namespace=namespace, graph_node=graph_node)
context = result.as_prompt_context()
return context, {
"enabled": True,
"namespace": namespace,
"latency_ms": result.latency_ms,
"document_count": len(result.documents),
"graph_neighbors": len(result.graph_neighbors),
"top_document_ids": [d.id for d in result.documents[:5]],
"top_scores": [d.score for d in result.documents[:5]],
}
async def _call_mcp_tool(self, tool: str, arguments: dict[str, Any] | None, state: dict[str, Any]) -> dict[str, Any]:
"""Chama uma ferramenta via MCPToolRouter usando o contrato canônico do framework.
Use este helper quando o agente precisa passar argumentos específicos
além do BusinessContext mapeado em mcp_parameter_mapping.yaml.
Observabilidade IC.MCP_TOOL_CALLED/IC.TOOL_CALLED permanece uniforme.
"""
if not getattr(self, "tool_router", None):
return {"ok": False, "tool_name": tool, "error": "tool_router não configurado"}
ctx = state.get("context") or {}
business_context = ctx.get("business_context") or state.get("business_context") or {}
original_context = {
**ctx,
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"session_id": state.get("conversation_key") or state.get("session_id"),
"conversation_key": state.get("conversation_key") or state.get("session_id"),
}
observer = getattr(self, "observer", None)
if observer:
await observer.emit_ic(
"IC.MCP_TOOL_CALLED",
{
"session_id": original_context.get("conversation_key") or original_context.get("session_id"),
"tenant_id": original_context.get("tenant_id"),
"agent_id": original_context.get("agent_id"),
"tool_name": tool,
"framework_native": True,
},
component="agent_runtime.native_mcp",
)
try:
res = await self.tool_router.call(
tool,
arguments or {},
business_context=business_context,
original_context=original_context,
)
result_payload = res.model_dump(mode="json") if hasattr(res, "model_dump") else dict(res)
except Exception as exc:
result_payload = {"ok": False, "tool_name": tool, "error": str(exc), "error_type": type(exc).__name__}
result_payload.setdefault("tool_name", tool)
if observer:
await observer.emit_ic(
"IC.TOOL_CALLED",
{
"session_id": original_context.get("conversation_key") or original_context.get("session_id"),
"tenant_id": original_context.get("tenant_id"),
"agent_id": original_context.get("agent_id"),
"tool_name": tool,
"ok": result_payload.get("ok"),
"server_name": result_payload.get("server_name"),
"error": result_payload.get("error"),
"framework_native": True,
},
component="agent_runtime.native_mcp",
)
return result_payload
async def _collect_mcp_context(self, state: dict[str, Any]) -> list[dict[str, Any]]:
results: list[dict[str, Any]] = []
if not getattr(self, "tool_router", None):
return results
tools = state.get("mcp_tools") or []
ctx = state.get("context") or {}
business_context = ctx.get("business_context") or state.get("business_context") or {}
original_context = {
**ctx,
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"session_id": state.get("conversation_key") or state.get("session_id"),
"conversation_key": state.get("conversation_key") or state.get("session_id"),
}
for tool in tools:
observer = getattr(self, "observer", None)
if observer:
await observer.emit_ic(
"IC.MCP_TOOL_CALLED",
{
"session_id": original_context.get("conversation_key") or original_context.get("session_id"),
"tenant_id": original_context.get("tenant_id"),
"agent_id": original_context.get("agent_id"),
"tool_name": tool,
},
component="agent_runtime",
)
res = await self.tool_router.call(
tool,
{},
business_context=business_context,
original_context=original_context,
)
result_payload = res.model_dump(mode="json")
if observer:
await observer.emit_ic(
"IC.TOOL_CALLED",
{
"session_id": original_context.get("conversation_key") or original_context.get("session_id"),
"tenant_id": original_context.get("tenant_id"),
"agent_id": original_context.get("agent_id"),
"tool_name": tool,
"ok": result_payload.get("ok"),
"server_name": result_payload.get("server_name"),
"error": result_payload.get("error"),
},
component="agent_runtime",
)
results.append(result_payload)
return results
async def _cache_get(self, key: str):
cache = getattr(self, "cache", None)
if not cache:
return None
return await cache.get(key)
async def _cache_set(self, key: str, value: Any, ttl_seconds: int | None = None):
cache = getattr(self, "cache", None)
if not cache:
return
await cache.set(key, value, ttl_seconds)
def _llm_cache_key(self, state: dict[str, Any], agent_name: str, prompt_parts: list[Any]) -> str:
business_context = (state.get("context") or {}).get("business_context") or {}
raw = "|".join([
agent_name,
state.get("tenant_id") or "",
state.get("agent_id") or "",
state.get("intent") or "",
business_context.get("customer_key") or "",
business_context.get("contract_key") or "",
business_context.get("interaction_key") or "",
state.get("sanitized_input") or state.get("user_text") or "",
repr(prompt_parts),
])
return "llm:" + hashlib.sha256(raw.encode("utf-8")).hexdigest()
async def _invoke_llm_cached(self, state: dict[str, Any], agent_name: str, messages: list[dict[str, str]]):
ttl = int(getattr(getattr(self, "settings", None), "CACHE_TTL_SECONDS", 300) or 300)
key = self._llm_cache_key(state, agent_name, messages)
cached = await self._cache_get(key)
if cached is not None:
telemetry = getattr(self, "telemetry", None)
if telemetry:
await telemetry.event("cache.llm.hit", {"agent": agent_name, "key": key}, kind="cache")
return cached
telemetry = getattr(self, "telemetry", None)
if telemetry:
await telemetry.event("cache.llm.miss", {"agent": agent_name, "key": key}, kind="cache")
answer = await self.llm.ainvoke(messages)
await self._cache_set(key, answer, ttl)
return answer

View File

@@ -0,0 +1,67 @@
from app.agents.prompting import apply_agent_profile_prompt
from app.agents.runtime import AgentRuntimeMixin
class SupportAgent(AgentRuntimeMixin):
name = "support_agent"
def __init__(self, llm, telemetry=None, tool_router=None, rag_service=None, cache=None, settings=None, observer=None):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
async def run(self, state):
await self._emit_ic(
"IC.SUPPORT_AGENT_STARTED",
state,
{"business_component": "suporte"},
component="agent.support.start",
)
tool_context = await self._collect_tool_context(state)
if tool_context:
await self._emit_ic(
"IC.SUPPORT_MCP_CONTEXT_COLLECTED",
state,
{"tool_result_count": len(tool_context)},
component="agent.support.mcp",
)
rag_context, rag_metadata = await self._retrieve_rag_context(state)
if rag_metadata.get("enabled"):
await self._emit_ic(
"IC.SUPPORT_RAG_CONTEXT_RETRIEVED",
state,
{
"document_count": rag_metadata.get("document_count"),
"graph_neighbors": rag_metadata.get("graph_neighbors"),
"latency_ms": rag_metadata.get("latency_ms"),
},
component="agent.support.rag",
)
messages = [
{"role": "system", "content": apply_agent_profile_prompt(state, "Você é um agente de suporte de varejo para troca, devolução e garantia.")},
{"role": "user", "content": (
f"Mensagem: {state.get('sanitized_input') or state['user_text']}\n"
f"Intent: {state.get('intent')}\n"
f"Dados MCP: {tool_context}\n"
f"Contexto RAG: {rag_context}"
)},
]
answer = await self._invoke_llm_cached(state, "SupportAgent", messages)
result = {"answer": f"[SupportAgent] {answer}", "next_state": "SUPPORT_ACTIVE", "mcp_results": tool_context, "rag": rag_metadata}
await self._emit_ic(
"IC.SUPPORT_AGENT_COMPLETED",
state,
{
"answer_chars": len(result.get("answer") or ""),
"has_mcp_results": bool(tool_context),
"rag_enabled": bool(rag_metadata.get("enabled")),
},
component="agent.support.completed",
)
return result
async def _collect_tool_context(self, state):
return await self._collect_mcp_context(state)

BIN
app/domain/.DS_Store vendored Normal file

Binary file not shown.

0
app/domain/__init__.py Normal file
View File

Binary file not shown.

View File

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

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
from __future__ import annotations
import re
from typing import Any
_CPF_RE = re.compile(r"(?i)\bcpf\b\s*[:=\-]?\s*(\d{3}\.\d{3}\.\d{3}-\d{2}|\d{11})\b")
_CNPJ_RE = re.compile(r"(?i)\bcnpj\b\s*[:=\-]?\s*(\d{2}\.\d{3}\.\d{3}/\d{4}-\d{2}|\d{14})\b")
_MSISDN_RE = re.compile(r"(?i)\b(?:msisdn|linha|telefone|celular)\b\s*[:=\-]?\s*(\+?55)?\s*\(?\d{2}\)?\s*9?\d{4}[-\s]?\d{4}\b")
_PROTOCOL_RE = re.compile(r"(?i)\b(?:protocolo|protocol_id|chamado|reclama[cç][aã]o|ticket)\b\s*[:=\-#]?\s*([A-Za-z0-9][A-Za-z0-9._\-/]{3,})\b")
def _digits(value: str | None) -> str | None:
if not value:
return None
digits = re.sub(r"\D+", "", str(value))
return digits or None
def extract_identity_from_text(text: str | None) -> dict[str, str]:
"""Extrai chaves de negócio de mensagens livres do usuário.
O IdentityResolver do framework mapeia campos estruturados. Esta função só
complementa payloads textuais como: "consultar dados do cliente cpf 123...".
"""
text = text or ""
found: dict[str, str] = {}
cpf = _CPF_RE.search(text)
if cpf:
value = _digits(cpf.group(1))
if value and len(value) == 11:
found["customer_key"] = value
found["cpf"] = value
found["document"] = value
found["document_type"] = "cpf"
cnpj = _CNPJ_RE.search(text)
if cnpj:
value = _digits(cnpj.group(1))
if value and len(value) == 14:
found["customer_key"] = value
found["cnpj"] = value
found["document"] = value
found["document_type"] = "cnpj"
protocol = _PROTOCOL_RE.search(text)
if protocol:
value = protocol.group(1).strip()
if value and not value.lower().startswith(("cpf", "cnpj")):
found["interaction_key"] = value
found["protocol_id"] = value
found["protocolo"] = value
# Só captura MSISDN quando há rótulo explícito para evitar confundir CPF/CNPJ.
msisdn = _MSISDN_RE.search(text)
if msisdn and "customer_key" not in found:
value = _digits(msisdn.group(0))
if value:
# Remove prefixo 55 se o usuário digitou com DDI.
if value.startswith("55") and len(value) in {12, 13}:
value = value[2:]
found["customer_key"] = value
found["msisdn"] = value
found["document_type"] = "msisdn"
return found
def enrich_payload_with_text_identity(payload: dict[str, Any] | None) -> dict[str, Any]:
payload = dict(payload or {})
text = (
payload.get("message")
or payload.get("text")
or payload.get("query")
or payload.get("content")
or ""
)
extracted = extract_identity_from_text(str(text))
# Payload estruturado sempre tem prioridade; extração só preenche lacunas.
for key, value in extracted.items():
payload.setdefault(key, value)
return payload
def enrich_context_with_text_identity(context: dict[str, Any] | None, text: str | None) -> dict[str, Any]:
context = dict(context or {})
extracted = extract_identity_from_text(text)
for key, value in extracted.items():
context.setdefault(key, value)
return context

826
app/main.py Normal file
View File

@@ -0,0 +1,826 @@
from __future__ import annotations
import logging
from uuid import uuid4
import time
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from agent_framework.channels.base import ChannelResponse
from agent_framework.channels.gateway import ChannelGateway
from agent_framework.config.agent_registry import AgentProfileRegistry
from agent_framework.config.settings import settings
from agent_framework.analytics.factory import create_analytics_publisher
from agent_framework.observability.observer import AgentObserver
from src.compat.framework_observer import configure as configure_global_observer
from agent_framework.llm.providers import create_llm
from agent_framework.memory.message_history import create_memory
from agent_framework.mcp.tool_router import create_mcp_tool_router
from agent_framework.models.identity import AgentIdentity
from agent_framework.identity import IdentityResolver, BusinessContext
from agent_framework.models.session import ChatMessage, SessionContext
from agent_framework.observability.telemetry import Telemetry
from agent_framework.observability.context import set_observability_context, clear_observability_context
from agent_framework.repositories.session_repository import create_session_repository
from agent_framework.checkpoints.checkpoint_repository import create_checkpoint_repository
from agent_framework.cache.cache import create_cache
from agent_framework.billing.usage_repository import create_usage_repository
from agent_framework.sse.events import SSEHub
from app.workflows.agent_graph import AgentWorkflow
from app.workflows.backoffice_native_runtime import BackofficeNativeRuntime
from app.identity_extraction import enrich_payload_with_text_identity, extract_identity_from_text
logging.basicConfig(level=settings.LOG_LEVEL)
logger = logging.getLogger("agent_template_backend")
app = FastAPI(title="Agent Template Backend FIRST-ready")
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",")],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
telemetry = Telemetry(settings)
usage_repository = create_usage_repository(settings)
llm = create_llm(settings, telemetry=telemetry, usage_repository=usage_repository)
memory = create_memory(settings)
sessions = create_session_repository(settings)
checkpoints = create_checkpoint_repository(settings)
cache = create_cache(settings, telemetry=telemetry)
gateway = ChannelGateway()
analytics = create_analytics_publisher(settings)
observer = AgentObserver(analytics=analytics)
configure_global_observer({
"enabled": getattr(settings, "ENABLE_ANALYTICS", False),
"providers": getattr(settings, "ANALYTICS_PROVIDERS", "oci_streaming"),
"topic_path": getattr(settings, "GCP_PUBSUB_TOPIC_PATH", None) or getattr(settings, "AGENT_PUBSUB_TOPIC", None),
})
tool_router = create_mcp_tool_router(settings, telemetry=telemetry)
identity_resolver = IdentityResolver.from_yaml(settings.IDENTITY_CONFIG_PATH)
agent_profiles = AgentProfileRegistry(settings)
sse_hub = SSEHub(settings, telemetry=telemetry)
workflow = AgentWorkflow(llm, memory, telemetry, analytics, settings, observer=observer, tool_router=tool_router)
backoffice_runtime = BackofficeNativeRuntime(settings=settings, telemetry=telemetry, analytics=analytics, observer=observer)
logger.info("LLM provider carregado: %s", llm.__class__.__name__)
logger.info("Langfuse habilitado: %s host=%s", telemetry.is_enabled(), settings.LANGFUSE_HOST)
logger.info("Analytics habilitado: %s providers=%s", getattr(settings, "ENABLE_ANALYTICS", False), getattr(settings, "ANALYTICS_PROVIDERS", ""))
logger.info("Agentes disponíveis: %s", [p.agent_id for p in agent_profiles.list_profiles()])
@app.middleware("http")
async def observability_context_middleware(request: Request, call_next):
request_id = request.headers.get("x-request-id") or str(uuid4())
set_observability_context(
request_id=request_id,
channel=request.headers.get("x-channel") or "http",
ura_call_id=request.headers.get("x-ura-call-id"),
)
started = time.time()
try:
response = await call_next(request)
response.headers["x-request-id"] = request_id
await telemetry.event("http.request.completed", {
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": int((time.time() - started) * 1000),
}, kind="http")
return response
except Exception as exc:
await telemetry.event("http.request.failed", {
"method": request.method,
"path": request.url.path,
"error": str(exc),
"duration_ms": int((time.time() - started) * 1000),
}, kind="http")
raise
class GatewayRequest(BaseModel):
channel: str = "web"
payload: dict
agent_id: str | None = None
tenant_id: str | None = None
def _resolve_identity(req: GatewayRequest, msg) -> tuple[AgentIdentity, dict, BusinessContext, list[str]]:
payload = enrich_payload_with_text_identity(req.payload or {})
context = dict(msg.context or {})
tenant_id = req.tenant_id or payload.get("tenant_id") or context.get("tenant_id") or "default"
agent_id = req.agent_id or payload.get("agent_id") or context.get("agent_id") or agent_profiles.default_agent_id
profile = agent_profiles.get(agent_id)
# 1) Identidade técnica do framework: isola tenant/agente/sessão.
context.update({"tenant_id": tenant_id, "agent_id": profile.agent_id, "agent_profile": profile.__dict__})
identity = AgentIdentity.from_context(context, session_id=msg.session_id)
# 2) Identidade de negócio: chaves canônicas vindas do front/canal.
# Correção importante: identidade extraída explicitamente do texto da
# mensagem atual (cpf/cnpj/protocolo) deve prevalecer sobre valores
# estáveis herdados da sessão, como msisdn default do frontend.
text_for_identity = str(payload.get("message") or payload.get("text") or payload.get("query") or msg.text or "")
explicit_identity = extract_identity_from_text(text_for_identity)
previous_business_context = dict(context.get("business_context") or context.get("identity") or {})
if explicit_identity.get("customer_key"):
previous_business_context.pop("customer_key", None)
if explicit_identity.get("interaction_key") or explicit_identity.get("protocol_id"):
previous_business_context.pop("interaction_key", None)
resolver_payload = {**context, **payload}
# Garante que source business_context.customer_key não roube prioridade do
# CPF/CNPJ explícito. O IdentityResolver do framework preserva estabilidade,
# então inserimos a intenção explícita no próprio business_context da chamada.
if explicit_identity:
bc_override = dict(resolver_payload.get("business_context") or {})
if explicit_identity.get("customer_key"):
bc_override["customer_key"] = explicit_identity["customer_key"]
if explicit_identity.get("interaction_key"):
bc_override["interaction_key"] = explicit_identity["interaction_key"]
if bc_override:
resolver_payload["business_context"] = bc_override
business_context = identity_resolver.resolve(
resolver_payload,
session_id=identity.conversation_key(),
previous=previous_business_context,
)
missing_identity_keys = identity_resolver.validate(business_context)
context.update({
"business_context": business_context.model_dump(),
"business_keys": business_context.to_context_dict(),
"identity_missing": missing_identity_keys,
"conversation_key": identity.conversation_key(),
"original_session_id": msg.session_id,
})
return identity, context, business_context, missing_identity_keys
async def _process_gateway_message(req: GatewayRequest, emit_sse: bool = False) -> dict:
msg = await gateway.normalize(req.channel, req.payload)
identity, normalized_context, business_context, missing_identity_keys = _resolve_identity(req, msg)
agent_session_id = identity.conversation_key()
message_id = (req.payload or {}).get("message_id") or str(uuid4())
set_observability_context(
session_id=agent_session_id,
user_id=msg.user_id,
tenant_id=identity.tenant_id,
agent_id=identity.agent_id,
channel=msg.channel,
message_id=message_id,
ura_call_id=(req.payload or {}).get("ura_call_id") or normalized_context.get("ura_call_id") or business_context.interaction_key,
)
stream = sse_hub.stream_for(agent_session_id)
async with stream.lock:
await sse_hub.emit(agent_session_id, "flow.start", {"session_id": agent_session_id, "message_id": message_id, "agent_id": identity.agent_id}) if emit_sse else None
session = await sessions.get(agent_session_id)
if not session:
context_fields = {
k: v
for k, v in normalized_context.items()
if k in SessionContext.model_fields
and k not in {"tenant_id", "agent_id", "session_id", "user_id", "channel", "channel_id"}
}
session = SessionContext(
tenant_id=identity.tenant_id,
agent_id=identity.agent_id,
session_id=agent_session_id,
user_id=msg.user_id,
channel=msg.channel,
channel_id=msg.channel_id,
**context_fields,
)
session.tenant_id = identity.tenant_id
session.agent_id = identity.agent_id
session.channel = msg.channel
session.channel_id = msg.channel_id or session.channel_id
await sessions.upsert(session)
session.metadata = {
**(session.metadata or {}),
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
"original_context": normalized_context,
}
await sse_hub.emit(agent_session_id, "session.upserted", {"session_id": agent_session_id, "business_context": business_context.model_dump()}) if emit_sse else None
await memory.append(
agent_session_id,
ChatMessage(
role="user",
content=msg.text,
metadata={
**normalized_context,
"agent_id": identity.agent_id,
"tenant_id": identity.tenant_id,
"message_id": message_id,
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
},
),
)
await sse_hub.emit(agent_session_id, "message.received", {"session_id": agent_session_id, "role": "user"}) if emit_sse else None
history = [m.model_dump(mode="json") for m in await memory.list(agent_session_id)]
trace_input = {
"text": msg.text,
"channel": msg.channel,
"channel_id": msg.channel_id,
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"conversation_key": agent_session_id,
"message_id": message_id,
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
}
async with telemetry.span(
"agent.gateway_message",
session_id=agent_session_id,
user_id=session.user_id,
channel=msg.channel,
input=trace_input,
tags=["agent-template", msg.channel, f"agent:{identity.agent_id}", f"tenant:{identity.tenant_id}"],
):
await telemetry.event("gateway.message.received", trace_input)
await sse_hub.emit(agent_session_id, "workflow.started", trace_input) if emit_sse else None
result = await workflow.ainvoke(
{
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"session_id": agent_session_id,
"conversation_key": agent_session_id,
"agent_profile": normalized_context["agent_profile"],
"user_text": msg.text,
"history": history,
"context": {
**normalized_context,
"session": session.model_dump(mode="json"),
"original_session_id": msg.session_id,
"session_id": agent_session_id,
"conversation_key": agent_session_id,
"user_id": session.user_id,
"channel": msg.channel,
"message_id": message_id,
"business_context": business_context.model_dump(),
"business_keys": business_context.to_context_dict(),
"identity_missing": missing_identity_keys,
},
}
)
await checkpoints.put(agent_session_id, {"state": result, "message_id": message_id})
await sse_hub.emit(agent_session_id, "workflow.completed", {"session_id": agent_session_id, "route": result.get("route"), "intent": result.get("intent")}) if emit_sse else None
answer = result.get("final_answer") or result.get("answer") or ""
await memory.append(
agent_session_id,
ChatMessage(
role="assistant",
content=answer,
metadata={
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"message_id": f"assistant-{message_id}",
"route": result.get("route"),
"intent": result.get("intent"),
"route_decision": result.get("route_decision"),
"judges": result.get("judge_results"),
},
),
)
await telemetry.event(
"gateway.message.responded",
{
"session_id": agent_session_id,
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"route": result.get("route"),
"intent": result.get("intent"),
"answer_chars": len(answer),
},
)
response = ChannelResponse(
channel=msg.channel,
session_id=agent_session_id,
text=answer,
metadata={
"channel_id": msg.channel_id,
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"original_session_id": msg.session_id,
"conversation_key": agent_session_id,
"message_id": message_id,
"route": result.get("route"),
"intent": result.get("intent"),
"route_decision": result.get("route_decision"),
"domain": result.get("domain"),
"mcp_tools": result.get("mcp_tools"),
"mcp_results": result.get("mcp_results"),
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
"judges": result.get("judge_results"),
"guardrails": result.get("guardrail_decisions"),
},
)
rendered = await gateway.render(response)
await sse_hub.emit(agent_session_id, "message.responded", rendered) if emit_sse else None
await sse_hub.emit(agent_session_id, "flow.end", {"session_id": agent_session_id, "message_id": message_id}) if emit_sse else None
return rendered
@app.get("/health")
async def health():
return {
"status": "ok",
"llm_provider": settings.LLM_PROVIDER,
"llm_class": llm.__class__.__name__,
"langfuse_enabled": telemetry.is_enabled(),
"agents": [p.agent_id for p in agent_profiles.list_profiles()],
"default_agent_id": agent_profiles.default_agent_id,
"routing_mode": settings.ROUTING_MODE,
"sse_enabled": settings.ENABLE_SSE,
"session_repository": settings.SESSION_REPOSITORY_PROVIDER,
"memory_repository": settings.MEMORY_REPOSITORY_PROVIDER,
"checkpoint_repository": settings.CHECKPOINT_REPOSITORY_PROVIDER,
"usage_repository": settings.USAGE_REPOSITORY_PROVIDER,
"identity_config_path": settings.IDENTITY_CONFIG_PATH,
"mcp_parameter_mapping_path": settings.MCP_PARAMETER_MAPPING_PATH,
}
@app.get("/agents")
async def list_agents():
return {"default_agent_id": agent_profiles.default_agent_id, "agents": [p.__dict__ for p in agent_profiles.list_profiles()]}
@app.get("/debug/env")
async def debug_env():
return {
"APP_ENV": settings.APP_ENV,
"LLM_PROVIDER": settings.LLM_PROVIDER,
"ENABLE_LANGFUSE": settings.ENABLE_LANGFUSE,
"LANGFUSE_HOST": settings.LANGFUSE_HOST,
"TELEMETRY_ENABLED": telemetry.is_enabled(),
"SQLITE_DB_PATH": settings.SQLITE_DB_PATH,
"SESSION_REPOSITORY_PROVIDER": settings.SESSION_REPOSITORY_PROVIDER,
"MEMORY_REPOSITORY_PROVIDER": settings.MEMORY_REPOSITORY_PROVIDER,
"CHECKPOINT_REPOSITORY_PROVIDER": settings.CHECKPOINT_REPOSITORY_PROVIDER,
"AGENTS_CONFIG_PATH": settings.AGENTS_CONFIG_PATH,
"ROUTING_CONFIG_PATH": settings.ROUTING_CONFIG_PATH,
"ROUTING_MODE": settings.ROUTING_MODE,
}
@app.get("/test-llm")
async def test_llm():
async with telemetry.span("debug.test_llm", input={"message": "Diga apenas OK"}):
answer = await llm.ainvoke([
{"role": "system", "content": "Responda de forma curta."},
{"role": "user", "content": "Diga apenas OK"},
])
telemetry.flush()
return {"provider": llm.__class__.__name__, "answer": answer}
@app.post("/debug/route")
async def debug_route(req: GatewayRequest):
msg = await gateway.normalize(req.channel, req.payload)
identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg)
state = {
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"session_id": msg.session_id or "debug-session",
"conversation_key": identity.conversation_key(),
"agent_profile": context["agent_profile"],
"user_text": msg.text,
"sanitized_input": msg.text,
"history": [],
"context": {**context, "session": context.get("session", {}), "channel": msg.channel, "business_context": business_context.model_dump()},
}
if settings.ROUTING_MODE == "supervisor":
plan = await workflow.supervisor.route_plan(state)
return {"mode": "supervisor", "route": "supervisor_agent", "agents": plan.agents, "intent": plan.intent, "confidence": plan.confidence, "reason": plan.reason, "metadata": plan.metadata}
decision = await workflow.router.route(state)
data = decision.model_dump(mode="json")
data["mode"] = "router"
return data
@app.post("/debug/identity")
async def debug_identity(req: GatewayRequest):
msg = await gateway.normalize(req.channel, req.payload)
identity, context, business_context, missing_identity_keys = _resolve_identity(req, msg)
return {
"technical_identity": {
"tenant_id": identity.tenant_id,
"agent_id": identity.agent_id,
"conversation_key": identity.conversation_key(),
"original_session_id": msg.session_id,
},
"business_context": business_context.model_dump(),
"identity_missing": missing_identity_keys,
"context_keys": sorted(context.keys()),
}
@app.get("/debug/usage")
async def debug_usage(tenant_id: str | None = None, session_id: str | None = None):
return await usage_repository.summarize(tenant_id=tenant_id, session_id=session_id)
@app.get("/debug/mcp/tools")
async def debug_mcp_tools():
return {"enabled": tool_router.enabled, "tools": tool_router.describe_tools()}
@app.post("/debug/mcp/call/{tool_name}")
async def debug_mcp_call(tool_name: str, arguments: dict | None = None):
arguments = arguments or {}
ctx = arguments.get("business_context") or arguments.get("identity") or {}
result = await tool_router.call(
tool_name,
arguments,
business_context=ctx,
original_context=arguments,
)
return result.model_dump(mode="json")
@app.post("/gateway/message")
async def gateway_message(req: GatewayRequest):
return await _process_gateway_message(req, emit_sse=False)
@app.post("/gateway/message/sse")
async def gateway_message_sse(req: GatewayRequest):
return await _process_gateway_message(req, emit_sse=True)
@app.get("/gateway/events/{session_id}")
async def gateway_events(session_id: str, request: Request):
last = request.headers.get("last-event-id") or request.query_params.get("last_event_id") or "0"
return StreamingResponse(
sse_hub.subscribe(session_id, int(last)),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
)
@app.get("/sessions/{session_id}/messages")
async def get_session_messages(session_id: str, limit: int = 50):
return {"session_id": session_id, "messages": [m.model_dump(mode="json") for m in await memory.list(session_id, limit)]}
@app.get("/sessions/{session_id}/checkpoint")
async def get_session_checkpoint(session_id: str):
return {"session_id": session_id, "checkpoint": await checkpoints.get_latest(session_id)}
@app.on_event("shutdown")
async def shutdown():
telemetry.shutdown()
# ---------------------------------------------------------------------------
# Backoffice TIM/ANATEL develop — execução 100% framework-native
# ---------------------------------------------------------------------------
# As rotas antigas permanecem como adapters REST, mas não registram routers
# legados e não executam legacy graph package nem legacy executor package.
# Elas chamam o BackofficeNativeRuntime, que compila os workflows com o motor
# do framework e aplica guardrails, judges, supervisor, checkpoint e telemetry.
import asyncio
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi import status, HTTPException
@app.on_event("startup")
async def startup_backoffice_native_domain():
"""Inicializa apenas recursos de domínio; o motor de workflow é do framework."""
try:
from src.utils.observer import setup_observer
setup_observer()
except Exception:
logger.warning("Observer de domínio não pôde ser inicializado", exc_info=True)
try:
from src.infrastructure.oci.autonomous import db_manager as original_db_manager
app.state.original_db_manager = original_db_manager
await original_db_manager.connect()
logger.info("Autonomous/Mongo manager de domínio conectado")
except Exception:
logger.warning("DB de domínio indisponível; fluxos continuam quando os nós suportarem fallback/mock", exc_info=True)
# Compatibilidade para nós que esperam app.state.oci_producer; a criação real
# continua opcional e não controla o grafo.
try:
from src.core.config import settings as original_settings
if getattr(original_settings, "ENABLE_OCI_STREAMING", False):
from src.infrastructure.streaming.producer import OciProducer
app.state.oci_producer = OciProducer(original_settings.OCI_RESPONSE_STREAM_OCID)
logger.info("OCI producer de domínio inicializado; consumer legado não é iniciado")
else:
try:
from src.infrastructure.streaming.debug_producer import LocalDebugProducer
if getattr(original_settings, "DEBUG", False):
app.state.oci_producer = LocalDebugProducer()
logger.info("LocalDebugProducer de domínio ativo")
except Exception:
pass
except Exception:
logger.warning("Producer de domínio não inicializado", exc_info=True)
@app.on_event("shutdown")
async def shutdown_backoffice_native_domain():
try:
dbm = getattr(app.state, "original_db_manager", None)
if dbm is not None:
await dbm.close()
except Exception:
logger.warning("Falha ao encerrar DB de domínio", exc_info=True)
@app.exception_handler(RequestValidationError)
async def native_validation_exception_handler(request: Request, exc: RequestValidationError):
"""Preserva o formato de erro ANATEL sem ativar rotas/executors legados."""
try:
from src.api.schemas.anatel_schemas import ERROR_CODE_MAPPING, ReasonCode
error_messages = []
for err in exc.errors():
loc_tuple = tuple(l for l in err.get("loc", []) if l != "body")
is_enum_error = err.get("type", "").startswith("enum")
target_fields = [("complaint", "inputChannel"), ("caseType",)]
if is_enum_error and loc_tuple in target_fields:
reason_code = ReasonCode.INVALID_VALUE
field_name = loc_tuple[-1]
reason_text = f"Invalid value for field {field_name} or it's not supported yet"
else:
mapping_result = ERROR_CODE_MAPPING.get(loc_tuple)
if mapping_result:
reason_code, reason_text = mapping_result
else:
reason_code = ReasonCode.FIELD_ERROR
loc_str = " -> ".join([str(l) for l in err.get("loc", [])])
reason_text = f"{loc_str}: {err.get('msg', 'Invalid field')}"
error_messages.append({"code": reason_code.value, "text": reason_text})
try:
body = await request.json()
correlation_id = body.get("transactionId") or body.get("correlation_id") or "unknown"
except Exception:
correlation_id = "unknown"
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={"title": "validation error", "status": 400, "correlation_id": correlation_id, "detail": {"messages": error_messages}},
)
except Exception:
return JSONResponse(status_code=422, content={"detail": exc.errors()})
async def _run_native_checklist(event, request: Request):
from src.api.utils import agent_helpers
transaction_id = event.transactionId or f"man-{uuid4().hex[:8]}"
payload = event.model_dump(mode="json", by_alias=True)
final_state = await backoffice_runtime.execute_workflow(
"backoffice_checklist",
payload=payload,
transaction_id=transaction_id,
app_state=request.app.state,
metadata={
"tenant_id": "default",
"agent_id": "backoffice_anatel",
"channel": "rest",
"legacy_contract": "agent.process-ticket",
},
)
try:
response_event = agent_helpers.build_cms_response_event(final_state, transaction_id)
return response_event.model_dump(mode="json", by_alias=True)
except Exception:
return {
"transactionId": transaction_id,
"framework_native": True,
"current_step": str(final_state.get("current_step")),
"final_response": final_state.get("final_response"),
"error": final_state.get("error"),
"metadata": final_state.get("metadata", {}),
}
@app.post("/agent/process-ticket", status_code=status.HTTP_200_OK)
async def native_process_ticket(request: Request, event: "TicketRequestEvent"):
return await _run_native_checklist(event, request)
@app.post("/agent/execute", status_code=status.HTTP_200_OK)
async def native_agent_execute(request: Request, event: "TicketRequestEvent"):
return await _run_native_checklist(event, request)
@app.post("/agent/process-and-stream", status_code=status.HTTP_200_OK)
async def native_process_and_stream(request: Request, event: "TicketRequestEvent"):
return await _run_native_checklist(event, request)
@app.post("/agent/search-tais-kb", status_code=status.HTTP_200_OK)
async def native_search_tais_kb(body: dict):
"""Adapter REST para TAIS KB usando o service/cliente de domínio, não grafo legado."""
try:
from src.components.clients.tais_kb_client import TaisKbClient
query = body.get("query") or body.get("text") or body.get("complaint_description") or ""
client = TaisKbClient()
if hasattr(client, "search"):
result = await client.search(query=query, **{k: v for k, v in body.items() if k not in {"query", "text", "complaint_description"}})
elif hasattr(client, "query"):
result = await client.query(query)
else:
raise AttributeError("TAIS KB client has no search/query method")
return {"framework_native": True, "result": result}
except Exception as exc:
await telemetry.event("backoffice.tais_kb.failed", {"error": str(exc)}, kind="tool")
return {"framework_native": True, "result": [], "error": str(exc)}
async def _run_native_emulator(request: Request, event):
transaction_id = event.transactionId
payload = event.model_dump(mode="json", by_alias=True)
final_state = await backoffice_runtime.execute_workflow(
"backoffice_response_emulator",
payload=payload,
transaction_id=transaction_id,
app_state=request.app.state,
metadata={
"tenant_id": "default",
"agent_id": "backoffice_anatel",
"channel": "rest",
"flow_mode": event.flow_mode,
"selected_actions": [a.model_dump(mode="json") for a in event.selected_actions],
"legacy_contract": "case.response-emulator",
},
)
try:
from src.api.utils.emulator_response_builder import build_emulator_response_event
response_event = build_emulator_response_event(final_state, transaction_id)
return response_event.model_dump(mode="json", by_alias=True)
except Exception:
return {
"transactionId": transaction_id,
"framework_native": True,
"current_step": str(final_state.get("current_step")),
"final_response": final_state.get("final_response"),
"error": final_state.get("error"),
"metadata": final_state.get("metadata", {}),
}
@app.post("/case/{transaction_id}/response-emulator/generate", status_code=status.HTTP_200_OK)
async def native_response_emulator_generate(transaction_id: str, request: Request, body: "EmulatorGenerateRequest"):
from src.api.schemas.anatel_response_emulator_schemas import ResponseEmulatorRequestEvent, OperatorFeedback
if body.transactionId != transaction_id:
raise HTTPException(status_code=400, detail="transactionId path/body mismatch")
if body.action == "generate":
event = ResponseEmulatorRequestEvent(
transactionId=transaction_id,
type="first_response",
flow_mode="generate",
selected_actions=body.selected_actions or [],
)
else:
event = ResponseEmulatorRequestEvent(
transactionId=transaction_id,
type="regenerate",
flow_mode="generate",
selected_actions=[],
previous_response="",
feedback=OperatorFeedback(comment=body.operator_instructions or ""),
)
return await _run_native_emulator(request, event)
@app.post("/case/{transaction_id}/response-emulator/finalize", status_code=status.HTTP_200_OK)
async def native_response_emulator_finalize(transaction_id: str, request: Request, body: "EmulatorFinalizeRequest"):
from src.api.schemas.anatel_response_emulator_schemas import ResponseEmulatorRequestEvent
if body.transactionId != transaction_id:
raise HTTPException(status_code=400, detail="transactionId path/body mismatch")
event = ResponseEmulatorRequestEvent(
transactionId=transaction_id,
type="first_response",
flow_mode=body.action,
selected_actions=[],
)
return await _run_native_emulator(request, event)
@app.get("/case/{transaction_id}/response-emulator", status_code=status.HTTP_200_OK)
async def native_response_emulator_status(transaction_id: str):
"""Status reader nativo. Lê DB de domínio quando disponível, sem rota legada."""
try:
from src.core.config import settings as original_settings
dbm = getattr(app.state, "original_db_manager", None)
db = getattr(dbm, "db", None)
if db is None:
return {"transactionId": transaction_id, "framework_native": True, "status": None, "detail": "domain DB unavailable"}
coll = db[original_settings.AUTONOMOUS_NOSQL_COLLECTION]
doc = await coll.find_one({"transactionId": transaction_id})
if not doc:
return {"transactionId": transaction_id, "framework_native": True, "status": None}
processing = doc.get("processing") or {}
return {
"transactionId": transaction_id,
"framework_native": True,
"status": processing.get("status"),
"current_step": processing.get("current_step"),
"case_response": processing.get("case_response") or doc.get("case_response"),
"validation": (doc.get("metadata") or {}).get("validation"),
"selected_actions_count": len((doc.get("metadata") or {}).get("selected_actions") or []),
"transitions": doc.get("transitions") or [],
"last_updated_at": processing.get("updated_at") or processing.get("timestamp"),
}
except Exception as exc:
return {"transactionId": transaction_id, "framework_native": True, "status": None, "error": str(exc)}
@app.post("/emulator-rag/search", status_code=status.HTTP_200_OK)
async def native_emulator_rag_search(body: dict):
try:
from src.agent.nodes.emulator._rag_query import query_emulator_rag
result = await query_emulator_rag(body)
return {"framework_native": True, "result": result}
except Exception as exc:
return {"framework_native": True, "result": [], "error": str(exc)}
@app.get("/health/live")
async def native_health_live():
return {"status": "live", "framework_native": True}
@app.get("/health/ready")
async def native_health_ready():
return {
"status": "ready",
"framework_native": True,
"workflows": list(backoffice_runtime._graphs.keys()),
"framework_layers": {
"gateway": True,
"identity": True,
"session_repository": settings.SESSION_REPOSITORY_PROVIDER,
"memory_repository": settings.MEMORY_REPOSITORY_PROVIDER,
"checkpoint_repository": settings.CHECKPOINT_REPOSITORY_PROVIDER,
"guardrails": True,
"judges": True,
"supervisor": True,
"mcp_router": tool_router.enabled,
"telemetry": telemetry.is_enabled(),
},
}
@app.get("/debug/backoffice/parity")
async def debug_backoffice_parity():
return {
"mode": "framework_native_domain_workflows",
"legacy_graph_execution": False,
"legacy_router_registration": False,
"forbidden_active_imports": ["legacy_reference_disabled/original_develop/src_agent_graphs", "legacy_reference_disabled/original_develop/src_api_executors"],
"runtime": "app.workflows.backoffice_native_runtime.BackofficeNativeRuntime",
"domain_package": "src.agent.nodes + src.components.clients + src.agent.local_prompts",
"workflows": {
"backoffice_checklist": [
"framework_input_guardrails", "fetch_ticket", "validation", "bypass_rules", "cache_check", "imdb_enrichment", "identity_verification", "speech_enrichment", "knowledge_base_enrichment", "canceling_analysis", "tim_complaint_analysis", "operator_route", "reclassification_analysis", "treatment_decision", "siebel_sr_opening", "framework_output_supervisor", "framework_output_guardrails", "framework_judges", "framework_supervisor_review", "framework_persist"
],
"backoffice_response_emulator": [
"framework_input_guardrails", "start_response_emulation", "fetch_case", "validate_actions", "router", "retrieve_templates", "retrieve_history", "generate_response", "validate_response", "persist_draft", "approve_draft", "close_case", "framework_output_supervisor", "framework_output_guardrails", "framework_judges", "framework_supervisor_review", "framework_persist"
],
},
"framework_layers": {
"gateway": True,
"identity": True,
"session_repository": settings.SESSION_REPOSITORY_PROVIDER,
"memory_repository": settings.MEMORY_REPOSITORY_PROVIDER,
"checkpoint_repository": settings.CHECKPOINT_REPOSITORY_PROVIDER,
"guardrails": True,
"judges": True,
"supervisor": True,
"mcp_router": tool_router.enabled,
"telemetry": telemetry.is_enabled(),
},
}
# Late imports only for FastAPI annotation resolution. They are schemas, not
# workflow motors.
from src.api.schemas.anatel_schemas import TicketRequestEvent
from src.api.schemas.anatel_response_emulator_schemas import EmulatorGenerateRequest, EmulatorFinalizeRequest

34
app/state.py Normal file
View File

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

Binary file not shown.

View File

@@ -0,0 +1,718 @@
from agent_framework.checkpoints.langgraph_saver import create_langgraph_checkpointer
from langgraph.graph import END, START, StateGraph
from agent_framework.guardrails.pipeline import GuardrailPipeline
from agent_framework.guardrails.output_supervisor import OutputSupervisor
from agent_framework.guardrails.rail_action import RailAction
from agent_framework.guardrails.rail_result import RailResult
from agent_framework.judges.judge import JudgePipeline
from agent_framework.routing.enterprise_router import EnterpriseRouter
from agent_framework.supervisor.supervisor import Supervisor
from agent_framework.observability.workflow_events import WorkflowTelemetry
from agent_framework.observability.guardrail_events import GuardrailTelemetry
from agent_framework.observability.judge_events import JudgeTelemetry
from agent_framework.observability.langgraph_telemetry import LangGraphDeepTelemetry
from agent_framework.observability.observer import AgentObserver
from app.agents.billing_agent import BillingAgent
from app.agents.product_agent import ProductAgent
from app.agents.orders_agent import OrdersAgent
from app.agents.support_agent import SupportAgent
from app.agents.backoffice_agent import BackofficeAgent
from app.state import AgentState
from agent_framework.rag.rag_service import RagService
from agent_framework.cache.cache import create_cache
class LegacyOutputGuardrailRail:
"""Adapter: reutiliza GuardrailPipeline.run_output dentro do OutputSupervisor novo.
O framework antigo retornava decisões allowed=True/False. O OutputSupervisor
corporativo trabalha com RailAction (allow/sanitize/retry/block/handover).
Este adapter evita reescrever todos os rails agora e mantém compatibilidade.
"""
code = "LEGACY_OUTPUT_GUARDRAILS"
def __init__(self, pipeline: GuardrailPipeline):
self.pipeline = pipeline
async def evaluate(self, candidate: str, context: dict):
final, decisions = await self.pipeline.run_output(candidate, context)
serialized = [d.model_dump() for d in decisions]
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
if blocked:
first = blocked[0]
code = (getattr(first, "code", "") or "").upper()
action = RailAction.RETRY if code in {"REVPREC", "CMP", "SCO", "GND"} else RailAction.BLOCK
return RailResult(
code=code or self.code,
action=action,
reason=getattr(first, "reason", "Resposta bloqueada por guardrail de saída"),
guidance=getattr(first, "reason", "Regerar resposta seguindo as políticas de saída."),
sanitized_text=final,
metadata={"legacy_decisions": serialized},
)
if final != candidate:
return RailResult(
code=self.code,
action=RailAction.SANITIZE,
reason="Resposta sanitizada por guardrail de saída legado.",
sanitized_text=final,
metadata={"legacy_decisions": serialized},
)
return RailResult(
code=self.code,
action=RailAction.ALLOW,
reason="Resposta aprovada pelos guardrails de saída legados.",
sanitized_text=final,
metadata={"legacy_decisions": serialized},
)
class AgentWorkflow:
"""Workflow principal com dois modos de roteamento.
Modos suportados por configuração:
ROUTING_MODE=router
input_guardrails -> routing_decision/EnterpriseRouter -> 1 agente -> output_guardrails
ROUTING_MODE=supervisor
input_guardrails -> routing_decision/Supervisor -> supervisor_agent -> N agentes -> consolidação
Em ambos os modos, memória/checkpoint/session usam tenant_id:agent_id:session_id.
"""
def __init__(self, llm, memory, telemetry, analytics, settings, observer: AgentObserver | None = None, tool_router=None):
self.llm = llm
self.memory = memory
self.telemetry = telemetry
self.analytics = analytics
self.observer = observer or AgentObserver(analytics=analytics)
self.settings = settings
self.tool_router = tool_router
self.tool_router = tool_router
self.guardrails = GuardrailPipeline(
observer=self.observer,
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
)
self.output_supervisor_engine = OutputSupervisor(
rails=[LegacyOutputGuardrailRail(self.guardrails)],
observer=self.observer,
max_retries=int(getattr(settings, "OUTPUT_SUPERVISOR_MAX_RETRIES", 3)),
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
)
self.judges = JudgePipeline()
self.supervisor = Supervisor()
self.workflow_telemetry = WorkflowTelemetry(telemetry)
self.guardrail_telemetry = GuardrailTelemetry(telemetry)
self.judge_telemetry = JudgeTelemetry(telemetry)
self.langgraph_telemetry = LangGraphDeepTelemetry(telemetry)
self.cache = create_cache(settings)
self.rag_service = RagService(settings, telemetry=telemetry)
self.router = EnterpriseRouter(settings, llm=llm, telemetry=telemetry)
agent_kwargs = {"telemetry": telemetry, "tool_router": getattr(self, "tool_router", None), "rag_service": self.rag_service, "cache": self.cache, "settings": settings, "observer": self.observer}
self.billing = BillingAgent(llm, **agent_kwargs)
self.product = ProductAgent(llm, **agent_kwargs)
self.orders = OrdersAgent(llm, **agent_kwargs)
self.support = SupportAgent(llm, **agent_kwargs)
self.backoffice = BackofficeAgent(llm, **agent_kwargs)
self.graph = self._build_graph()
def _node(self, name, fn):
async def _wrapped(state):
async with self.langgraph_telemetry.node(name, state):
return await fn(state)
return _wrapped
def _build_graph(self):
builder = StateGraph(AgentState)
builder.add_node("input_guardrails", self._node("input_guardrails", self.input_guardrails))
builder.add_node("routing_decision", self._node("routing_decision", self.routing_decision))
builder.add_node("billing_agent", self._node("billing_agent", self.billing_agent))
builder.add_node("product_agent", self._node("product_agent", self.product_agent))
builder.add_node("orders_agent", self._node("orders_agent", self.orders_agent))
builder.add_node("support_agent", self._node("support_agent", self.support_agent))
builder.add_node("backoffice_agent", self._node("backoffice_agent", self.backoffice_agent))
builder.add_node("handoff", self._node("handoff", self.handoff))
builder.add_node("supervisor_agent", self._node("supervisor_agent", self.supervisor_agent))
builder.add_node("output_supervisor", self._node("output_supervisor", self.output_supervisor))
builder.add_node("output_guardrails", self._node("output_guardrails", self.output_guardrails))
builder.add_node("judge", self._node("judge", self.judge))
builder.add_node("supervisor_review", self._node("supervisor_review", self.supervisor_review))
builder.add_node("persist", self._node("persist", self.persist))
builder.add_edge(START, "input_guardrails")
builder.add_conditional_edges(
"input_guardrails",
self._after_input_guardrails,
{"blocked": "persist", "continue": "routing_decision"},
)
builder.add_conditional_edges(
"routing_decision",
lambda s: s.get("route", "billing_agent"),
{
"billing_agent": "billing_agent",
"product_agent": "product_agent",
"orders_agent": "orders_agent",
"support_agent": "support_agent",
"backoffice_agent": "backoffice_agent",
"handoff": "handoff",
"supervisor_agent": "supervisor_agent",
},
)
builder.add_edge("billing_agent", "output_supervisor")
builder.add_edge("product_agent", "output_supervisor")
builder.add_edge("orders_agent", "output_supervisor")
builder.add_edge("support_agent", "output_supervisor")
builder.add_edge("backoffice_agent", "output_supervisor")
builder.add_edge("handoff", "output_supervisor")
builder.add_edge("supervisor_agent", "output_supervisor")
builder.add_edge("output_supervisor", "output_guardrails")
builder.add_edge("output_guardrails", "judge")
builder.add_edge("judge", "supervisor_review")
builder.add_edge("supervisor_review", "persist")
builder.add_edge("persist", END)
return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings))
def _after_input_guardrails(self, state):
return "blocked" if state.get("blocked") else "continue"
async def input_guardrails(self, state):
async with self.telemetry.span(
"workflow.input_guardrails",
session_id=state.get("conversation_key") or state.get("session_id"),
input=state.get("user_text"),
):
history_texts = [m.get("content", "") for m in state.get("history", [])]
await self.observer.emit_grl(
"001",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "input",
},
component="workflow.input_guardrails.start",
)
sanitized, decisions = await self.guardrails.run_input(
state["user_text"],
{
**(state.get("context") or {}),
"history_texts": history_texts,
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"agent_profile": state.get("agent_profile") or {},
},
)
for _decision in decisions:
await self.guardrail_telemetry.evaluated("input", _decision)
await self.observer.emit_grl(
"002" if _decision.allowed else "004",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "input",
"rail_code": getattr(_decision, "code", None),
"allowed": bool(_decision.allowed),
"reason": getattr(_decision, "reason", None),
},
component="workflow.input_guardrails.decision",
)
if not _decision.allowed:
await self.guardrail_telemetry.blocked("input", _decision)
await self.telemetry.event(
"guardrails.input.completed",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"decisions": [d.model_dump() for d in decisions],
},
)
await self.observer.emit_grl(
"009",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "input",
"blocked": any(not d.allowed for d in decisions),
"decision_count": len(decisions),
},
component="workflow.input_guardrails.final",
)
if any(not d.allowed for d in decisions):
return {
"sanitized_input": sanitized,
"answer": "Não consegui seguir com essa mensagem por regra de segurança.",
"final_answer": "Não consegui seguir com essa mensagem por regra de segurança.",
"guardrail_decisions": [d.model_dump() for d in decisions],
"route": "blocked",
"blocked": True,
}
return {
"sanitized_input": sanitized,
"guardrail_decisions": [d.model_dump() for d in decisions],
"blocked": False,
}
async def routing_decision(self, state):
mode = getattr(self.settings, "ROUTING_MODE", "router")
async with self.telemetry.span(
"workflow.routing_decision",
session_id=state.get("conversation_key") or state.get("session_id"),
input={
"mode": mode,
"text": state.get("sanitized_input") or state.get("user_text"),
"previous_state": state.get("next_state"),
},
):
if mode == "supervisor":
plan = await self.supervisor.route_plan(state)
await self.langgraph_telemetry.edge("routing_decision", "supervisor_agent", state, {"method": "supervisor", "intent": plan.intent, "confidence": plan.confidence})
return {
"route": "supervisor_agent",
"intent": plan.intent,
"supervisor_plan": {
"agents": plan.agents,
"intent": plan.intent,
"confidence": plan.confidence,
"reason": plan.reason,
"metadata": plan.metadata,
},
"route_decision": {
"route": "supervisor_agent",
"agent": "supervisor",
"intent": plan.intent,
"confidence": plan.confidence,
"reason": plan.reason,
"method": "supervisor",
"metadata": plan.metadata,
},
}
decision = await self.router.route(state)
await self.langgraph_telemetry.edge("routing_decision", decision.route, state, {"method": getattr(decision, "method", None), "intent": decision.intent, "confidence": decision.confidence})
await self.observer.emit_ic(
"ROUTE_SELECTED",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": decision.route,
"intent": decision.intent,
"confidence": decision.confidence,
"method": getattr(decision, "method", None),
},
component="workflow.routing_decision",
)
return {
"route": decision.route,
"intent": decision.intent,
"route_decision": decision.model_dump(mode="json"),
"domain": decision.domain,
"mcp_tools": decision.mcp_tools,
"next_state": decision.next_state,
}
async def billing_agent(self, state):
async with self.langgraph_telemetry.node("billing_agent", state):
async with self.telemetry.span(
"workflow.agent.billing",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.billing.run(state)
async def product_agent(self, state):
async with self.langgraph_telemetry.node("product_agent", state):
async with self.telemetry.span(
"workflow.agent.product",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.product.run(state)
async def orders_agent(self, state):
async with self.langgraph_telemetry.node("orders_agent", state):
async with self.telemetry.span(
"workflow.agent.orders",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.orders.run(state)
async def support_agent(self, state):
async with self.langgraph_telemetry.node("support_agent", state):
async with self.telemetry.span(
"workflow.agent.support",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.support.run(state)
async def backoffice_agent(self, state):
async with self.langgraph_telemetry.node("backoffice_agent", state):
async with self.telemetry.span(
"workflow.agent.backoffice",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"intent": state.get("intent")},
):
return await self.backoffice.run(state)
async def supervisor_agent(self, state):
"""Executa um ou mais agentes no modo supervisor e consolida a resposta.
Este nó mantém o desenho de supervisor sem obrigar o restante do workflow
a conhecer quantos agentes foram acionados. Cada execução especializada
recebe o mesmo estado, mas com route/active_agent atualizados.
"""
plan = state.get("supervisor_plan") or {}
agents = plan.get("agents") or ["backoffice_agent"]
handlers = {
"billing_agent": self.billing.run,
"product_agent": self.product.run,
"orders_agent": self.orders.run,
"support_agent": self.support.run,
"backoffice_agent": self.backoffice.run,
}
partials = []
mcp_results = []
async with self.telemetry.span(
"workflow.supervisor_agent",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"agents": agents, "intent": state.get("intent")},
):
for agent_name in agents:
handler = handlers.get(agent_name)
if handler is None:
continue
child_state = {**state, "route": agent_name, "active_agent": agent_name}
result = await handler(child_state)
partials.append({"agent": agent_name, "answer": result.get("answer", "")})
mcp_results.extend(result.get("mcp_results") or [])
if len(partials) == 1:
answer = partials[0]["answer"]
else:
joined = "\n\n".join(f"{p['agent']}: {p['answer']}" for p in partials)
answer = (
"[Supervisor] Consolidação de múltiplos agentes acionados.\n"
f"{joined}"
)
return {
"answer": answer,
"supervisor_results": partials,
"mcp_results": mcp_results,
"next_state": "SUPERVISOR_ACTIVE",
}
async def handoff(self, state):
async with self.telemetry.span("workflow.handoff", session_id=state.get("session_id")):
target = (state.get("route_decision") or {}).get("metadata", {}).get("target_agent")
answer = (
"Vou redirecionar sua solicitação para o especialista correto. "
f"Destino sugerido: {target or 'agente especializado'}."
)
return {"answer": answer}
async def output_supervisor(self, state):
"""Valida a resposta candidata com o OutputSupervisor corporativo.
Este nó não substitui o roteador/supervisor multiagente. Ele roda após o
agente gerar `answer` e antes dos judges/persistência, produzindo campos
supervisor_* no state e eventos GRL.001..GRL.009 via AgentObserver.
"""
if not bool(getattr(self.settings, "ENABLE_OUTPUT_SUPERVISOR", True)):
return {
"output_guardrails_already_applied": False,
"supervisor_action": "disabled",
"supervisor_attempt": int(state.get("supervisor_attempt", 0)),
}
candidate = state.get("answer") or ""
context = {
**(state.get("context") or {}),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"session_id": state.get("conversation_key") or state.get("session_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"supervisor_attempt": int(state.get("supervisor_attempt", 0)),
}
async with self.telemetry.span(
"workflow.output_supervisor",
session_id=state.get("conversation_key") or state.get("session_id"),
input=candidate,
):
decision = await self.output_supervisor_engine.evaluate(candidate, context)
action = decision.action.value
await self.telemetry.event(
"output_supervisor.completed",
{
"session_id": context["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"action": action,
"approved": decision.approved,
"guidance": decision.guidance,
},
)
await self.observer.emit_ic(
"IC.OUTPUT_SUPERVISOR_COMPLETED",
{
"session_id": context["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"action": action,
"approved": decision.approved,
"result_count": len(decision.results),
},
component="workflow.output_supervisor",
)
if decision.action in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}:
final_answer = decision.candidate
elif decision.action == RailAction.HANDOVER:
final_answer = "Vou encaminhar seu atendimento para continuidade com um especialista."
else:
final_answer = decision.fallback_message
return {
"answer": final_answer,
"final_answer": final_answer,
"supervisor_action": action,
"supervisor_guidance": decision.guidance,
"supervisor_attempt": int(state.get("supervisor_attempt", 0)) + (1 if decision.action == RailAction.RETRY else 0),
"supervisor_handover_reason": decision.handover_reason,
"output_supervisor_results": [
{
"code": r.code,
"action": r.action.value,
"reason": r.reason,
"guidance": r.guidance,
"metadata": r.metadata,
}
for r in decision.results
],
"output_guardrails_already_applied": True,
"guardrail_decisions": state.get("guardrail_decisions", [])
+ [item for r in decision.results for item in (r.metadata or {}).get("legacy_decisions", [])],
}
async def output_guardrails(self, state):
if state.get("output_guardrails_already_applied"):
return {"final_answer": state.get("final_answer") or state.get("answer") or ""}
async with self.telemetry.span(
"workflow.output_guardrails",
session_id=state.get("conversation_key") or state.get("session_id"),
input=state.get("answer"),
):
await self.observer.emit_grl(
"001",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "output",
"route": state.get("route"),
"intent": state.get("intent"),
},
component="workflow.output_guardrails.start",
)
final, decisions = await self.guardrails.run_output(
state["answer"], state.get("context", {})
)
for _decision in decisions:
await self.guardrail_telemetry.evaluated("output", _decision)
await self.observer.emit_grl(
"002" if _decision.allowed else "004",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "output",
"rail_code": getattr(_decision, "code", None),
"allowed": bool(_decision.allowed),
"reason": getattr(_decision, "reason", None),
},
component="workflow.output_guardrails.decision",
)
if not _decision.allowed:
await self.guardrail_telemetry.blocked("output", _decision)
await self.telemetry.event(
"guardrails.output.completed",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"decisions": [d.model_dump() for d in decisions],
},
)
await self.observer.emit_grl(
"009",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"phase": "output",
"blocked": any(not d.allowed for d in decisions),
"decision_count": len(decisions),
},
component="workflow.output_guardrails.final",
)
return {
"final_answer": final,
"guardrail_decisions": state.get("guardrail_decisions", [])
+ [d.model_dump() for d in decisions],
}
async def judge(self, state):
async with self.telemetry.span(
"workflow.judge",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"question": state.get("user_text"), "answer": state.get("final_answer")},
):
results = await self.judges.evaluate_all(
state["user_text"], state["final_answer"], state.get("context", {})
)
for _result in results:
await self.judge_telemetry.evaluated(_result)
await self.telemetry.event(
"judges.completed",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"results": [r.model_dump() for r in results],
},
)
return {"judge_results": [r.model_dump() for r in results]}
async def supervisor_review(self, state):
async with self.telemetry.span(
"workflow.supervisor_review",
session_id=state.get("conversation_key") or state.get("session_id"),
input=state.get("final_answer"),
):
ok, answer = await self.supervisor.review(
state["final_answer"], state.get("context", {})
)
await self.telemetry.event(
"supervisor.review.completed",
{"session_id": state.get("session_id"), "approved": ok},
)
return {"final_answer": answer if ok else answer}
async def persist(self, state):
async with self.telemetry.span(
"workflow.persist",
session_id=state.get("conversation_key") or state.get("session_id"),
input={"route": state.get("route"), "intent": state.get("intent")},
):
await self.observer.emit_ic(
"AGENT_COMPLETED",
{
"session_id": state.get("conversation_key") or state["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"route_decision": state.get("route_decision"),
"judges": state.get("judge_results", []),
"mcp_tools": state.get("mcp_tools", []),
"mcp_results": state.get("mcp_results", []),
},
)
await self.observer.emit_noc(
"006",
{
"session_id": state.get("conversation_key") or state["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"answer_chars": len(state.get("final_answer") or ""),
},
component="workflow.persist",
)
await self.telemetry.event(
"agent.completed",
{
"session_id": state.get("conversation_key") or state["session_id"],
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"route": state.get("route"),
"intent": state.get("intent"),
"answer_chars": len(state.get("final_answer") or ""),
},
)
return state
async def ainvoke(self, state):
thread_id = state.get("conversation_key") or state["session_id"]
config = {"configurable": {"thread_id": thread_id}}
async with self.telemetry.span(
"workflow.langgraph.ainvoke",
session_id=state.get("conversation_key") or state.get("session_id"),
user_id=state.get("context", {}).get("user_id"),
input={"user_text": state.get("user_text")},
tags=["langgraph", "agent-workflow", f"routing-mode:{getattr(self.settings, 'ROUTING_MODE', 'router')}",],
):
await self.workflow_telemetry.started("agent_workflow", state)
await self.observer.emit_noc(
"001",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"channel_id": (state.get("context") or {}).get("channel"),
"message_id": (state.get("context") or {}).get("message_id"),
"ura_call_id": (state.get("context") or {}).get("ura_call_id"),
},
component="workflow.ainvoke",
)
await self.observer.emit_ic(
"AGENT_STARTED",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"channel_id": (state.get("context") or {}).get("channel"),
"message_id": (state.get("context") or {}).get("message_id"),
"user_text_chars": len(state.get("user_text") or ""),
},
component="workflow.ainvoke",
)
try:
result = await self.graph.ainvoke(state, config=config)
await self.workflow_telemetry.completed("agent_workflow", result)
return result
except Exception as exc:
await self.workflow_telemetry.failed("agent_workflow", exc)
await self.observer.emit_noc(
"005",
{
"session_id": state.get("conversation_key") or state.get("session_id"),
"tenant_id": state.get("tenant_id"),
"agent_id": state.get("agent_id"),
"error": str(exc),
"exception_type": exc.__class__.__name__,
},
component="workflow.ainvoke",
)
raise

View File

@@ -0,0 +1,748 @@
"""Backoffice TIM/ANATEL workflows executed by the framework runtime.
This module is the migration boundary that makes the backoffice **framework-native**:
* domain logic remains in business nodes/services/prompts copied from develop;
* the backend no longer imports or executes ``src.agent.graphs.*``;
* the framework runtime builds/compiles the LangGraph workflows, owns telemetry,
guardrails, judges, supervisor, checkpoint and persistence hooks;
* legacy REST contracts call ``execute_workflow`` instead of legacy executors.
"""
from __future__ import annotations
from typing import Any, Callable
from pathlib import Path
import inspect
import json
import logging
import yaml
from langgraph.graph import END, START, StateGraph
from agent_framework.checkpoints.langgraph_saver import create_langgraph_checkpointer
from agent_framework.guardrails.pipeline import GuardrailPipeline
from agent_framework.guardrails.output_supervisor import OutputSupervisor
from agent_framework.guardrails.rail_action import RailAction
from agent_framework.guardrails.rail_result import RailResult
from agent_framework.judges.judge import JudgePipeline
from agent_framework.supervisor.supervisor import Supervisor
from agent_framework.observability.workflow_events import WorkflowTelemetry
from agent_framework.observability.guardrail_events import GuardrailTelemetry
from agent_framework.observability.judge_events import JudgeTelemetry
from agent_framework.observability.langgraph_telemetry import LangGraphDeepTelemetry
from agent_framework.observability.observer import AgentObserver
from src.agent.state.agent_state import AgentState, create_initial_state, increment_iteration
from src.agent.state.steps import GraphStep
from src.agent.state.steps_emulator import EmulatorGraphStep
import src.agent.nodes as checklist_nodes
from src.agent.nodes.emulator import (
approve_draft_node,
close_case_node,
fetch_case_node,
generate_response_node,
persist_draft_node,
retrieve_history_node,
retrieve_templates_node,
router_node,
start_response_emulation_node,
validate_actions_node,
validate_response_node,
)
logger = logging.getLogger("backoffice.native_runtime")
def _project_root() -> Path:
return Path(__file__).resolve().parents[2]
def _load_yaml(path: str | Path) -> dict[str, Any]:
resolved = Path(path)
if not resolved.is_absolute():
resolved = _project_root() / resolved
if not resolved.exists():
raise FileNotFoundError(f"Configuração não encontrada: {resolved}")
with resolved.open("r", encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
if not isinstance(data, dict):
raise ValueError(f"Configuração YAML inválida: {resolved}")
data["_config_path"] = str(resolved)
return data
def _resolve_agent_profile(agent_id: str = "backoffice_anatel") -> dict[str, Any]:
agents_cfg = _load_yaml("config/agents.yaml")
for agent in agents_cfg.get("agents", []) or []:
if agent.get("agent_id") == agent_id:
profile = dict(agent)
profile["_agents_config_path"] = agents_cfg.get("_config_path")
return profile
raise ValueError(f"agent_id={agent_id!r} não encontrado em config/agents.yaml")
def _build_guardrail_pipeline(*, settings, observer: AgentObserver, guardrails_config: dict[str, Any]) -> GuardrailPipeline:
"""Cria o GuardrailPipeline do framework amarrado ao profile do agente.
O framework local pode ter assinaturas diferentes conforme a versão. Este
helper tenta usar config_path/config/profile quando suportado; em versões
antigas, instancia o pipeline e anexa a configuração ativa para telemetry e
debug, evitando depender apenas do config global.
"""
base_kwargs: dict[str, Any] = {
"observer": observer,
"enable_parallel": bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
"fail_fast": bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
}
config_path = guardrails_config.get("_config_path")
try:
accepted = set(inspect.signature(GuardrailPipeline.__init__).parameters)
except Exception:
accepted = set()
kwargs = dict(base_kwargs)
if "config_path" in accepted:
kwargs["config_path"] = config_path
if "config_file" in accepted:
kwargs["config_file"] = config_path
if "config" in accepted:
kwargs["config"] = guardrails_config
if "profile" in accepted:
kwargs["profile"] = guardrails_config.get("profile") or guardrails_config.get("agent_id")
if "agent_id" in accepted:
kwargs["agent_id"] = guardrails_config.get("agent_id")
try:
pipeline = GuardrailPipeline(**kwargs)
except TypeError:
pipeline = GuardrailPipeline(**base_kwargs)
for method_name in ("load_config", "configure", "set_config", "with_config"):
method = getattr(pipeline, method_name, None)
if callable(method):
try:
method(guardrails_config)
break
except TypeError:
try:
method(config_path)
break
except TypeError:
continue
setattr(pipeline, "active_agent_id", guardrails_config.get("agent_id"))
setattr(pipeline, "active_profile", guardrails_config.get("profile"))
setattr(pipeline, "active_config_path", config_path)
setattr(pipeline, "active_config", guardrails_config)
return pipeline
class NativeOutputGuardrailRail:
code = "NATIVE_OUTPUT_GUARDRAILS"
def __init__(self, pipeline: GuardrailPipeline):
self.pipeline = pipeline
async def evaluate(self, candidate: str, context: dict[str, Any]):
final, decisions = await self.pipeline.run_output(candidate, context)
serialized = [d.model_dump() for d in decisions]
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
if blocked:
first = blocked[0]
code = (getattr(first, "code", "") or "").upper()
action = RailAction.RETRY if code in {"REVPREC", "CMP", "SCO", "GND"} else RailAction.BLOCK
return RailResult(
code=code or self.code,
action=action,
reason=getattr(first, "reason", "Resposta bloqueada por guardrail de saída"),
guidance=getattr(first, "reason", "Regerar resposta seguindo as políticas de saída."),
sanitized_text=final,
metadata={"native_decisions": serialized},
)
if final != candidate:
return RailResult(
code=self.code,
action=RailAction.SANITIZE,
reason="Resposta sanitizada por guardrail de saída.",
sanitized_text=final,
metadata={"native_decisions": serialized},
)
return RailResult(
code=self.code,
action=RailAction.ALLOW,
reason="Resposta aprovada pelos guardrails de saída.",
sanitized_text=final,
metadata={"native_decisions": serialized},
)
class BackofficeNativeRuntime:
"""Framework-owned workflow runtime for the backoffice domain."""
def __init__(self, *, settings, telemetry, analytics, observer: AgentObserver | None = None):
self.settings = settings
self.telemetry = telemetry
self.analytics = analytics
self.observer = observer or AgentObserver(analytics=analytics)
self.agent_profile = _resolve_agent_profile("backoffice_anatel")
self.guardrails_config = _load_yaml(self.agent_profile["guardrails_config_path"])
self.guardrails = _build_guardrail_pipeline(
settings=settings,
observer=self.observer,
guardrails_config=self.guardrails_config,
)
logger.info(
"Backoffice guardrails bound to framework profile agent_id=%s profile=%s config_path=%s input=%s output=%s",
self.guardrails_config.get("agent_id"),
self.guardrails_config.get("profile"),
self.guardrails_config.get("_config_path"),
[r.get("code") for r in self.guardrails_config.get("input", [])],
[r.get("code") for r in self.guardrails_config.get("output", [])],
)
self.output_supervisor_engine = OutputSupervisor(
rails=[NativeOutputGuardrailRail(self.guardrails)],
observer=self.observer,
max_retries=int(getattr(settings, "OUTPUT_SUPERVISOR_MAX_RETRIES", 3)),
enable_parallel=bool(getattr(settings, "ENABLE_PARALLEL_GUARDRAILS", True)),
fail_fast=bool(getattr(settings, "GUARDRAILS_FAIL_FAST", True)),
)
self.judges = JudgePipeline()
self.supervisor = Supervisor()
self.workflow_telemetry = WorkflowTelemetry(telemetry)
self.guardrail_telemetry = GuardrailTelemetry(telemetry)
self.judge_telemetry = JudgeTelemetry(telemetry)
self.langgraph_telemetry = LangGraphDeepTelemetry(telemetry)
self._graphs: dict[str, Any] = {}
def _base_event_payload(self, state: AgentState | None = None, **extra: Any) -> dict[str, Any]:
state = state or {}
metadata = state.get("metadata", {}) or {}
payload = {
"workflow_id": metadata.get("framework_workflow_id"),
"session_id": state.get("session_id") or metadata.get("session_id") or metadata.get("transaction_id"),
"transaction_id": metadata.get("transaction_id"),
"agent_id": metadata.get("agent_id") or self.guardrails_config.get("agent_id"),
"guardrails_profile": metadata.get("guardrails_profile") or self.guardrails_config.get("profile"),
"framework_native": True,
}
payload.update({k: v for k, v in extra.items() if v is not None})
return payload
async def _safe_emit_ic(self, code: str, state: AgentState | None = None, payload: dict[str, Any] | None = None, *, component: str) -> None:
try:
await self.observer.emit_ic(code, self._base_event_payload(state, **(payload or {})), component=component)
except Exception as exc:
logger.debug("Falha ao emitir IC %s: %s", code, exc)
async def _safe_emit_noc(self, code: str, state: AgentState | None = None, payload: dict[str, Any] | None = None, *, component: str) -> None:
try:
normalized = code if str(code).startswith("NOC.") else f"NOC.{str(code).zfill(3)}"
await self.observer.emit_noc(normalized, self._base_event_payload(state, **(payload or {})), component=component)
except Exception as exc:
logger.debug("Falha ao emitir NOC %s: %s", code, exc)
async def _safe_emit_grl(self, code: str, state: AgentState | None = None, payload: dict[str, Any] | None = None, *, component: str) -> None:
try:
normalized = code if str(code).startswith("GRL.") else f"GRL.{str(code).zfill(3)}"
await self.observer.emit_grl(normalized, self._base_event_payload(state, **(payload or {})), component=component)
except Exception as exc:
logger.debug("Falha ao emitir GRL %s: %s", code, exc)
async def _emit_by_code(self, code: str, state: AgentState, payload: dict[str, Any] | None, *, component: str) -> None:
code = str(code or "IC.UNKNOWN")
if code.startswith("NOC."):
await self._safe_emit_noc(code, state, payload, component=component)
elif code.startswith("GRL."):
await self._safe_emit_grl(code, state, payload, component=component)
else:
await self._safe_emit_ic(code, state, payload, component=component)
async def _bridge_legacy_ics(self, state: AgentState, node_name: str) -> None:
"""Reemite IC/NOC/GRL legados do develop pelo AgentObserver do framework.
Os nós originais ainda chamam ``agent_framework.observer.event(...)``.
O coletor legado captura esses eventos; esta ponte pega apenas os novos
eventos desde a última etapa e os publica de forma padronizada no
observer do framework, preservando AGA.*, NOC.* e GRL.* no Langfuse/OCI.
"""
try:
from src.utils.ics_collector import ICsCollector
except Exception:
return
metadata = state.setdefault("metadata", {})
session_id = state.get("session_id") or metadata.get("transaction_id")
try:
events = ICsCollector.get_current(session_id) if session_id else []
except Exception:
events = []
last_idx = int(metadata.get("_framework_ics_bridge_index", 0) or 0)
new_events = events[last_idx:]
if not new_events:
return
metadata["_framework_ics_bridge_index"] = len(events)
bridge_log = metadata.setdefault("framework_ics_bridge", [])
for item in new_events:
code = str(item.get("code") or "IC.UNKNOWN")
event_payload = dict(item.get("metadata") or {})
event_payload.update({
"legacy_bridge": True,
"legacy_type": item.get("type"),
"legacy_description": item.get("description"),
"legacy_timestamp": item.get("timestamp"),
"source_node": node_name,
})
await self._emit_by_code(code, state, event_payload, component=f"backoffice.native_runtime.legacy_bridge.{node_name}")
bridge_log.append({"code": code, "type": item.get("type"), "node": node_name})
def _node(self, name: str, fn: Callable[[AgentState], Any]):
async def _wrapped(state: AgentState) -> AgentState:
async with self.langgraph_telemetry.node(name, state):
await self.telemetry.event(
"backoffice.workflow.node.started",
{"workflow_id": state.get("metadata", {}).get("framework_workflow_id"), "node": name, "session_id": state.get("session_id")},
kind="workflow",
)
await self._safe_emit_ic(
"IC.BACKOFFICE_NODE_STARTED",
state,
{"node": name},
component=f"backoffice.native_runtime.node.{name}",
)
try:
result = await fn(state)
except Exception as exc:
await self._safe_emit_noc(
"NOC.009",
state,
{"node": name, "type": "ERROR", "error_type": type(exc).__name__, "error": str(exc)},
component=f"backoffice.native_runtime.node.{name}",
)
raise
await self._bridge_legacy_ics(result, name)
await self.telemetry.event(
"backoffice.workflow.node.completed",
{"workflow_id": result.get("metadata", {}).get("framework_workflow_id"), "node": name, "session_id": result.get("session_id")},
kind="workflow",
)
await self._safe_emit_ic(
"IC.BACKOFFICE_NODE_COMPLETED",
result,
{"node": name, "has_error": bool(result.get("error"))},
component=f"backoffice.native_runtime.node.{name}",
)
return result
return _wrapped
async def _framework_input_guardrails(self, state: AgentState) -> AgentState:
await self._safe_emit_grl(
"GRL.001",
state,
{"phase": "input", "status": "started", "rails": [r.get("code") for r in self.guardrails_config.get("input", []) if r.get("enabled", True)]},
component="backoffice.native_runtime.guardrails.input",
)
payload = state.get("metadata", {}).get("request_context", {})
serialized = json.dumps(payload, ensure_ascii=False, default=str)
context = {
**state.get("metadata", {}),
"workflow_id": state.get("metadata", {}).get("framework_workflow_id"),
"agent_id": self.guardrails_config.get("agent_id"),
"guardrails_profile": self.guardrails_config.get("profile"),
"guardrails_config_path": self.guardrails_config.get("_config_path"),
"active_input_rails": [r.get("code") for r in self.guardrails_config.get("input", []) if r.get("enabled", True)],
"active_output_rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)],
}
sanitized, decisions = await self.guardrails.run_input(serialized, context)
state.setdefault("metadata", {})["framework_input_guardrails"] = [d.model_dump() for d in decisions]
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
await self._safe_emit_grl(
"GRL.002",
state,
{
"phase": "input",
"status": "completed",
"decision_count": len(decisions),
"blocked": bool(blocked),
"codes": [getattr(d, "code", None) for d in decisions],
},
component="backoffice.native_runtime.guardrails.input",
)
if blocked:
first = blocked[0]
state["error"] = {"type": "InputGuardrailBlocked", "message": getattr(first, "reason", "Entrada bloqueada"), "step": "framework_input_guardrails"}
state["final_response"] = sanitized or "Entrada bloqueada por política de segurança."
await self._safe_emit_grl(
"GRL.003",
state,
{"phase": "input", "status": "blocked", "code": getattr(first, "code", None), "reason": getattr(first, "reason", None)},
component="backoffice.native_runtime.guardrails.input",
)
return state
@staticmethod
def _after_input_guardrails(state: AgentState) -> str:
return "blocked" if state.get("error", {}).get("type") == "InputGuardrailBlocked" else "continue"
async def _framework_output_supervisor(self, state: AgentState) -> AgentState:
await self._safe_emit_grl(
"GRL.004",
state,
{"phase": "output_supervisor", "status": "started"},
component="backoffice.native_runtime.output_supervisor",
)
candidate = state.get("final_response") or state.get("response") or str(state.get("metadata", {}).get("request_context", {}).get("transactionId", ""))
context = {
**state.get("metadata", {}),
"session_id": state.get("session_id"),
"agent_id": self.guardrails_config.get("agent_id"),
"guardrails_profile": self.guardrails_config.get("profile"),
"guardrails_config_path": self.guardrails_config.get("_config_path"),
"active_output_rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)],
}
decision = await self.output_supervisor_engine.evaluate(candidate, context)
if decision.action in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}:
final = decision.candidate
elif decision.action == RailAction.HANDOVER:
final = "Encaminhado para continuidade com especialista."
else:
final = decision.fallback_message
state["final_response"] = final
state.setdefault("metadata", {})["framework_output_supervisor"] = {
"action": decision.action.value,
"approved": decision.approved,
"results": [
{"code": r.code, "action": r.action.value, "reason": r.reason, "guidance": r.guidance, "metadata": r.metadata}
for r in decision.results
],
}
await self._safe_emit_grl(
"GRL.005",
state,
{
"phase": "output_supervisor",
"status": "completed",
"action": decision.action.value,
"approved": decision.approved,
"result_codes": [r.code for r in decision.results],
},
component="backoffice.native_runtime.output_supervisor",
)
if decision.action not in {RailAction.ALLOW, RailAction.SANITIZE, RailAction.OBSERVE}:
await self._safe_emit_grl(
"GRL.006",
state,
{"phase": "output_supervisor", "status": "blocked_or_handover", "action": decision.action.value},
component="backoffice.native_runtime.output_supervisor",
)
return state
async def _framework_output_guardrails(self, state: AgentState) -> AgentState:
await self._safe_emit_grl(
"GRL.007",
state,
{"phase": "output", "status": "started", "rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)]},
component="backoffice.native_runtime.guardrails.output",
)
candidate = state.get("final_response") or ""
context = {
**state.get("metadata", {}),
"agent_id": self.guardrails_config.get("agent_id"),
"guardrails_profile": self.guardrails_config.get("profile"),
"guardrails_config_path": self.guardrails_config.get("_config_path"),
"active_output_rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)],
}
final, decisions = await self.guardrails.run_output(candidate, context)
state["final_response"] = final
state.setdefault("metadata", {})["framework_output_guardrails"] = [d.model_dump() for d in decisions]
blocked = [d for d in decisions if not getattr(d, "allowed", True)]
await self._safe_emit_grl(
"GRL.008",
state,
{
"phase": "output",
"status": "completed",
"decision_count": len(decisions),
"blocked": bool(blocked),
"sanitized": final != candidate,
"codes": [getattr(d, "code", None) for d in decisions],
},
component="backoffice.native_runtime.guardrails.output",
)
if blocked or final != candidate:
await self._safe_emit_grl(
"GRL.009",
state,
{"phase": "output", "status": "blocked_or_sanitized", "blocked": bool(blocked), "sanitized": final != candidate},
component="backoffice.native_runtime.guardrails.output",
)
return state
async def _framework_judges(self, state: AgentState) -> AgentState:
payload = state.get("metadata", {}).get("request_context", {})
question = json.dumps(payload, ensure_ascii=False, default=str)
answer = state.get("final_response") or ""
results = await self.judges.evaluate_all(question, answer, state.get("metadata", {}))
state.setdefault("metadata", {})["framework_judges"] = [r.model_dump() for r in results]
return state
async def _framework_supervisor_review(self, state: AgentState) -> AgentState:
answer = state.get("final_response") or ""
ok, reviewed = await self.supervisor.review(answer, state.get("metadata", {}))
state["final_response"] = reviewed if ok else reviewed
state.setdefault("metadata", {})["framework_supervisor_review"] = {"approved": ok}
return state
async def _framework_persist(self, state: AgentState) -> AgentState:
await self._safe_emit_ic(
"IC.BACKOFFICE_WORKFLOW_COMPLETED",
state,
{
"current_step": str(state.get("current_step")),
"has_error": bool(state.get("error")),
},
component="backoffice.native_runtime.persist",
)
await self._safe_emit_noc(
"NOC.006",
state,
{
"type": "INFO" if not state.get("error") else "FAILURE",
"status": "Backoffice workflow completed",
"current_step": str(state.get("current_step")),
},
component="backoffice.native_runtime.persist",
)
return state
def _compile(self, workflow_id: str):
if workflow_id == "backoffice_checklist":
return self._build_checklist_graph()
if workflow_id == "backoffice_response_emulator":
return self._build_emulator_graph()
raise ValueError(f"Unknown workflow_id={workflow_id}")
def get_graph(self, workflow_id: str):
graph = self._graphs.get(workflow_id)
if graph is None:
graph = self._compile(workflow_id)
self._graphs[workflow_id] = graph
return graph
async def execute_workflow(self, workflow_id: str, *, payload: dict[str, Any], transaction_id: str | None = None, app_state=None, metadata: dict[str, Any] | None = None) -> AgentState:
transaction_id = transaction_id or payload.get("transactionId") or payload.get("transaction_id") or "backoffice-session"
state = create_initial_state(session_id=transaction_id)
state["metadata"].update(metadata or {})
state["metadata"].update({
"transaction_id": transaction_id,
"request_context": payload,
"framework_workflow_id": workflow_id,
"framework_native": True,
"agent_id": self.guardrails_config.get("agent_id"),
"guardrails_profile": self.guardrails_config.get("profile"),
"guardrails_config_path": self.guardrails_config.get("_config_path"),
"active_input_rails": [r.get("code") for r in self.guardrails_config.get("input", []) if r.get("enabled", True)],
"active_output_rails": [r.get("code") for r in self.guardrails_config.get("output", []) if r.get("enabled", True)],
"_oci_producer": getattr(app_state, "oci_producer", None) if app_state is not None else None,
"_framework_ics_bridge_index": 0,
})
try:
from src.utils.ics_collector import ICsCollector
ICsCollector.start(transaction_id)
except Exception:
pass
await self._safe_emit_noc(
"NOC.001",
state,
{"type": "INFO", "status": "Backoffice workflow started"},
component="backoffice.native_runtime.execute",
)
await self._safe_emit_ic(
"IC.BACKOFFICE_WORKFLOW_STARTED",
state,
{"payload_keys": sorted(list(payload.keys()))},
component="backoffice.native_runtime.execute",
)
graph = self.get_graph(workflow_id)
config = {"configurable": {"thread_id": f"{workflow_id}:{transaction_id}"}}
try:
final_state = await graph.ainvoke(state, config=config)
await self._bridge_legacy_ics(final_state, "workflow_end")
return final_state
except Exception as exc:
await self._safe_emit_noc(
"NOC.009",
state,
{"type": "ERROR", "status": "Backoffice workflow failed", "error_type": type(exc).__name__, "error": str(exc)},
component="backoffice.native_runtime.execute",
)
raise
finally:
try:
final_ref = locals().get("final_state", state)
final_ref.get("metadata", {}).pop("_oci_producer", None)
final_ref.get("metadata", {}).pop("_framework_ics_bridge_index", None)
except Exception:
pass
try:
from src.utils.ics_collector import ICsCollector
ICsCollector.stop(transaction_id)
except Exception:
pass
# -------------------- Checklist workflow --------------------
def _build_checklist_graph(self):
builder = StateGraph(AgentState)
builder.add_node("framework_input_guardrails", self._node("framework_input_guardrails", self._framework_input_guardrails))
builder.add_node("fetch_ticket", self._node("fetch_ticket", self._fetch_ticket))
builder.add_node(GraphStep.VALIDATION, self._node(str(GraphStep.VALIDATION), checklist_nodes.validation_node.validate_ticket))
builder.add_node(GraphStep.BYPASS_RULES, self._node(str(GraphStep.BYPASS_RULES), checklist_nodes.bypass_rules_node.evaluate_bypass_rules))
builder.add_node(GraphStep.CACHE_CHECK, self._node(str(GraphStep.CACHE_CHECK), checklist_nodes.cache_check_node.check_cache_node))
builder.add_node(GraphStep.IMDB_ENRICHMENT, self._node(str(GraphStep.IMDB_ENRICHMENT), checklist_nodes.imdb_enrichment_node.imdb_enrich_ticket))
builder.add_node(GraphStep.IDENTITY_VERIFICATION, self._node(str(GraphStep.IDENTITY_VERIFICATION), checklist_nodes.identity_verification_node.perform_identity_verification))
builder.add_node(GraphStep.SPEECH_ENRICHMENT, self._node(str(GraphStep.SPEECH_ENRICHMENT), checklist_nodes.speech_enrichment_node.enrich_with_speech))
builder.add_node("knowledge_base_enrichment", self._node("knowledge_base_enrichment", checklist_nodes.knowledge_base_enrichment_node.enrich_with_knowledge_base))
builder.add_node(GraphStep.CANCELING_ANALYSIS, self._node(str(GraphStep.CANCELING_ANALYSIS), checklist_nodes.canceling_analysis_node.perform_canceling_analysis))
builder.add_node(GraphStep.TIM_COMPLAINT_ANALYSIS, self._node(str(GraphStep.TIM_COMPLAINT_ANALYSIS), checklist_nodes.tim_complaint_analysis_node.perform_tim_complaint_analysis))
builder.add_node("different_complaint_operator", self._node("different_complaint_operator", checklist_nodes.different_complaint_operator_node.perform_different_operator))
builder.add_node("undefined_complaint_operator", self._node("undefined_complaint_operator", checklist_nodes.undefined_complaint_operator_node.perform_undefined_complaint))
builder.add_node("tim_complaint", self._node("tim_complaint", checklist_nodes.tim_complaint_node.handle_tim_complaint))
builder.add_node(GraphStep.RECLASSIFICATION_ANALYSIS, self._node(str(GraphStep.RECLASSIFICATION_ANALYSIS), checklist_nodes.reclassification_analysis_node.perform_reclassification_analysis))
builder.add_node(GraphStep.TREATMENT_DECISION, self._node(str(GraphStep.TREATMENT_DECISION), checklist_nodes.treatment_decision_node.treatment_decision))
builder.add_node(GraphStep.SIEBEL_SR_OPENING, self._node(str(GraphStep.SIEBEL_SR_OPENING), checklist_nodes.siebel_sr_opening_node.open_siebel_sr))
builder.add_node("framework_output_supervisor", self._node("framework_output_supervisor", self._framework_output_supervisor))
builder.add_node("framework_output_guardrails", self._node("framework_output_guardrails", self._framework_output_guardrails))
builder.add_node("framework_judges", self._node("framework_judges", self._framework_judges))
builder.add_node("framework_supervisor_review", self._node("framework_supervisor_review", self._framework_supervisor_review))
builder.add_node("framework_persist", self._node("framework_persist", self._framework_persist))
builder.add_edge(START, "framework_input_guardrails")
builder.add_conditional_edges("framework_input_guardrails", self._after_input_guardrails, {"blocked": "framework_persist", "continue": "fetch_ticket"})
builder.add_edge("fetch_ticket", GraphStep.VALIDATION)
builder.add_conditional_edges(GraphStep.VALIDATION, checklist_nodes.validation_node.should_continue, {"continue": GraphStep.BYPASS_RULES, "reject": "framework_output_supervisor"})
builder.add_edge(GraphStep.BYPASS_RULES, GraphStep.CACHE_CHECK)
builder.add_conditional_edges(GraphStep.CACHE_CHECK, self._route_after_cache_check, {GraphStep.TREATMENT_DECISION: GraphStep.TREATMENT_DECISION, GraphStep.CANCELING_ANALYSIS: GraphStep.CANCELING_ANALYSIS, GraphStep.IMDB_ENRICHMENT: GraphStep.IMDB_ENRICHMENT})
builder.add_conditional_edges(GraphStep.IMDB_ENRICHMENT, checklist_nodes.imdb_enrichment_node.should_continue, {"continue": GraphStep.IDENTITY_VERIFICATION, "failed": "framework_output_supervisor"})
builder.add_conditional_edges(GraphStep.IDENTITY_VERIFICATION, checklist_nodes.identity_verification_node.route_after_identity_verification, {"proceed": GraphStep.SPEECH_ENRICHMENT, "cancel": GraphStep.SIEBEL_SR_OPENING, "smart_human": GraphStep.TREATMENT_DECISION, "failed": "framework_output_supervisor"})
builder.add_edge(GraphStep.SPEECH_ENRICHMENT, "knowledge_base_enrichment")
builder.add_conditional_edges("knowledge_base_enrichment", self._route_after_knowledge_base, {GraphStep.CANCELING_ANALYSIS: GraphStep.CANCELING_ANALYSIS, GraphStep.TREATMENT_DECISION: GraphStep.TREATMENT_DECISION})
builder.add_conditional_edges(GraphStep.CANCELING_ANALYSIS, self._route_after_canceling, {GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING, GraphStep.PROCEED_GRAPH: GraphStep.TIM_COMPLAINT_ANALYSIS, "finalize": "framework_output_supervisor"})
builder.add_conditional_edges(GraphStep.TIM_COMPLAINT_ANALYSIS, self._route_after_tim_complaint_analysis, {"tim_complaint": "tim_complaint", "different_complaint_operator": "different_complaint_operator", "undefined_complaint_operator": "undefined_complaint_operator", "finalize": "framework_output_supervisor"})
builder.add_edge("tim_complaint", GraphStep.RECLASSIFICATION_ANALYSIS)
builder.add_conditional_edges("different_complaint_operator", self._route_after_operator_check, {GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING, GraphStep.PROCEED_GRAPH: GraphStep.RECLASSIFICATION_ANALYSIS})
builder.add_conditional_edges("undefined_complaint_operator", self._route_after_operator_check, {GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING, GraphStep.PROCEED_GRAPH: GraphStep.RECLASSIFICATION_ANALYSIS})
builder.add_conditional_edges(GraphStep.RECLASSIFICATION_ANALYSIS, self._route_after_reclassification, {GraphStep.SIEBEL_SR_OPENING: GraphStep.SIEBEL_SR_OPENING, GraphStep.PROCEED_GRAPH: GraphStep.TREATMENT_DECISION, "finalize": "framework_output_supervisor"})
builder.add_edge(GraphStep.TREATMENT_DECISION, GraphStep.SIEBEL_SR_OPENING)
builder.add_edge(GraphStep.SIEBEL_SR_OPENING, "framework_output_supervisor")
builder.add_edge("framework_output_supervisor", "framework_output_guardrails")
builder.add_edge("framework_output_guardrails", "framework_judges")
builder.add_edge("framework_judges", "framework_supervisor_review")
builder.add_edge("framework_supervisor_review", "framework_persist")
builder.add_edge("framework_persist", END)
return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings))
async def _fetch_ticket(self, state: AgentState) -> AgentState:
increment_iteration(state)
return await checklist_nodes.fetch_ticket_node.fetch_ticket_data(state)
@staticmethod
def _route_after_cache_check(state: AgentState) -> str:
if state.get("cache_found") is True:
if state.get("bypass_treatment_validations"):
return GraphStep.TREATMENT_DECISION
return GraphStep.CANCELING_ANALYSIS
return GraphStep.IMDB_ENRICHMENT
@staticmethod
def _route_after_knowledge_base(state: AgentState) -> str:
return GraphStep.TREATMENT_DECISION if state.get("bypass_treatment_validations") else GraphStep.CANCELING_ANALYSIS
@staticmethod
def _route_after_canceling(state: AgentState) -> str:
step = state.get("current_step")
if step == GraphStep.CANCELING_ANALYSIS_CANCEL_TICKET:
return GraphStep.SIEBEL_SR_OPENING
if step == GraphStep.PROCEED_GRAPH:
return GraphStep.PROCEED_GRAPH
return "finalize"
@staticmethod
def _route_after_tim_complaint_analysis(state: AgentState) -> str:
decision = state.get("metadata", {}).get("request_context", {}).get("is_tim_complaint", "")
if decision == "sim":
return "tim_complaint"
if decision == "não":
return "different_complaint_operator"
if decision == "inconclusivo":
return "undefined_complaint_operator"
return "finalize"
@staticmethod
def _route_after_operator_check(state: AgentState) -> str:
context = state.get("metadata", {}).get("request_context", {})
if context.get("forward_complaint"):
return GraphStep.SIEBEL_SR_OPENING
return GraphStep.PROCEED_GRAPH
@staticmethod
def _route_after_reclassification(state: AgentState) -> str:
step = state.get("current_step")
if step == GraphStep.RECLASSIFICATION_ANALYSIS_COMPLETED:
context = state.get("metadata", {}).get("request_context", {})
if context.get("siebel_action") == "reclassificar":
return GraphStep.SIEBEL_SR_OPENING
return GraphStep.PROCEED_GRAPH
return "finalize"
# -------------------- Emulator workflow --------------------
def _build_emulator_graph(self):
builder = StateGraph(AgentState)
builder.add_node("framework_input_guardrails", self._node("framework_input_guardrails", self._framework_input_guardrails))
builder.add_node(EmulatorGraphStep.RESPONSE_EMULATION_START, self._node(str(EmulatorGraphStep.RESPONSE_EMULATION_START), start_response_emulation_node.start_response_emulation))
builder.add_node(EmulatorGraphStep.FETCH_CASE, self._node(str(EmulatorGraphStep.FETCH_CASE), fetch_case_node.fetch_case))
builder.add_node(EmulatorGraphStep.VALIDATE_ACTIONS, self._node(str(EmulatorGraphStep.VALIDATE_ACTIONS), validate_actions_node.validate_actions))
builder.add_node(EmulatorGraphStep.ROUTER_DECISION, self._node(str(EmulatorGraphStep.ROUTER_DECISION), router_node.route))
builder.add_node(EmulatorGraphStep.RETRIEVE_TEMPLATES, self._node(str(EmulatorGraphStep.RETRIEVE_TEMPLATES), retrieve_templates_node.retrieve_templates))
builder.add_node(EmulatorGraphStep.RETRIEVE_HISTORY, self._node(str(EmulatorGraphStep.RETRIEVE_HISTORY), retrieve_history_node.retrieve_history))
builder.add_node(EmulatorGraphStep.GENERATE_RESPONSE, self._node(str(EmulatorGraphStep.GENERATE_RESPONSE), generate_response_node.generate_response))
builder.add_node(EmulatorGraphStep.VALIDATE_RESPONSE, self._node(str(EmulatorGraphStep.VALIDATE_RESPONSE), validate_response_node.validate_response))
builder.add_node(EmulatorGraphStep.PERSIST_DRAFT, self._node(str(EmulatorGraphStep.PERSIST_DRAFT), persist_draft_node.persist_draft))
builder.add_node(EmulatorGraphStep.APPROVE_DRAFT, self._node(str(EmulatorGraphStep.APPROVE_DRAFT), approve_draft_node.approve_draft))
builder.add_node(EmulatorGraphStep.CLOSE_CASE, self._node(str(EmulatorGraphStep.CLOSE_CASE), close_case_node.close_case))
builder.add_node("framework_output_supervisor", self._node("framework_output_supervisor", self._framework_output_supervisor))
builder.add_node("framework_output_guardrails", self._node("framework_output_guardrails", self._framework_output_guardrails))
builder.add_node("framework_judges", self._node("framework_judges", self._framework_judges))
builder.add_node("framework_supervisor_review", self._node("framework_supervisor_review", self._framework_supervisor_review))
builder.add_node("framework_persist", self._node("framework_persist", self._framework_persist))
builder.add_edge(START, "framework_input_guardrails")
builder.add_conditional_edges("framework_input_guardrails", self._after_input_guardrails, {"blocked": "framework_persist", "continue": EmulatorGraphStep.RESPONSE_EMULATION_START})
builder.add_edge(EmulatorGraphStep.RESPONSE_EMULATION_START, EmulatorGraphStep.FETCH_CASE)
builder.add_conditional_edges(EmulatorGraphStep.FETCH_CASE, self._emulator_route_after_fetch, {"generate": EmulatorGraphStep.VALIDATE_ACTIONS, "approve": EmulatorGraphStep.APPROVE_DRAFT, "close": EmulatorGraphStep.CLOSE_CASE, "failed": "framework_output_supervisor"})
builder.add_conditional_edges(EmulatorGraphStep.VALIDATE_ACTIONS, validate_actions_node.should_continue, {"continue": EmulatorGraphStep.ROUTER_DECISION, "failed": "framework_output_supervisor"})
builder.add_conditional_edges(EmulatorGraphStep.ROUTER_DECISION, router_node.next_step_after_router, {EmulatorGraphStep.RETRIEVE_TEMPLATES: EmulatorGraphStep.RETRIEVE_TEMPLATES, EmulatorGraphStep.RETRIEVE_HISTORY: EmulatorGraphStep.RETRIEVE_HISTORY, EmulatorGraphStep.GENERATE_RESPONSE: EmulatorGraphStep.GENERATE_RESPONSE})
builder.add_conditional_edges(EmulatorGraphStep.RETRIEVE_TEMPLATES, router_node.next_step_after_templates, {EmulatorGraphStep.RETRIEVE_HISTORY: EmulatorGraphStep.RETRIEVE_HISTORY, EmulatorGraphStep.GENERATE_RESPONSE: EmulatorGraphStep.GENERATE_RESPONSE})
builder.add_edge(EmulatorGraphStep.RETRIEVE_HISTORY, EmulatorGraphStep.GENERATE_RESPONSE)
builder.add_conditional_edges(EmulatorGraphStep.GENERATE_RESPONSE, generate_response_node.should_continue, {"continue": EmulatorGraphStep.VALIDATE_RESPONSE, "failed": "framework_output_supervisor"})
builder.add_edge(EmulatorGraphStep.VALIDATE_RESPONSE, EmulatorGraphStep.PERSIST_DRAFT)
builder.add_edge(EmulatorGraphStep.PERSIST_DRAFT, "framework_output_supervisor")
builder.add_edge(EmulatorGraphStep.APPROVE_DRAFT, "framework_output_supervisor")
builder.add_edge(EmulatorGraphStep.CLOSE_CASE, "framework_output_supervisor")
builder.add_edge("framework_output_supervisor", "framework_output_guardrails")
builder.add_edge("framework_output_guardrails", "framework_judges")
builder.add_edge("framework_judges", "framework_supervisor_review")
builder.add_edge("framework_supervisor_review", "framework_persist")
builder.add_edge("framework_persist", END)
return builder.compile(checkpointer=create_langgraph_checkpointer(self.settings))
@staticmethod
def _emulator_route_after_fetch(state: AgentState) -> str:
if state.get("error"):
return "failed"
flow_mode = (state.get("metadata") or {}).get("flow_mode")
if flow_mode == "close":
return "close"
if flow_mode == "approve":
return "approve"
return "generate"