Ajustes conforme relatorio de testes 2026-08-27
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AuthorizedLinesMockService:
|
||||
"""Mock da integração que informa quais linhas podem ser operadas.
|
||||
|
||||
A linha digitada/falada pelo cliente nunca concede autorização. A consulta
|
||||
parte da identidade autenticada do atendimento e retorna somente linhas que
|
||||
um backend de identidade/conta teria previamente relacionado ao cliente.
|
||||
"""
|
||||
|
||||
def __init__(self, fixture_path: Path) -> None:
|
||||
self.fixture_path = Path(fixture_path)
|
||||
|
||||
@staticmethod
|
||||
def _digits(value: Any) -> str:
|
||||
return "".join(ch for ch in str(value or "") if ch.isdigit())
|
||||
|
||||
def _load(self) -> dict[str, Any]:
|
||||
with self.fixture_path.open("r", encoding="utf-8") as fh:
|
||||
payload = json.load(fh)
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
def consultar_linhas_autorizadas(
|
||||
self,
|
||||
*,
|
||||
authenticated_msisdn: str,
|
||||
customer_key: str | None = None,
|
||||
contract_key: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
authenticated = self._digits(authenticated_msisdn)
|
||||
payload = self._load()
|
||||
accounts = payload.get("accounts") if isinstance(payload.get("accounts"), list) else []
|
||||
|
||||
account: dict[str, Any] | None = None
|
||||
for candidate in accounts:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
fixture_msisdn = self._digits(candidate.get("authenticated_msisdn"))
|
||||
if authenticated and fixture_msisdn == authenticated:
|
||||
account = candidate
|
||||
break
|
||||
|
||||
if account is None:
|
||||
return {
|
||||
"success": False,
|
||||
"status": "AUTHORIZED_LINES_NOT_FOUND",
|
||||
"source": "mock",
|
||||
"authenticated_msisdn": authenticated,
|
||||
"authorized_lines": [],
|
||||
"authorized_msisdns": [authenticated] if authenticated else [],
|
||||
"reason": "authenticated_line_not_found_in_mock",
|
||||
"metadata": {"side_effect_free": True, "fixture": self.fixture_path.name},
|
||||
}
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for item in account.get("authorized_lines") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
msisdn = self._digits(item.get("msisdn"))
|
||||
if not msisdn or msisdn in seen:
|
||||
continue
|
||||
seen.add(msisdn)
|
||||
row = dict(item)
|
||||
row["msisdn"] = msisdn
|
||||
rows.append(row)
|
||||
|
||||
if authenticated and authenticated not in seen:
|
||||
rows.insert(0, {
|
||||
"msisdn": authenticated,
|
||||
"relationship": "authenticated",
|
||||
"status": "ACTIVE",
|
||||
"authorized": True,
|
||||
})
|
||||
|
||||
authorized = [
|
||||
row["msisdn"]
|
||||
for row in rows
|
||||
if row.get("authorized", True) is True
|
||||
and str(row.get("status") or "ACTIVE").upper() == "ACTIVE"
|
||||
]
|
||||
return {
|
||||
"success": True,
|
||||
"status": "SUCCESS",
|
||||
"source": "mock",
|
||||
"authenticated_msisdn": authenticated,
|
||||
"customer_key": customer_key or account.get("customer_key"),
|
||||
"contract_key": contract_key or account.get("contract_key"),
|
||||
"authorized_lines": rows,
|
||||
"authorized_msisdns": authorized,
|
||||
"metadata": {"side_effect_free": True, "fixture": self.fixture_path.name},
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class DiscountHistoryMockService:
|
||||
"""Mock do sistema de registro/histórico de descontos do cliente.
|
||||
|
||||
O serviço retorna fatos estruturados. A camada conversacional é responsável
|
||||
por transformar esses fatos em linguagem natural; o mock não devolve texto
|
||||
pronto para o cliente.
|
||||
"""
|
||||
|
||||
def __init__(self, fixture_path: Path) -> None:
|
||||
self.fixture_path = Path(fixture_path)
|
||||
|
||||
@staticmethod
|
||||
def _digits(value: Any) -> str:
|
||||
return "".join(ch for ch in str(value or "") if ch.isdigit())
|
||||
|
||||
def _load(self) -> dict[str, Any]:
|
||||
with self.fixture_path.open("r", encoding="utf-8") as fh:
|
||||
payload = json.load(fh)
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
|
||||
def consultar_historico_descontos(
|
||||
self,
|
||||
*,
|
||||
msisdn: str,
|
||||
customer_key: str | None = None,
|
||||
contract_key: str | None = None,
|
||||
nome_plano: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
line = self._digits(msisdn)
|
||||
payload = self._load()
|
||||
accounts = payload.get("accounts") if isinstance(payload.get("accounts"), list) else []
|
||||
|
||||
account: dict[str, Any] | None = None
|
||||
for candidate in accounts:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
if self._digits(candidate.get("msisdn")) == line:
|
||||
account = candidate
|
||||
break
|
||||
|
||||
if account is None:
|
||||
return {
|
||||
"success": False,
|
||||
"status": "DISCOUNT_HISTORY_NOT_FOUND",
|
||||
"source": "mock",
|
||||
"msisdn": line,
|
||||
"discounts": [],
|
||||
"metadata": {"side_effect_free": True, "fixture": self.fixture_path.name},
|
||||
}
|
||||
|
||||
requested_plan = str(nome_plano or "").strip().casefold()
|
||||
discounts: list[dict[str, Any]] = []
|
||||
for item in account.get("discounts") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
plan_name = str(item.get("plan_name") or "").strip()
|
||||
if requested_plan and requested_plan not in plan_name.casefold():
|
||||
continue
|
||||
discounts.append(dict(item))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"status": "SUCCESS",
|
||||
"source": "mock",
|
||||
"msisdn": line,
|
||||
"customer_key": customer_key or account.get("customer_key"),
|
||||
"contract_key": contract_key or account.get("contract_key"),
|
||||
"as_of_date": account.get("as_of_date"),
|
||||
"last_invoice_issue_date": account.get("last_invoice_issue_date"),
|
||||
"last_billed_period": account.get("last_billed_period"),
|
||||
"discounts": discounts,
|
||||
"metadata": {
|
||||
"side_effect_free": True,
|
||||
"fixture": self.fixture_path.name,
|
||||
"authoritative_for": [
|
||||
"discount_status",
|
||||
"termination_reason",
|
||||
"discount_dates",
|
||||
"discount_values",
|
||||
"contract_as_of_date",
|
||||
"last_billed_discount_reference",
|
||||
],
|
||||
"temporal_semantics": {
|
||||
"current_value": "contract_value_as_of_date",
|
||||
"last_billed_discount_value": "discount_applied_in_last_billed_period",
|
||||
},
|
||||
},
|
||||
}
|
||||
51
contas_mcp/servers/contas_mcp_server/line_policy.py
Normal file
51
contas_mcp/servers/contas_mcp_server/line_policy.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
POLICY_NAME = "authenticated_line_only"
|
||||
POLICY_DESCRIPTION = "Somente a linha identificada/autenticada na chamada pode ser consultada ou alterada."
|
||||
|
||||
|
||||
def _digits(value: Any) -> str:
|
||||
return "".join(ch for ch in str(value or "") if ch.isdigit())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LinePolicyDecision:
|
||||
allowed: bool
|
||||
effective_msisdn: str
|
||||
reason: str = ""
|
||||
user_message: str = ""
|
||||
requested_reference: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def apply_line_policy(tool_name: str, args: dict[str, Any]) -> LinePolicyDecision:
|
||||
authenticated = _digits(args.get("msisdn"))
|
||||
reference = args.get("requested_line_reference")
|
||||
if not authenticated or not isinstance(reference, dict):
|
||||
return LinePolicyDecision(True, authenticated or str(args.get("msisdn") or ""))
|
||||
|
||||
kind = str(reference.get("kind") or "").strip().lower()
|
||||
requested = _digits(reference.get("value"))
|
||||
same_line = False
|
||||
if kind == "full" and requested:
|
||||
same_line = requested == authenticated or requested.endswith(authenticated) or authenticated.endswith(requested)
|
||||
elif kind == "suffix" and requested:
|
||||
same_line = authenticated.endswith(requested)
|
||||
else:
|
||||
return LinePolicyDecision(True, authenticated)
|
||||
|
||||
if same_line:
|
||||
return LinePolicyDecision(True, authenticated, requested_reference=reference)
|
||||
|
||||
return LinePolicyDecision(
|
||||
False,
|
||||
authenticated,
|
||||
reason="other_line_not_allowed",
|
||||
user_message=(
|
||||
"Por segurança, este atendimento só permite consultar ou realizar operações "
|
||||
"na linha identificada na chamada. Não posso usar outra linha informada na conversa."
|
||||
),
|
||||
requested_reference=reference,
|
||||
)
|
||||
51
contas_mcp/servers/contas_mcp_server/line_policy_alt1.py
Normal file
51
contas_mcp/servers/contas_mcp_server/line_policy_alt1.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
POLICY_NAME = "authenticated_line_only"
|
||||
POLICY_DESCRIPTION = "Somente a linha identificada/autenticada na chamada pode ser consultada ou alterada."
|
||||
|
||||
|
||||
def _digits(value: Any) -> str:
|
||||
return "".join(ch for ch in str(value or "") if ch.isdigit())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LinePolicyDecision:
|
||||
allowed: bool
|
||||
effective_msisdn: str
|
||||
reason: str = ""
|
||||
user_message: str = ""
|
||||
requested_reference: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def apply_line_policy(tool_name: str, args: dict[str, Any]) -> LinePolicyDecision:
|
||||
authenticated = _digits(args.get("msisdn"))
|
||||
reference = args.get("requested_line_reference")
|
||||
if not authenticated or not isinstance(reference, dict):
|
||||
return LinePolicyDecision(True, authenticated or str(args.get("msisdn") or ""))
|
||||
|
||||
kind = str(reference.get("kind") or "").strip().lower()
|
||||
requested = _digits(reference.get("value"))
|
||||
same_line = False
|
||||
if kind == "full" and requested:
|
||||
same_line = requested == authenticated or requested.endswith(authenticated) or authenticated.endswith(requested)
|
||||
elif kind == "suffix" and requested:
|
||||
same_line = authenticated.endswith(requested)
|
||||
else:
|
||||
return LinePolicyDecision(True, authenticated)
|
||||
|
||||
if same_line:
|
||||
return LinePolicyDecision(True, authenticated, requested_reference=reference)
|
||||
|
||||
return LinePolicyDecision(
|
||||
False,
|
||||
authenticated,
|
||||
reason="other_line_not_allowed",
|
||||
user_message=(
|
||||
"Por segurança, este atendimento só permite consultar ou realizar operações "
|
||||
"na linha identificada na chamada. Não posso usar outra linha informada na conversa."
|
||||
),
|
||||
requested_reference=reference,
|
||||
)
|
||||
93
contas_mcp/servers/contas_mcp_server/line_policy_alt2.py
Normal file
93
contas_mcp/servers/contas_mcp_server/line_policy_alt2.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
POLICY_NAME = "authorized_related_lines"
|
||||
POLICY_DESCRIPTION = (
|
||||
"Permite outra linha somente quando ela é resolvida univocamente entre "
|
||||
"linhas devolvidas pelo serviço autorizado consultar_linhas_autorizadas."
|
||||
)
|
||||
|
||||
|
||||
def _digits(value: Any) -> str:
|
||||
return "".join(ch for ch in str(value or "") if ch.isdigit())
|
||||
|
||||
|
||||
def _looks_like_msisdn(value: Any) -> bool:
|
||||
size = len(_digits(value))
|
||||
return 10 <= size <= 13
|
||||
|
||||
|
||||
def _authorized_lines(args: dict[str, Any], authenticated: str) -> list[str]:
|
||||
"""Retorna apenas linhas autorizadas pela integração explícita.
|
||||
|
||||
Não colhe MSISDNs de billing/invoice/LLM. A presença de uma linha em uma
|
||||
fatura não equivale, por si só, a autorização operacional.
|
||||
"""
|
||||
found: set[str] = {authenticated} if authenticated else set()
|
||||
evidence = args.get("authorized_lines_evidence")
|
||||
if not isinstance(evidence, dict) or evidence.get("success") is not True:
|
||||
return sorted(found)
|
||||
|
||||
for value in evidence.get("authorized_msisdns") or []:
|
||||
candidate = _digits(value)
|
||||
if _looks_like_msisdn(candidate):
|
||||
found.add(candidate)
|
||||
|
||||
for item in evidence.get("authorized_lines") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("authorized", True) is not True:
|
||||
continue
|
||||
if str(item.get("status") or "ACTIVE").upper() != "ACTIVE":
|
||||
continue
|
||||
candidate = _digits(item.get("msisdn"))
|
||||
if _looks_like_msisdn(candidate):
|
||||
found.add(candidate)
|
||||
return sorted(found)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LinePolicyDecision:
|
||||
allowed: bool
|
||||
effective_msisdn: str
|
||||
reason: str = ""
|
||||
user_message: str = ""
|
||||
requested_reference: dict[str, Any] | None = None
|
||||
authorized_lines: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def apply_line_policy(tool_name: str, args: dict[str, Any]) -> LinePolicyDecision:
|
||||
authenticated = _digits(args.get("msisdn"))
|
||||
reference = args.get("requested_line_reference")
|
||||
authorized = _authorized_lines(args, authenticated)
|
||||
if not authenticated or not isinstance(reference, dict):
|
||||
return LinePolicyDecision(True, authenticated or str(args.get("msisdn") or ""), authorized_lines=tuple(authorized))
|
||||
|
||||
kind = str(reference.get("kind") or "").strip().lower()
|
||||
requested = _digits(reference.get("value"))
|
||||
if kind == "full" and requested:
|
||||
matches = [line for line in authorized if line == requested or line.endswith(requested) or requested.endswith(line)]
|
||||
elif kind == "suffix" and requested:
|
||||
matches = [line for line in authorized if line.endswith(requested)]
|
||||
else:
|
||||
return LinePolicyDecision(True, authenticated, requested_reference=reference, authorized_lines=tuple(authorized))
|
||||
|
||||
unique = sorted(set(matches))
|
||||
if len(unique) == 1:
|
||||
return LinePolicyDecision(True, unique[0], requested_reference=reference, authorized_lines=tuple(authorized))
|
||||
if len(unique) > 1:
|
||||
return LinePolicyDecision(
|
||||
False, authenticated, reason="other_line_ambiguous",
|
||||
user_message="Encontrei mais de uma linha autorizada compatível com a referência informada. Confirme qual linha deseja usar.",
|
||||
requested_reference=reference, authorized_lines=tuple(authorized),
|
||||
)
|
||||
return LinePolicyDecision(
|
||||
False, authenticated, reason="other_line_not_authorized_or_not_resolved",
|
||||
user_message=(
|
||||
"Não consegui confirmar a linha informada entre as linhas autorizadas deste atendimento. "
|
||||
"Confirme a linha ou utilize a linha identificada na chamada."
|
||||
),
|
||||
requested_reference=reference, authorized_lines=tuple(authorized),
|
||||
)
|
||||
@@ -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