Ajustes conforme relatorio de testes 2026-08-27
This commit is contained in:
@@ -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"),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user