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"