bugfix: route stickness precedences (transaction in the same intent)

This commit is contained in:
2026-08-20 09:22:03 -03:00
parent bb0ef019bf
commit 9df2467deb
434 changed files with 7098 additions and 281 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -3,5 +3,8 @@ from __future__ import annotations
# Compatibilidade local do template/backend.
# A implementação oficial agora fica no framework para evitar duplicação entre agentes.
from agent_framework.runtime import AgentRuntimeMixin, MessageBuilder, RuntimeContext
from app.presentation import register_tool_renderers
register_tool_renderers()
__all__ = ["AgentRuntimeMixin", "MessageBuilder", "RuntimeContext"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -37,6 +37,47 @@ class ContasDomainService:
def consultar_faturas(self, *, msisdn: str, **_: Any) -> Any:
return self.client.consultar_faturas(msisdn)
def consultar_plano(self, *, msisdn: str, **args: Any) -> dict[str, Any]:
"""Retorna somente os planos presentes na evidência de billing analysis.
A consulta é deliberadamente determinística: não usa VAS, RAG nem o
workflow de invoice_explanation para responder qual é o plano do cliente.
"""
billing = args.get("billing_analysis")
if not isinstance(billing, dict):
billing = self.client.billing_analysis(
msisdn,
invoice_id=args.get("invoice_id"),
customer_id=args.get("customer_id"),
)
plans: list[dict[str, Any]] = []
for section in billing.get("currentInvoice") or [] if isinstance(billing, dict) else []:
if not isinstance(section, dict):
continue
section_type = str(section.get("type") or "").strip().casefold()
section_desc = str(section.get("desc") or "").strip().casefold()
if section_type not in {"plano", "planos"} and section_desc not in {"plano", "planos"}:
continue
for item in section.get("items") or []:
if not isinstance(item, dict):
continue
name = str(item.get("desc") or item.get("name") or "").strip()
if not name:
continue
plans.append({
"name": name,
"value": item.get("value"),
"type": str(item.get("type") or section.get("type") or "plano"),
"contestable": item.get("contestable"),
})
return {
"plans": plans,
"count": len(plans),
"source": "billing_analysis.currentInvoice",
}
def invoice_explanation(self, *, msisdn: str, **args: Any) -> dict[str, Any]:
# Reuse framework-prefetched evidence when available; the domain never owns
# a second cache/session implementation. Explanation itself is produced by

Binary file not shown.

View File

@@ -0,0 +1,3 @@
from .tool_renderers import register_tool_renderers
__all__ = ["register_tool_renderers"]

Binary file not shown.

View File

@@ -0,0 +1,108 @@
from __future__ import annotations
from typing import Any
from agent_framework.presentation import register_tool_response_renderer
def _money_brl(value: Any) -> str:
try:
return f"{float(value):.2f}".replace(".", ",")
except (TypeError, ValueError):
return str(value)
def render_contas_plan(
*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str
) -> str | None:
plans = result.get("plans")
if not isinstance(plans, list) or not plans:
return None
lines: list[str] = []
for plan in plans:
if not isinstance(plan, dict):
continue
name = plan.get("name")
if not name:
continue
value = plan.get("value")
line = f"- {name}"
if value is not None:
line += f": R$ {_money_brl(value)}"
lines.append(line)
if not lines:
return None
if len(lines) == 1:
return f"[{agent_label}] Seu plano é:\n{lines[0]}"
return f"[{agent_label}] Existem {len(lines)} planos associados à sua conta:\n" + "\n".join(lines)
def _service_value(service: dict[str, Any]) -> Any:
details = service.get("details") if isinstance(service.get("details"), dict) else {}
for candidate in (
details.get("valor"),
details.get("value"),
service.get("valor"),
service.get("value"),
service.get("price"),
):
if candidate not in (None, ""):
return candidate
return None
def _money_brl_preserve(value: Any) -> str:
if value is None:
return ""
text = str(value).strip()
if not text:
return ""
# TIM APIs may already return BRL decimal strings such as "10,00".
# Normalize only when needed and never invent a value.
if "," in text and "." not in text:
try:
return f"{float(text.replace(',', '.')):.2f}".replace(".", ",")
except ValueError:
return text
return _money_brl(value)
def render_contas_vas(
*, tool_name: str, result: dict[str, Any], state: dict[str, Any], agent_label: str
) -> str | None:
services = result.get("products") or result.get("services")
if not isinstance(services, list) or not services:
return None
lines: list[str] = []
for service in services:
if not isinstance(service, dict):
continue
name = service.get("name") or service.get("description") or service.get("billDescription")
if not name:
continue
details = service.get("details") if isinstance(service.get("details"), dict) else {}
can = service.get("can") if isinstance(service.get("can"), dict) else {}
details_can = details.get("can") if isinstance(details.get("can"), dict) else {}
cancelable = bool(can.get("cancel", details_can.get("cancel", False)))
category = service.get("classe") or details.get("classe") or ("avulso" if cancelable else None)
value = _service_value(service)
line = f"- {name}"
if category:
line += f" ({category})"
if value not in (None, ""):
line += f": R$ {_money_brl_preserve(value)}"
lines.append(line)
if not lines:
return None
return f"[{agent_label}] Você possui os seguintes serviços ativos:\n" + "\n".join(lines)
def register_tool_renderers() -> None:
register_tool_response_renderer("contas.plan", render_contas_plan)
register_tool_response_renderer("contas.vas", render_contas_vas)