Ajustes conforme relatorio de testes 2026-08-27

This commit is contained in:
2026-08-29 09:53:32 -03:00
parent 0ecff719b7
commit 88e1f070d7
791 changed files with 27040 additions and 29038 deletions

View File

@@ -28,6 +28,14 @@ from app.domain.contas.invoice_resolver import InvoiceResolver
from app.domain.contas.invoice_context import InvoiceContextService
from app.domain.contas.item_matcher import SimilarityItemMatcher
from app.domain.contas.vas_cancellation_message import compose_vas_cancellation_message
from app.domain.contas.contestation_validation import validate_contestation_items
from contas_mcp.servers.contas_mcp_server.authorized_lines_service import AuthorizedLinesMockService
from contas_mcp.servers.contas_mcp_server.discount_history_service import DiscountHistoryMockService
from contas_mcp.servers.contas_mcp_server.line_policy import (
POLICY_NAME as LINE_POLICY_NAME,
POLICY_DESCRIPTION as LINE_POLICY_DESCRIPTION,
apply_line_policy,
)
app = FastAPI(title="TIM Contas MCP - Framework Native")
service = ContasDomainService()
@@ -35,6 +43,8 @@ invoice_resolver = InvoiceResolver(matcher=SimilarityItemMatcher())
settings = get_settings()
_workflow_runtime: WorkflowRuntime | None = None
_invoice_context_service: InvoiceContextService | None = None
authorized_lines_service = AuthorizedLinesMockService(PROJECT_ROOT / "app" / "domain" / "contas" / "fixtures" / "authorized_lines.json")
discount_history_service = DiscountHistoryMockService(PROJECT_ROOT / "app" / "domain" / "contas" / "fixtures" / "discount_history.json")
def get_invoice_context_service() -> InvoiceContextService:
@@ -74,14 +84,18 @@ TOOLS: dict[str, dict[str, Any]] = {
"consultar_faturas": {"description": "Consulta faturas do cliente, incluindo valor total quando disponível na fatura detalhada.", "input_schema": {"msisdn": "string"}},
"consultar_plano": {"description": "Consulta exclusivamente o plano ou planos contratados presentes na fatura.", "input_schema": {"msisdn": "string"}},
"invoice_explanation": {"description": "Executa o workflow de explicação de fatura com pause/resume pelo WorkflowRuntime do framework.", "input_schema": {"msisdn": "string"}},
"buscar_informacao": {"description": "Preserva a capability de conhecimento e delega a recuperação ao RAG do framework.", "input_schema": {"queries": "array"}},
"consultar_vas": {"description": "Consulta VAS ativos.", "input_schema": {"msisdn": "string"}},
"consultar_linhas_autorizadas": {"description": "Mock side-effect-free da integração que retorna as linhas autorizadas/relacionadas à identidade autenticada do atendimento.", "input_schema": {"msisdn": "string", "customer_key": "string", "contract_key": "string"}},
"consultar_historico_vas": {"description": "Consulta histórico de VAS.", "input_schema": {"msisdn": "string"}},
"consultar_historico_descontos": {"description": "Consulta o histórico autoritativo de descontos, incluindo status, valores, datas e causa explícita de término quando disponível.", "input_schema": {"msisdn": "string", "nome_plano": "string"}},
"cancelar_vas_avulso": {"description": "Executa workflow de cancelamento VAS após confirmação transacional do framework.", "input_schema": {"msisdn": "string", "subject": "string", "items": "array"}},
"tratar_vas_estrategico": {"description": "Executa workflow conversacional de VAS estratégico/bundle.", "input_schema": {"msisdn": "string", "subject": "string", "items": "array"}},
"validar_vas_subject": {"description": "Pré-valida se subject resolve para uma entidade VAS concreta sem efeitos colaterais.", "input_schema": {"msisdn": "string", "subject": "string", "target_tool": "string"}},
"validar_contestacao": {"description": "Pre-valida contestação sem executar efeitos transacionais.", "input_schema": {"msisdn": "string", "subject": "string", "valor": "number", "motivo": "string", "target_tool": "string"}},
"contestar_cobranca": {"description": "Executa workflow completo de contestação/Conta Certa.", "input_schema": {"msisdn": "string", "subject": "string", "valor": "number"}},
"pro_rata": {"description": "Executa workflow conversacional de pró-rata.", "input_schema": {"msisdn": "string", "planos": "array", "has_plano_controle": "boolean"}},
"termino_desconto": {"description": "Formata capability de término de desconto.", "input_schema": {"msisdn": "string", "nome_plano": "string"}},
"termino_desconto": {"description": "Analisa término/retirada de desconto com evidência de fatura/plano; não infere causa ausente.", "input_schema": {"msisdn": "string", "nome_plano": "string"}},
"valor_divergente": {"description": "Executa capability de valor divergente.", "input_schema": {"msisdn": "string"}},
"retomar_workflow": {"description": "Retoma um workflow pausado pelo mesmo execution_id.", "input_schema": {"workflow_name": "string", "execution_id": "string", "resposta_usuario": "string"}},
"consultar_status_solicitacao": {"description": "Consulta/atualiza status técnico de solicitação/protocolo TIM.", "input_schema": {"msisdn": "string", "protocol": "string"}},
@@ -318,10 +332,19 @@ def _preflight_subject(name: str, args: dict[str, Any]) -> dict[str, Any] | None
"success": False,
"error": f"O item '{subject}' existe na fatura, mas não pertence a uma categoria tratável por esta operação.",
}
if name in {"cancelar_vas_avulso", "tratar_vas_estrategico"} and not outcome.resolved:
return {
"status": "NEEDS_PARAMETER",
"success": False,
"parameter": "subject",
"reason": "subject_not_resolved",
"subject": subject,
"metadata": {"side_effect_free": True, "entity_resolution": "invoice_evidence"},
}
return None
async def _enrich_invoice_context(name: str, args: dict[str, Any]) -> None:
if name not in {"consultar_faturas", "consultar_plano", "invoice_explanation", "cancelar_vas_avulso", "tratar_vas_estrategico", "contestar_cobranca"}:
if name not in {"consultar_faturas", "consultar_plano", "invoice_explanation", "cancelar_vas_avulso", "tratar_vas_estrategico", "validar_vas_subject", "contestar_cobranca"}:
return
msisdn = str(args.get("msisdn") or "").strip()
if not msisdn:
@@ -330,13 +353,13 @@ async def _enrich_invoice_context(name: str, args: dict[str, Any]) -> None:
# consultar_faturas/invoice_explanation the semantic amount also matters, so
# a cached pair without detail must still be enriched.
has_base = isinstance(args.get("complete_invoices_payload"), dict) and isinstance(args.get("billing_analysis"), dict)
needs_detail = name in {"consultar_faturas", "invoice_explanation", "cancelar_vas_avulso", "contestar_cobranca"}
needs_detail = name in {"consultar_faturas", "invoice_explanation", "cancelar_vas_avulso", "validar_vas_subject", "contestar_cobranca"}
has_detail = isinstance(args.get("invoice_detail"), dict) or args.get("invoice_amount") not in (None, "")
if has_base and (not needs_detail or has_detail):
return
session_id = str(args.get("session_id") or args.get("original_session_id") or args.get("conversation_key") or "").strip()
invoice_id = str(args.get("invoice_id") or args.get("current_invoice_number") or "").strip()
include_detail = name in {"consultar_faturas", "invoice_explanation", "cancelar_vas_avulso", "contestar_cobranca"} and not isinstance(args.get("invoice_detail"), dict)
include_detail = name in {"consultar_faturas", "invoice_explanation", "cancelar_vas_avulso", "validar_vas_subject", "contestar_cobranca"} and not isinstance(args.get("invoice_detail"), dict)
try:
ctx = await get_invoice_context_service().get(
session_id=session_id,
@@ -408,6 +431,37 @@ def _result_payload(result: Any, *, workflow_name: str) -> dict[str, Any]:
"resume_tool": "retomar_workflow" if data.get("status") == "PAUSED" else None,
}
payload = {**data, "metadata": metadata}
# Generic workflow terminal contract. The MCP adapter promotes structural
# terminal signals from the actual last node without knowing the workflow
# business meaning. This lets the framework short-circuit composition for
# handoff/end-session workflows in any domain.
if data.get("status") == "COMPLETED" and isinstance(data.get("output"), dict):
terminal_output = data["output"].get(last_node) if last_node else None
if isinstance(terminal_output, dict):
session_control = str(terminal_output.get("session_control") or "").strip().upper()
terminal_status = str(terminal_output.get("terminal_status") or "").strip()
is_terminal = (
terminal_output.get("terminal") is True
or terminal_output.get("session_ended") is True
or terminal_output.get("handoff") is True
or bool(terminal_status)
or session_control in {"HUMAN_HANDOFF", "END_SESSION"}
)
if is_terminal:
for key in (
"mensagem", "user_message", "message", "session_control",
"human_handoff_requested", "handoff", "session_ended",
"terminal_status", "handoff_reason",
):
if key in terminal_output:
payload[key] = terminal_output[key]
payload["terminal"] = True
payload["terminal_action"] = (
"handoff" if terminal_output.get("handoff") is True or session_control == "HUMAN_HANDOFF"
else "end_session"
)
# Compatibilidade funcional do antigo backend, agora derivada apenas do
# branch determinístico do WorkflowRuntime do framework.
if data.get("status") == "COMPLETED":
@@ -415,6 +469,16 @@ def _result_payload(result: Any, *, workflow_name: str) -> dict[str, Any]:
if last_node == "registrar_protocolo_aceite":
payload["recomenda_finalizacao"] = True
payload["status_finalizacao_sugerido"] = "resolvido"
elif last_node == "handoff_pos_explicacao_nao":
terminal_output = (data.get("output") or {}).get(last_node) if isinstance(data.get("output"), dict) else {}
terminal_output = terminal_output if isinstance(terminal_output, dict) else {}
payload["mensagem"] = str(terminal_output.get("mensagem") or "Para continuar com a sua solicitação, aguarde um instante.")
payload["session_control"] = "HUMAN_HANDOFF"
payload["human_handoff_requested"] = True
payload["handoff"] = True
payload["session_ended"] = True
payload["terminal_status"] = "human_handoff"
payload["handoff_reason"] = str(terminal_output.get("handoff_reason") or "invoice_explanation_not_resolved")
elif last_node == "finalizar_nao_resolvido":
payload["recomenda_finalizacao"] = True
payload["status_finalizacao_sugerido"] = "nao_resolvido"
@@ -837,6 +901,44 @@ async def _run_cancelamento_com_contestacao(args: dict[str, Any]) -> dict[str, A
},
}
def _derive_pro_rata_plans(args: dict[str, Any]) -> list[dict[str, Any]]:
"""Deriva os dois planos contratuais do PDF parseado, sem LLM."""
detail = args.get("invoice_detail")
if not isinstance(detail, dict):
return [dict(x) for x in (args.get("planos") or []) if isinstance(x, dict)]
payload = detail.get("parsed_content") if isinstance(detail.get("parsed_content"), dict) else detail
candidates: list[dict[str, Any]] = []
for _key, section in payload.items() if isinstance(payload, dict) else []:
if not isinstance(section, dict):
continue
plans = section.get("Planos")
if not isinstance(plans, dict):
continue
# A visão por linha possui valores em dict; DANFE-COM possui listas e não
# deve ser confundido com os planos contratuais consolidados.
if not plans or not all(isinstance(v, dict) for v in plans.values()):
continue
for name, data in plans.items():
row = {"desc": str(name), **dict(data)}
candidates.append(row)
if candidates:
break
return candidates
def _prepare_pro_rata(args: dict[str, Any]) -> dict[str, Any] | None:
plans = _derive_pro_rata_plans(args)
args["planos"] = plans
args["has_plano_controle"] = any(bool(p.get("is_controle")) or "controle" in str(p.get("desc") or "").casefold() or "ctrl" in str(p.get("desc") or "").casefold() for p in plans)
if len(plans) != 2:
return {
"success": False,
"reason": "requires_exactly_two_plans",
"plans_found": len(plans),
}
return None
async def _validate_contestation(args: dict[str, Any]) -> dict[str, Any]:
"""Business-owned, side-effect-free eligibility validation for contestation."""
await _enrich_invoice_context("contestar_cobranca", args)
@@ -846,13 +948,325 @@ async def _validate_contestation(args: dict[str, Any]) -> dict[str, Any]:
if status == "NEEDS_CLARIFICATION":
return {**dict(preflight), "eligible": False}
return {**dict(preflight), "eligible": False}
invoice_payload = args.get("billing_analysis") if isinstance(args.get("billing_analysis"), dict) else {}
requested_value = args.get("valor") if args.get("valor") not in (None, "") else args.get("resolved_value")
canonical_items = [{
"item_name": str(args.get("resolved_subject") or args.get("subject") or "").strip(),
"claimed_amount": requested_value,
"validated_amount": requested_value,
}]
validated, validation_log, validation_error = validate_contestation_items(canonical_items, invoice_payload)
if validation_error:
# ``subject`` is an entity reference, not arbitrary free text. If CVAL
# proves that the extracted value does not resolve to any concrete item in
# the authoritative invoice evidence, request recollection of that single
# parameter instead of terminating the transaction. This intentionally
# avoids word blacklists: the evidence decides whether the reference is
# concrete. Other CVAL failures (amount/category/business rules) remain
# terminal and fail closed.
unresolved_subject = any(
str(entry.get("erro") or "") == "item_nao_encontrado_na_fatura"
for entry in (validation_log or [])
if isinstance(entry, dict)
)
if unresolved_subject:
return {
"eligible": False,
"status": "NEEDS_PARAMETER",
"parameter": "subject",
"reason": "subject_not_resolved",
"subject": args.get("resolved_subject") or args.get("subject"),
"resolved_value": requested_value,
"validation_log": validation_log,
"items": validated,
"metadata": {
"side_effect_free": True,
"target_tool": args.get("target_tool") or "contestar_cobranca",
"guardrail_code": "CVAL",
"entity_resolution": "invoice_evidence",
},
}
return {
"eligible": False,
"status": "BLOCKED",
"reason": "CVAL",
"error": validation_error,
"subject": args.get("resolved_subject") or args.get("subject"),
"resolved_value": requested_value,
"category": args.get("resolved_category"),
"validation_log": validation_log,
"items": validated,
"metadata": {"side_effect_free": True, "target_tool": args.get("target_tool") or "contestar_cobranca", "guardrail_code": "CVAL"},
}
return {
"eligible": True,
"status": "ELIGIBLE",
"subject": args.get("subject"),
"resolved_value": args.get("resolved_value") or args.get("valor"),
"subject": args.get("resolved_subject") or args.get("subject"),
"resolved_value": requested_value,
"category": args.get("resolved_category"),
"metadata": {"side_effect_free": True, "target_tool": args.get("target_tool") or "contestar_cobranca"},
"validation_log": validation_log,
"items": validated,
"metadata": {"side_effect_free": True, "target_tool": args.get("target_tool") or "contestar_cobranca", "guardrail_code": "CVAL"},
}
def _norm_entity_reference(value: Any) -> str:
return " ".join(_norm_invoice_name(value).split())
def _vas_entity_catalog(args: dict[str, Any]) -> list[dict[str, Any]]:
"""Build a deduplicated catalog of concrete VAS entities from authorized evidence."""
found: dict[str, dict[str, Any]] = {}
def add(name: Any, *, source: str, category: Any = None, cancelable: Any = None) -> None:
canonical = str(name or "").strip()
key = _norm_entity_reference(canonical)
if not key:
return
row = found.setdefault(key, {
"name": canonical,
"sources": [],
"category": str(category or "").strip(),
"cancelable": cancelable,
})
if source not in row["sources"]:
row["sources"].append(source)
if not row.get("category") and category:
row["category"] = str(category).strip()
if row.get("cancelable") is None and cancelable is not None:
row["cancelable"] = bool(cancelable)
# Active VAS is authoritative for currently provisioned products.
msisdn = str(args.get("msisdn") or "").strip()
if msisdn:
try:
current = service.client.consultar_vas(msisdn)
products = current.get("products") or current.get("services") or [] if isinstance(current, dict) else []
for item in products if isinstance(products, list) else []:
if not isinstance(item, dict):
continue
can = item.get("can") if isinstance(item.get("can"), dict) else {}
add(item.get("name") or item.get("description"), source="consultar_vas", category="vas_ativo", cancelable=can.get("cancel"))
except Exception:
# Invoice evidence below still gives a safe, side-effect-free resolver.
pass
# Billing/invoice evidence covers strategic/bundle items that may not be in the
# active-VAS endpoint representation.
for item in _invoice_subject_catalog(args):
category = str(item.get("category") or "")
cat_norm = _norm_entity_reference(category)
if any(token in cat_norm for token in (
"servicos contratados de parceiros", "streaming", "avulso", "estrategico", "bundle", "sva"
)):
add(item.get("name"), source="invoice_evidence", category=category, cancelable=item.get("contestable"))
return list(found.values())
def _resolve_catalog_entity(subject: str, catalog: list[dict[str, Any]]) -> tuple[str | None, list[dict[str, Any]]]:
needle = _norm_entity_reference(subject)
if not needle:
return None, []
exact = [item for item in catalog if _norm_entity_reference(item.get("name")) == needle]
if len(exact) == 1:
return str(exact[0].get("name") or subject), exact
partial = [
item for item in catalog
if needle in _norm_entity_reference(item.get("name"))
or _norm_entity_reference(item.get("name")) in needle
]
# Deduplication by canonical name prevents repeated charges of the same named
# service from becoming a false ambiguity at the entity-name level.
unique: dict[str, dict[str, Any]] = {_norm_entity_reference(i.get("name")): i for i in partial}
matches = list(unique.values())
if len(matches) == 1:
return str(matches[0].get("name") or subject), matches
return None, matches
def _vas_domain_policy_from_invoice_detail(canonical: str, args: dict[str, Any]) -> tuple[str, str]:
"""Resolve the business class from the detailed invoice evidence.
``billing_analysis`` is useful for entity discovery, but it can flatten the same
service into broad sections such as ``streaming`` or partner services. The
parsed invoice detail carries the domain-owned ``classe`` attribute used by
Contas (``avulso``, ``estrategico`` or ``bundle``), so it is the authoritative
source for choosing the business treatment after canonicalization.
Returns ``(tool_category, item_type)``. An empty pair means that the detail
does not provide an unambiguous classification and the specialized resolver
should be tried next.
"""
target = _norm_entity_reference(canonical)
if not target:
return "", ""
detail = args.get("invoice_detail")
if not isinstance(detail, dict):
return "", ""
parsed = detail.get("parsed_content") if isinstance(detail.get("parsed_content"), dict) else detail
if not isinstance(parsed, dict):
return "", ""
roots = list(parsed.values()) if parsed and all(isinstance(v, dict) for v in parsed.values()) else [parsed]
classes: set[str] = set()
for root in roots:
if not isinstance(root, dict):
continue
for section_name, section_value in root.items():
if not isinstance(section_value, list):
continue
for item in section_value:
if not isinstance(item, dict):
continue
name = item.get("desc") or item.get("name")
if _norm_entity_reference(name) != target:
continue
raw_class = str(item.get("classe") or "").strip().casefold()
if raw_class:
classes.add(raw_class)
elif _norm_entity_reference(section_name) == _norm_entity_reference("Streamings"):
classes.add("estrategico")
# Conflicting evidence must not silently change the action.
normalized = {
"estrategico" if c in {"estrategico", "estrategica", "strategic"} else
"bundle" if c == "bundle" else
"avulso" if c in {"avulso", "avulsa"} else c
for c in classes
}
normalized.discard("")
if len(normalized) != 1:
return "", ""
item_type = next(iter(normalized))
if item_type in {"estrategico", "bundle"}:
return "vas_estrategico", item_type
if item_type == "avulso":
return "cancelar_vas_avulso", item_type
return "", item_type
async def _validate_vas_subject(args: dict[str, Any]) -> dict[str, Any]:
"""Side-effect-free entity resolution used before confirmation/execution."""
await _enrich_invoice_context("validar_vas_subject", args)
subject = str(args.get("subject") or "").strip()
catalog = _vas_entity_catalog(args)
canonical, matches = _resolve_catalog_entity(subject, catalog)
if canonical:
requested_tool = str(args.get("target_tool") or "").strip()
effective_tool = requested_tool
resolved_class = ""
resolved_type = ""
# Domain-owned policy revalidation: canonicalization may reveal that the
# requested entity belongs to a different business treatment. The MCP
# validator decides that mapping; the framework only applies the generic
# transaction_decision contract returned below.
try:
# Prefer the detailed invoice because it preserves the Contas-owned
# ``classe`` attribute. This prevents a canonical entity such as
# Youtube Premium from being correctly resolved but then executed by
# the previously selected avulso tool.
resolved_class, resolved_type = _vas_domain_policy_from_invoice_detail(canonical, args)
# Compatibility/fallback for sources that do not expose parsed detail.
if not resolved_class:
evidence_candidates: list[dict[str, Any]] = []
detail = args.get("invoice_detail")
if isinstance(detail, dict):
parsed = detail.get("parsed_content") if isinstance(detail.get("parsed_content"), dict) else detail
if isinstance(parsed, dict):
evidence_candidates.append(parsed)
billing_analysis = args.get("billing_analysis")
if not isinstance(billing_analysis, dict):
billing_analysis = service.client.billing_analysis(str(args.get("msisdn") or ""))
if isinstance(billing_analysis, dict):
evidence_candidates.append(billing_analysis)
for evidence in evidence_candidates:
outcome = invoice_resolver.resolve([canonical], evidence)
if outcome and len(outcome.resolved) == 1:
resolved = outcome.resolved[0]
resolved_class = str(getattr(resolved, "tool_category", "") or "")
resolved_type = str(getattr(resolved, "item_type", "") or "")
break
if requested_tool == "cancelar_vas_avulso" and resolved_class == "vas_estrategico":
effective_tool = "tratar_vas_estrategico"
except Exception:
# Resolution already succeeded against authorized VAS evidence. If the
# domain classifier is unavailable, preserve the original action rather
# than guessing another business operation.
effective_tool = requested_tool
action_changed = bool(effective_tool and requested_tool and effective_tool != requested_tool)
confirmation_message = ""
if action_changed:
confirmation_message = (
f"Identifiquei o serviço {canonical}. Esse serviço possui tratamento específico. "
"Você deseja prosseguir? Responda 'sim' para continuar ou 'não' para cancelar."
)
return {
"eligible": True,
"status": "ELIGIBLE",
"subject": canonical,
"resolved_subject": canonical,
"entity_resolution": "vas_evidence",
"transaction_decision": {
"resolved_arguments": {"subject": canonical},
"target_tool": effective_tool or requested_tool,
"action_changed": action_changed,
"requires_reconfirmation": action_changed,
"confirmation_message": confirmation_message,
"domain_policy": {
"class": resolved_class or None,
"item_type": resolved_type or None,
},
},
"metadata": {
"side_effect_free": True,
"target_tool": requested_tool,
"effective_tool": effective_tool or requested_tool,
"catalog_size": len(catalog),
},
}
return {
"eligible": False,
"status": "NEEDS_PARAMETER",
"parameter": "subject",
"reason": "subject_not_resolved" if not matches else "subject_ambiguous",
"subject": subject,
"options": [str(item.get("name") or "") for item in matches if item.get("name")],
"entity_resolution": "vas_evidence",
"metadata": {"side_effect_free": True, "target_tool": args.get("target_tool"), "catalog_size": len(catalog)},
}
def _line_policy_result(name: str, args: dict[str, Any]) -> dict[str, Any] | None:
decision = apply_line_policy(name, args)
args["_line_policy_name"] = LINE_POLICY_NAME
args["_line_policy_authenticated_msisdn"] = str(args.get("msisdn") or "")
if decision.requested_reference:
args["_line_policy_requested_reference"] = decision.requested_reference
if decision.allowed:
if decision.effective_msisdn:
args["msisdn"] = decision.effective_msisdn
args["_line_policy_effective_msisdn"] = decision.effective_msisdn
return None
return {
"success": False,
"status": "LINE_POLICY_BLOCKED",
"terminal": True,
"terminal_action": "block",
"reason": decision.reason or "line_policy_blocked",
"message": decision.user_message,
"user_message": decision.user_message,
"metadata": {
"line_policy": LINE_POLICY_NAME,
"line_policy_description": LINE_POLICY_DESCRIPTION,
"side_effect_free": True,
"requested_line_reference": decision.requested_reference,
},
}
@@ -861,10 +1275,14 @@ async def _invoke(name: str, args: dict[str, Any]) -> Any:
"consultar_faturas": ("msisdn",),
"consultar_plano": ("msisdn",),
"invoice_explanation": ("msisdn",),
"buscar_informacao": (),
"consultar_vas": ("msisdn",),
"consultar_linhas_autorizadas": ("msisdn",),
"consultar_historico_vas": ("msisdn",),
"consultar_historico_descontos": ("msisdn",),
"cancelar_vas_avulso": ("msisdn", "subject"),
"tratar_vas_estrategico": ("msisdn", "subject"),
"validar_vas_subject": ("msisdn", "subject"),
"validar_contestacao": ("msisdn", "subject", "valor"),
"contestar_cobranca": ("msisdn", "subject", "valor"),
"pro_rata": ("msisdn",),
@@ -876,10 +1294,57 @@ async def _invoke(name: str, args: dict[str, Any]) -> Any:
}
_require(args, *requirements.get(name, ()))
if name == "buscar_informacao":
return service.buscar_informacao(queries=args.get("queries") or [])
if name == "consultar_linhas_autorizadas":
return authorized_lines_service.consultar_linhas_autorizadas(
authenticated_msisdn=str(args.get("msisdn") or ""),
customer_key=str(args.get("customer_key") or "") or None,
contract_key=str(args.get("contract_key") or "") or None,
)
# ALT2 consulta explicitamente a fonte autoritativa de linhas relacionadas.
# A referência falada pelo cliente nunca é usada para conceder autorização.
if LINE_POLICY_NAME == "authorized_related_lines" and isinstance(args.get("requested_line_reference"), dict):
args["authorized_lines_evidence"] = authorized_lines_service.consultar_linhas_autorizadas(
authenticated_msisdn=str(args.get("msisdn") or ""),
customer_key=str(args.get("customer_key") or "") or None,
contract_key=str(args.get("contract_key") or "") or None,
)
# Carrega evidência da conta usando a linha autenticada. A política ativa é
# aplicada somente depois disso: alt1 bloqueia qualquer outra linha; alt2
# pode resolver uma linha relacionada a partir dessa evidência autorizada.
await _enrich_invoice_context(name, args)
line_block = _line_policy_result(name, args)
if line_block is not None:
return _with_prefetch_events(line_block, args)
if name == "consultar_historico_descontos":
return discount_history_service.consultar_historico_descontos(
msisdn=str(args.get("msisdn") or ""),
customer_key=str(args.get("customer_key") or "") or None,
contract_key=str(args.get("contract_key") or "") or None,
nome_plano=str(args.get("nome_plano") or "") or None,
)
if name == "termino_desconto":
# A própria capability garante a consulta à fonte autoritativa; não
# depende de o LLM selecionar uma segunda tool para obter grounding.
args["discount_evidence"] = discount_history_service.consultar_historico_descontos(
msisdn=str(args.get("msisdn") or ""),
customer_key=str(args.get("customer_key") or "") or None,
contract_key=str(args.get("contract_key") or "") or None,
nome_plano=str(args.get("nome_plano") or "") or None,
)
if name == "validar_vas_subject":
return await _validate_vas_subject(args)
if name == "validar_contestacao":
return await _validate_contestation(args)
if name == "pro_rata":
prepared = _prepare_pro_rata(args)
if prepared is not None:
return prepared
await _enrich_invoice_context(name, args)
preflight = _preflight_subject(name, args)
if preflight is not None:
return _with_prefetch_events(preflight, args)
@@ -927,6 +1392,8 @@ async def health() -> dict[str, Any]:
"langgraph_direct_import": False,
"checkpoint_provider": getattr(settings, "CHECKPOINT_REPOSITORY_PROVIDER", "memory"),
"gateway_mode": "mock" if service.client.mock else "real",
"line_policy": LINE_POLICY_NAME,
"line_policy_description": LINE_POLICY_DESCRIPTION,
"tools": len(TOOLS),
"env_file": str(PROJECT_ROOT / ".env"),
}