mirror of
https://github.com/hoshikawa2/compass_backoffice.git
synced 2026-07-09 13:54:20 +00:00
first commit
This commit is contained in:
BIN
app/.DS_Store
vendored
Normal file
BIN
app/.DS_Store
vendored
Normal file
Binary file not shown.
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
BIN
app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/identity_extraction.cpython-313.pyc
Normal file
BIN
app/__pycache__/identity_extraction.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/main.cpython-313.pyc
Normal file
BIN
app/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/__pycache__/state.cpython-313.pyc
Normal file
BIN
app/__pycache__/state.cpython-313.pyc
Normal file
Binary file not shown.
15
app/agents/README.md
Normal file
15
app/agents/README.md
Normal 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.
|
||||
BIN
app/agents/__pycache__/backoffice_agent.cpython-313.pyc
Normal file
BIN
app/agents/__pycache__/backoffice_agent.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/agents/__pycache__/billing_agent.cpython-313.pyc
Normal file
BIN
app/agents/__pycache__/billing_agent.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/agents/__pycache__/orders_agent.cpython-313.pyc
Normal file
BIN
app/agents/__pycache__/orders_agent.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/agents/__pycache__/product_agent.cpython-313.pyc
Normal file
BIN
app/agents/__pycache__/product_agent.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/agents/__pycache__/prompting.cpython-313.pyc
Normal file
BIN
app/agents/__pycache__/prompting.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/agents/__pycache__/runtime.cpython-313.pyc
Normal file
BIN
app/agents/__pycache__/runtime.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/agents/__pycache__/support_agent.cpython-313.pyc
Normal file
BIN
app/agents/__pycache__/support_agent.cpython-313.pyc
Normal file
Binary file not shown.
421
app/agents/backoffice_agent.py
Normal file
421
app/agents/backoffice_agent.py
Normal 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"
|
||||
70
app/agents/billing_agent.py
Normal file
70
app/agents/billing_agent.py
Normal 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)
|
||||
69
app/agents/orders_agent.py
Normal file
69
app/agents/orders_agent.py
Normal 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)
|
||||
70
app/agents/product_agent.py
Normal file
70
app/agents/product_agent.py
Normal 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
15
app/agents/prompting.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def apply_agent_profile_prompt(state: dict, default_prompt: str) -> str:
|
||||
"""Adiciona o prefixo de prompt configurado para o agent_template selecionado.
|
||||
|
||||
Cada agent_id pode definir metadata.system_prefix em config/agents.yaml. Isso
|
||||
mantém prompts isolados sem duplicar o código dos agentes especializados.
|
||||
"""
|
||||
profile = state.get("agent_profile") or (state.get("context") or {}).get("agent_profile") or {}
|
||||
metadata = profile.get("metadata") or {}
|
||||
prefix = (metadata.get("system_prefix") or "").strip()
|
||||
if not prefix:
|
||||
return default_prompt
|
||||
return f"{prefix}\n\n{default_prompt}"
|
||||
267
app/agents/runtime.py
Normal file
267
app/agents/runtime.py
Normal 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
|
||||
67
app/agents/support_agent.py
Normal file
67
app/agents/support_agent.py
Normal 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
BIN
app/domain/.DS_Store
vendored
Normal file
Binary file not shown.
0
app/domain/__init__.py
Normal file
0
app/domain/__init__.py
Normal file
BIN
app/domain/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
app/domain/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
0
app/domain/backoffice/__init__.py
Normal file
0
app/domain/backoffice/__init__.py
Normal file
BIN
app/domain/backoffice/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
app/domain/backoffice/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
1
app/examples/__init__.py
Normal file
1
app/examples/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Exemplos de uso do template backend enterprise."""
|
||||
BIN
app/examples/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
app/examples/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/examples/__pycache__/grl_examples.cpython-313.pyc
Normal file
BIN
app/examples/__pycache__/grl_examples.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/examples/__pycache__/ic_examples.cpython-313.pyc
Normal file
BIN
app/examples/__pycache__/ic_examples.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/examples/__pycache__/mcp_examples.cpython-313.pyc
Normal file
BIN
app/examples/__pycache__/mcp_examples.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/examples/__pycache__/noc_examples.cpython-313.pyc
Normal file
BIN
app/examples/__pycache__/noc_examples.cpython-313.pyc
Normal file
Binary file not shown.
BIN
app/examples/__pycache__/observer_examples.cpython-313.pyc
Normal file
BIN
app/examples/__pycache__/observer_examples.cpython-313.pyc
Normal file
Binary file not shown.
37
app/examples/grl_examples.py
Normal file
37
app/examples/grl_examples.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Exemplos de GRL.
|
||||
|
||||
GRL representa eventos de guardrails. Em regra, GRL.001..GRL.009 são emitidos
|
||||
pelo pipeline de guardrails e pelo OutputSupervisor do framework. Use emissão
|
||||
manual apenas para validações customizadas do agente.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def exemplo_guardrail_observado(observer: Any, state: dict[str, Any], rail_code: str, reason: str) -> None:
|
||||
await observer.emit_grl(
|
||||
"OBSERVE",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"rail_code": rail_code,
|
||||
"reason": reason,
|
||||
},
|
||||
component="examples.grl",
|
||||
)
|
||||
|
||||
|
||||
async def exemplo_guardrail_block(observer: Any, state: dict[str, Any], rail_code: str, reason: str) -> None:
|
||||
await observer.emit_grl(
|
||||
"004",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"rail_code": rail_code,
|
||||
"reason": reason,
|
||||
"action": "block",
|
||||
},
|
||||
component="examples.grl",
|
||||
)
|
||||
34
app/examples/ic_examples.py
Normal file
34
app/examples/ic_examples.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Exemplos de IC - Item de Controle.
|
||||
|
||||
ICs representam eventos de negócio. Eles alimentam Informacional, Curadoria,
|
||||
analytics, BigQuery ou qualquer publisher configurado no framework.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def exemplo_fatura_consultada(observer: Any, state: dict[str, Any], invoice_id: str) -> None:
|
||||
await observer.emit_ic(
|
||||
"IC.FATURA_CONSULTADA",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"invoice_id": invoice_id,
|
||||
},
|
||||
component="examples.ic",
|
||||
)
|
||||
|
||||
|
||||
async def exemplo_acao_concluida(observer: Any, state: dict[str, Any], action_name: str, ok: bool) -> None:
|
||||
await observer.emit_ic(
|
||||
"IC.ACAO_CONCLUIDA",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"action_name": action_name,
|
||||
"ok": ok,
|
||||
},
|
||||
component="examples.ic",
|
||||
)
|
||||
43
app/examples/mcp_examples.py
Normal file
43
app/examples/mcp_examples.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Exemplos de MCP + IC.
|
||||
|
||||
O AgentRuntimeMixin já possui _collect_mcp_context(), mas este arquivo mostra o
|
||||
padrão para chamadas explícitas ao tool_router quando necessário.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def exemplo_chamada_mcp(tool_router: Any, observer: Any, state: dict[str, Any], tool_name: str, payload: dict[str, Any]) -> Any:
|
||||
session_id = state.get("conversation_key") or state.get("session_id")
|
||||
|
||||
await observer.emit_ic(
|
||||
"IC.MCP_TOOL_CALLED",
|
||||
{
|
||||
"session_id": session_id,
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"tool_name": tool_name,
|
||||
},
|
||||
component="examples.mcp",
|
||||
)
|
||||
|
||||
result = await tool_router.call(
|
||||
tool_name,
|
||||
payload,
|
||||
business_context=(state.get("context") or {}).get("business_context") or {},
|
||||
original_context=state.get("context") or {},
|
||||
)
|
||||
|
||||
await observer.emit_ic(
|
||||
"IC.TOOL_CALLED",
|
||||
{
|
||||
"session_id": session_id,
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"tool_name": tool_name,
|
||||
"ok": getattr(result, "ok", None),
|
||||
},
|
||||
component="examples.mcp",
|
||||
)
|
||||
|
||||
return result
|
||||
37
app/examples/noc_examples.py
Normal file
37
app/examples/noc_examples.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Exemplos de NOC.
|
||||
|
||||
NOC representa telemetria operacional. O workflow do template já emite NOC.001,
|
||||
NOC.005 e NOC.006. Estes exemplos mostram eventos adicionais que a squad pode
|
||||
emitir em pontos críticos.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def exemplo_api_invalida(observer: Any, state: dict[str, Any], api_url: str, status_code: int, latency_ms: int) -> None:
|
||||
await observer.emit_noc(
|
||||
"002",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"apiUrl": api_url,
|
||||
"statusCode": status_code,
|
||||
"latencyMs": latency_ms,
|
||||
},
|
||||
component="examples.noc",
|
||||
)
|
||||
|
||||
|
||||
async def exemplo_latencia_banco(observer: Any, state: dict[str, Any], resource_name: str, latency_ms: int) -> None:
|
||||
await observer.emit_noc(
|
||||
"003",
|
||||
{
|
||||
"session_id": state.get("conversation_key") or state.get("session_id"),
|
||||
"tenant_id": state.get("tenant_id"),
|
||||
"agent_id": state.get("agent_id"),
|
||||
"resourceName": resource_name,
|
||||
"latencyMs": latency_ms,
|
||||
},
|
||||
component="examples.noc",
|
||||
)
|
||||
28
app/examples/observer_examples.py
Normal file
28
app/examples/observer_examples.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Resumo prático do Observer corporativo.
|
||||
|
||||
Use este arquivo como cola rápida para IC, NOC e GRL.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def emitir_eventos_basicos(observer: Any, state: dict[str, Any]) -> None:
|
||||
session_id = state.get("conversation_key") or state.get("session_id")
|
||||
|
||||
await observer.emit_ic(
|
||||
"IC.EXEMPLO_NEGOCIO",
|
||||
{"session_id": session_id, "agent_id": state.get("agent_id")},
|
||||
component="examples.observer",
|
||||
)
|
||||
|
||||
await observer.emit_noc(
|
||||
"EXEMPLO_OPERACIONAL",
|
||||
{"session_id": session_id, "agent_id": state.get("agent_id")},
|
||||
component="examples.observer",
|
||||
)
|
||||
|
||||
await observer.emit_grl(
|
||||
"OBSERVE",
|
||||
{"session_id": session_id, "agent_id": state.get("agent_id"), "rail_code": "CUSTOM"},
|
||||
component="examples.observer",
|
||||
)
|
||||
90
app/identity_extraction.py
Normal file
90
app/identity_extraction.py
Normal 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
826
app/main.py
Normal 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
34
app/state.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from typing import Any, TypedDict
|
||||
|
||||
|
||||
class AgentState(TypedDict, total=False):
|
||||
tenant_id: str
|
||||
agent_id: str
|
||||
session_id: str
|
||||
conversation_key: str
|
||||
agent_profile: dict[str, Any]
|
||||
user_text: str
|
||||
sanitized_input: str
|
||||
route: str
|
||||
intent: str
|
||||
route_decision: dict[str, Any]
|
||||
answer: str
|
||||
final_answer: str
|
||||
history: list[dict[str, Any]]
|
||||
context: dict[str, Any]
|
||||
guardrail_decisions: list[dict[str, Any]]
|
||||
judge_results: list[dict[str, Any]]
|
||||
next_state: str
|
||||
domain: str
|
||||
mcp_tools: list[str]
|
||||
mcp_results: list[dict[str, Any]]
|
||||
supervisor_plan: dict[str, Any]
|
||||
supervisor_results: list[dict[str, Any]]
|
||||
active_agent: str
|
||||
blocked: bool
|
||||
supervisor_action: str
|
||||
supervisor_guidance: str
|
||||
supervisor_attempt: int
|
||||
supervisor_handover_reason: str
|
||||
output_supervisor_results: list[dict[str, Any]]
|
||||
output_guardrails_already_applied: bool
|
||||
BIN
app/workflows/__pycache__/agent_graph.cpython-313.pyc
Normal file
BIN
app/workflows/__pycache__/agent_graph.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
718
app/workflows/agent_graph.py
Normal file
718
app/workflows/agent_graph.py
Normal 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
|
||||
748
app/workflows/backoffice_native_runtime.py
Normal file
748
app/workflows/backoffice_native_runtime.py
Normal 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"
|
||||
Reference in New Issue
Block a user