Files
first_contas/app/agents/contas_agent.py
2026-06-16 20:54:49 -03:00

219 lines
8.7 KiB
Python

from __future__ import annotations
from typing import Any
from app.agents.runtime import AgentRuntimeMixin
_CONTAS_TOOLS_BY_INTENT: dict[str, list[str]] = {
"billing_invoice_explanation": ["consultar_fatura", "consultar_pagamentos"],
"billing_payment_status": ["consultar_pagamentos", "consultar_fatura"],
"billing_second_copy": ["consultar_fatura", "recuperar_pdf_fatura"],
"billing_contestation": ["consultar_fatura", "consultar_pagamentos", "consultar_status_sr"],
"service_plan_information": ["consultar_plano", "listar_servicos"],
"vas_services_information": ["listar_servicos", "consultar_plano", "consultar_status_sr"],
"billing_protocol_status": ["consultar_status_sr"],
"billing_protocol_register": ["registrar_protocolo"],
"vas_cancel_or_block": ["listar_servicos", "cancelar_vas", "bloquear_vas", "registrar_protocolo"],
"send_billing_sms": ["enviar_sms"],
"tracking_activity": ["registrar_tracking"],
"generic_billing_rag": [],
}
class ContasAgent(AgentRuntimeMixin):
"""Agent Contas FIRST migrado para o agent_framework_oci.
Esta classe mantém a lógica específica de negócio apenas como prompt e
seleção declarativa de intents/tools. Guardrails, judges, RAG, memória,
cache MCP, Identity/BusinessContext, telemetria IC/NOC/GRL e chamada MCP são
executados pelos componentes nativos do framework.
"""
name = "contas_agent"
def __init__(
self,
llm,
telemetry=None,
tool_router=None,
rag_service=None,
cache=None,
settings=None,
observer=None,
memory=None,
summary_memory=None,
):
self.llm = llm
self.telemetry = telemetry
self.tool_router = tool_router
self.rag_service = rag_service
self.cache = cache
self.settings = settings
self.observer = observer
self.memory = memory
self.summary_memory = summary_memory
async def run(self, state: dict[str, Any]) -> dict[str, Any]:
state = self.normalize_tools_by_intent(
state,
default_tools_by_intent=_CONTAS_TOOLS_BY_INTENT,
default_intent="billing_invoice_explanation",
route=self.name,
)
await self._emit_ic(
"IC.CONTAS_AGENT_STARTED",
state,
{
"framework_native": True,
"uses_native_guardrails": True,
"uses_native_rag": True,
"uses_native_mcp_router": True,
"legacy_business_layer": False,
},
component="agent.contas.start",
)
runtime = self.get_runtime_context(state)
missing = []
if not runtime.pick("customer_key", "msisdn", "customer_id"):
missing.append("customer_key/msisdn")
if missing:
await self._emit_noc(
"NOC.CONTAS_IDENTITY_INCOMPLETE",
state,
{"missing": missing, "business_context": runtime.business_context},
component="agent.contas.identity",
)
await self.prepare_memory_context(state)
rag_context, rag_metadata = await self._retrieve_rag_context(state)
await self._emit_ic(
"IC.CONTAS_RAG_CONTEXT_RETRIEVED",
state,
rag_metadata,
component="agent.contas.rag",
)
mcp_results = await self.execute_tools_for_intent(
state,
aliases={
"customer_key": ["msisdn", "customer_id", "ani", "from"],
"contract_key": ["invoice_id", "current_invoice_number", "asset_id"],
"interaction_key": ["ura_call_id", "message_id", "call_id"],
"resource_key": ["asset_id", "product_id"],
"account_key": ["billing_account_id", "account_id"],
},
)
mcp_results = self._normalize_mcp_diagnostics(mcp_results)
state["mcp_results"] = mcp_results
await self._emit_ic(
"IC.CONTAS_MCP_CONTEXT_COLLECTED",
state,
{
"tool_count": len(state.get("mcp_tools") or []),
"result_count": len(mcp_results),
"ok_count": sum(1 for r in mcp_results if r.get("ok")),
},
component="agent.contas.mcp",
)
system_prompt = """
Você é o Agent Contas FIRST migrado para o agent_framework_oci.
Regras de arquitetura:
- Use os resultados MCP recebidos do framework como fonte operacional.
- Use o contexto RAG nativo como fonte de política/conhecimento.
- Não invente dados de fatura, pagamento, plano, VAS, protocolo ou contestação.
- Não crie regras de negócio paralelas; quando faltar evidência, diga exatamente o que falta.
- Mantenha a resposta curta, auditável e adequada para atendimento de telecom.
- Para ações sensíveis como contestação/cancelamento/bloqueio, explique o próximo passo e solicite confirmação quando a tool/política exigir.
Coberturas vindas do agent_contas_first reaproveitadas como comportamento, não como camada legada:
- explicação de fatura e divergência de valor;
- consulta de pagamentos e segunda via;
- plano/serviços/VAS;
- contestação e fluxos com confirmação;
- uso de protocolo apenas quando existir ferramenta/evidência.
""".strip()
messages = self.build_messages(
state,
system_prompt=system_prompt,
mcp_results=mcp_results,
rag_context=rag_context,
rag_metadata=rag_metadata,
extra_sections={
"Diretriz de migração": "Máximo uso do framework; legado apenas como referência funcional.",
"Intents suportadas": sorted(_CONTAS_TOOLS_BY_INTENT.keys()),
},
)
try:
answer = await self._invoke_llm_cached(state, self.name, messages)
except Exception as exc:
await self._emit_noc(
"NOC.CONTAS_LLM_FAILED",
state,
{"error": str(exc)},
component="agent.contas.llm",
)
answer = self.build_llm_fallback_answer(state, mcp_results, agent_label="ContasAgent")
await self._emit_ic(
"IC.CONTAS_AGENT_COMPLETED",
state,
{
"answer_chars": len(str(answer or "")),
"mcp_result_count": len(mcp_results),
"rag_document_count": rag_metadata.get("document_count"),
},
component="agent.contas.completed",
)
return {
"answer": str(answer or ""),
"mcp_results": mcp_results,
"rag_metadata": rag_metadata,
"active_agent": self.name,
"route": self.name,
"next_state": self._next_state_for_intent(state),
}
def _normalize_mcp_diagnostics(self, results: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Preserva evidência MCP mesmo quando versões antigas do router retornam erro vazio.
A integração correta agora retorna metadata rica do MCP server legado.
Esta proteção evita que o prompt/telemetria recebam `ok=False` sem
diagnóstico quando o ToolRouter instalado for anterior ou quando o
servidor MCP antigo ainda estiver rodando em outra janela.
"""
normalized: list[dict[str, Any]] = []
for item in results or []:
if not isinstance(item, dict):
normalized.append({"ok": False, "error": f"Resultado MCP inválido: {type(item).__name__}", "metadata": {"exception_type": "InvalidMCPResult"}})
continue
current = dict(item)
metadata = current.get("metadata")
if not isinstance(metadata, dict):
metadata = {}
if current.get("ok") is False and not str(current.get("error") or "").strip():
current["error"] = "MCP retornou ok=False sem mensagem de erro; confirme se o legacy_tim_mcp v7 está em execução e reinicie o backend."
metadata.setdefault("exception_type", "EmptyMCPError")
metadata.setdefault("diagnostic_hint", "O servidor MCP antigo ou o ToolRouter instalado pode estar descartando error/metadata.")
current["metadata"] = metadata
normalized.append(current)
return normalized
def _next_state_for_intent(self, state: dict[str, Any]) -> str:
intent = state.get("intent") or ""
if intent in {"billing_contestation", "billing_second_copy"}:
return "WAITING_BILLING_CONFIRMATION"
if intent in {"service_plan_information", "vas_services_information"}:
return "WAITING_SERVICE_CONFIRMATION"
return "CONTAS_ACTIVE"