new feature: External guardrails/judges

This commit is contained in:
2026-08-24 11:14:10 -03:00
parent 05373deff2
commit fd37138c4f
384 changed files with 40593 additions and 3621 deletions

View File

@@ -10,6 +10,7 @@ from typing import Any
from urllib.parse import quote
import requests
import yaml
from .integrations.secure_pdf_crypto import encrypt_secure_pdf_value
from .parsers import parse_tim_bill_pdf
@@ -23,6 +24,29 @@ class TimApiError(RuntimeError):
self.attempts = list(attempts or [])
_INTEGRATION_DEFAULTS_CACHE: dict[str, str] | None = None
def get_tim_integration_value(*names: str, default: str = "") -> str:
"""Env > config/tim_integration_defaults.yaml > default explícito."""
global _INTEGRATION_DEFAULTS_CACHE
for name in names:
value = os.getenv(name)
if value is not None and str(value).strip():
return str(value).strip()
if _INTEGRATION_DEFAULTS_CACHE is None:
path = Path(os.getenv("TIM_INTEGRATION_DEFAULTS_PATH", "./config/tim_integration_defaults.yaml"))
try:
raw = yaml.safe_load(path.read_text(encoding="utf-8")) if path.exists() else {}
except Exception as exc:
raise TimApiError(f"Falha ao ler defaults de integração TIM: {path}: {exc}") from exc
_INTEGRATION_DEFAULTS_CACHE = {str(k): str(v) for k, v in (raw or {}).items() if v not in (None, "")}
for name in names:
value = _INTEGRATION_DEFAULTS_CACHE.get(name)
if value is not None and str(value).strip():
return str(value).strip()
return default
class TimApiClient:
"""Thin TIM integration adapter.
@@ -31,9 +55,11 @@ class TimApiClient:
"""
FIXTURES = Path(__file__).with_name("fixtures")
INTEGRATION_DEFAULTS_PATH = Path(os.getenv("TIM_INTEGRATION_DEFAULTS_PATH", "./config/tim_integration_defaults.yaml"))
_integration_defaults_cache: dict[str, str] | None = None
def __init__(self) -> None:
self.mock = os.getenv("TIM_USE_MOCK_GATEWAY", "true").lower() in {"1", "true", "yes", "on"} or os.getenv("TIM_GATEWAY_MODE", "mock").lower() == "mock"
self.mock = os.getenv("TIM_USE_MOCK_GATEWAY", "false").lower() in {"1", "true", "yes", "on"} or os.getenv("TIM_GATEWAY_MODE", "real").lower() == "mock"
self.timeout = int(os.getenv("TIM_GATEWAY_DEFAULT_TIMEOUT", "30"))
self.max_retries = int(os.getenv("TIM_GATEWAY_RETRY_MAX_RETRIES", "3"))
self.backoff = float(os.getenv("TIM_GATEWAY_RETRY_BACKOFF_FACTOR", "0.5"))
@@ -42,14 +68,14 @@ class TimApiClient:
path = self.FIXTURES / f"{name}.json"
return json.loads(path.read_text(encoding="utf-8"))
@staticmethod
def _env_first(*names: str, default: str = "") -> str:
"""Retorna a primeira variável não vazia, preservando aliases do Contas original."""
for name in names:
value = os.getenv(name)
if value is not None and str(value).strip():
return str(value).strip()
return default
@classmethod
def _integration_defaults(cls) -> dict[str, str]:
get_tim_integration_value("__warmup__")
return dict(_INTEGRATION_DEFAULTS_CACHE or {})
@classmethod
def _env_first(cls, *names: str, default: str = "") -> str:
return get_tim_integration_value(*names, default=default)
@classmethod
def _auth_value(
@@ -155,7 +181,7 @@ class TimApiClient:
"POST", url, payload={"msisdn": msisdn},
headers=self._headers(
client_id=None, auth=self._env_first("TIM_COMPLETE_INVOICES_AUTH"),
extra={"ClientID": self._env_first("TIM_COMPLETE_INVOICES_CLIENT_ID", default="AIAGENTCR")},
extra={"ClientID": self._env_first("TIM_COMPLETE_INVOICES_CLIENT_ID", )},
),
timeout=int(self._env_first("TIM_COMPLETE_INVOICES_TIMEOUT", default="30")),
)
@@ -171,9 +197,9 @@ class TimApiClient:
password_names=("TIM_DIVERGENCIA_PASSWORD", "TIM_DIVERGENCIA_PASS", "TIM_DIVERGENCE_PASSWORD", "TIM_DIVERGENCE_PASS"),
)
headers = self._headers(client_id=None, auth=auth, extra={
"clientID": self._env_first("TIM_DIVERGENCIA_CLIENT_ID", default="AIAGENTCR"),
"clientID": self._env_first("TIM_DIVERGENCIA_CLIENT_ID", ),
})
params = {"channel": context.get("channel") or "AIAGENTCR"}
params = {"channel": context.get("channel") or self._env_first("TIM_DIVERGENCIA_CHANNEL")}
attempts: list[dict[str, Any]] = []
try:
result = self.request("GET", url, params=params, headers=headers, timeout=int(self._env_first("TIM_DIVERGENCIA_TIMEOUT", "TIM_DIVERGENCE_TIMEOUT", default="120")), attempt_log=attempts)
@@ -192,7 +218,7 @@ class TimApiClient:
"GET", url,
headers=self._headers(
client_id=None, auth=self._env_first("TIM_QUERY_AUTH", "TIM_CONSULTA_AUTH"),
extra={"clientId": self._env_first("TIM_CONSULTA_CLIENT_ID", default="AIAAGENTCR")},
extra={"clientId": self._env_first("TIM_CONSULTA_CLIENT_ID", )},
),
timeout=int(self._env_first("TIM_QUERY_TIMEOUT", "TIM_CONSULTA_TIMEOUT", default="30")),
)
@@ -207,7 +233,7 @@ class TimApiClient:
client_id=None,
auth=os.getenv("TIM_VAS_HISTORY_AUTH", ""),
extra={
"clientId": self._env_first("TIM_VAS_HISTORY_CLIENT_ID", default="AIAGENTCR"),
"clientId": self._env_first("TIM_VAS_HISTORY_CLIENT_ID", ),
"messageId": os.getenv("TIM_VAS_HISTORY_MESSAGE_ID", "") or str(uuid.uuid4()),
},
)
@@ -220,7 +246,7 @@ class TimApiClient:
digits = "".join(ch for ch in str(msisdn) if ch.isdigit())
normalized = digits[2:] if len(digits) == 13 else digits
app_id = str(service.get("appId") or service.get("app_id") or "")
csp_id = str(service.get("cspId") or service.get("csp_id") or os.getenv("TIM_DEFAULT_CSP_ID", "740"))
csp_id = str(service.get("cspId") or service.get("csp_id") or self._env_first("TIM_DEFAULT_CSP_ID"))
op = self._env_first("TIM_BLOCK_OPERATION_TYPE", "TIM_BLOQUEIO_OPERATION_TYPE", default="block")
base = {"Customer": {"Msisdn": normalized}, "AppId": app_id, "CspId": csp_id, "TypeOperation": op}
payloads = [
@@ -237,7 +263,7 @@ class TimApiClient:
auth=self._env_first("TIM_BLOCK_AUTH", "TIM_BLOQUEIO_AUTH"),
extra={
"Accept-Encoding": self._env_first("TIM_BLOCK_ACCEPT_ENCODING", "TIM_BLOQUEIO_ACCEPT_ENCODING", default="gzip,deflate"),
"clientId": self._env_first("TIM_BLOCK_CLIENT_ID", "TIM_BLOQUEIO_CLIENT_ID", default="AIAGENTCR"),
"clientId": self._env_first("TIM_BLOCK_CLIENT_ID", "TIM_BLOQUEIO_CLIENT_ID", ),
"messageId": str(uuid.uuid4()),
},
)
@@ -256,17 +282,17 @@ class TimApiClient:
return self.fixture("cancel_vas")
url = self._env_first("TIM_CANCELLATION_URL", "TIM_CANCELAMENTO_URL")
payload = {
"channel": "AIAGENTCR",
"channel": self._env_first("TIM_CANCELAMENTO_CHANNEL"),
"msisdn": msisdn,
"appId": str(service.get("appId") or service.get("app_id") or ""),
"cspId": str(service.get("cspId") or service.get("csp_id") or os.getenv("TIM_DEFAULT_CSP_ID", "740")),
"cspId": str(service.get("cspId") or service.get("csp_id") or self._env_first("TIM_DEFAULT_CSP_ID")),
"interactionProtocol": protocol,
}
headers = self._headers(
client_id=None,
auth=self._env_first("TIM_CANCELLATION_AUTH", "TIM_CANCELAMENTO_AUTH"),
extra={
"clientId": os.getenv("TIM_CANCELAMENTO_CLIENT_ID", os.getenv("TIM_DEFAULT_CLIENT_ID", "AIAGENTCR")),
"clientId": os.getenv("TIM_CANCELAMENTO_CLIENT_ID", self._env_first("TIM_DEFAULT_CLIENT_ID")),
"messageId": str(uuid.uuid4()),
"AuthorizationOAM": self._env_first("TIM_CANCELLATION_AUTH_OAM", "TIM_CANCELAMENTO_AUTH_OAM", "TIM_CANCELAMENTO_AUTHORIZATION_OAM"),
"Cn_field": self._env_first("TIM_CANCELLATION_CN_FIELD", "TIM_CANCELAMENTO_CN_FIELD"),
@@ -284,7 +310,7 @@ class TimApiClient:
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_CONTRATO_AUTH", ""),
extra={"clientId": self._env_first("TIM_CONTRATO_CLIENT_ID", default="AIAGENTCR")},
extra={"clientId": self._env_first("TIM_CONTRATO_CLIENT_ID", )},
)
return self.request("GET", f"{base}/{quote(msisdn)}", headers=headers, timeout=int(os.getenv("TIM_CONTRATO_TIMEOUT", "30")))
@@ -296,7 +322,7 @@ class TimApiClient:
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_PROFILE_FULL_AUTH", ""),
extra={"ClientID": os.getenv("TIM_PROFILE_FULL_CLIENT_ID", "AIAGENTCR")},
extra={"ClientID": self._env_first("TIM_PROFILE_FULL_CLIENT_ID")},
)
return self.request("GET", url, headers=headers, timeout=int(self._env_first("TIM_PROFILE_FULL_TIMEOUT", default="30")))
@@ -321,7 +347,7 @@ class TimApiClient:
)
body = {
"socialSecNo": str(data.get("socialSecNo") or data.get("social_sec_no") or ""),
"channel": "AIAGENTCR",
"channel": self._env_first("TIM_PROTOCOL_CHANNEL"),
"customerId": str(data.get("customerId") or data.get("customer_id") or ""),
"assetId": str(data.get("assetId") or data.get("asset_id") or msisdn),
"customerName": str(data.get("customerName") or data.get("customer_name") or ""),
@@ -340,7 +366,7 @@ class TimApiClient:
"interaction": {
"protocol": str(data.get("interactionProtocol") or data.get("interaction_protocol") or ""),
"flagSms": True,
"source": "AIAGENTCR",
"source": self._env_first("TIM_DEFAULT_SOURCE"),
"callId": str(data.get("interactionCallId") or data.get("interaction_call_id") or ""),
"crmSource": "Siebel Pós",
"reasonId": str(data.get("interactionReasonId") or data.get("interaction_reason_id") or ""),
@@ -355,7 +381,7 @@ class TimApiClient:
client_id=None,
auth=str(data.get("Authorization") or os.getenv("TIM_PROTOCOL_AUTH", "")),
extra={
"clientId": str(data.get("clientId") or data.get("client_id") or os.getenv("TIM_PROTOCOL_CLIENT_ID", "BFFDIGITAL")),
"clientId": str(data.get("clientId") or data.get("client_id") or self._env_first("TIM_PROTOCOL_CLIENT_ID")),
"messageId": message_id,
"Authorizationoam": str(data.get("authorization_oam") or os.getenv("TIM_PROTOCOL_AUTHORIZATION_OAM", "")),
"Cn_field": str(data.get("cn_field") or os.getenv("TIM_PROTOCOL_CN_FIELD", "")),
@@ -378,7 +404,7 @@ class TimApiClient:
if self.mock:
return self.fixture("contestacao_tool")
data = dict(payload)
data.setdefault("userId", self._env_first("TIM_CUSTOMER_CONTESTATION_USER_ID", default="AIAGENTCR"))
data.setdefault("userId", self._env_first("TIM_CUSTOMER_CONTESTATION_USER_ID", ))
data.setdefault("customerIdCurrent", data.get("customerId") or "")
data.setdefault("customerType", "2")
data.setdefault("customerStatus", "1")
@@ -401,9 +427,9 @@ class TimApiClient:
data.setdefault("refundOption", "0")
data.setdefault("doubleRefund", False)
data.setdefault("manualContaCertaIndicator", False)
client_id = str(data.pop("clientId", "") or os.getenv("TIM_CUSTOMER_CONTESTATION_CLIENT_ID", "AIAGENTCR"))
client_id = str(data.pop("clientId", "") or self._env_first("TIM_CUSTOMER_CONTESTATION_CLIENT_ID"))
message_id = str(data.pop("messageId", "") or uuid.uuid4())
user_id = str(data.get("userId") or "AIAGENTCR").upper()
user_id = str(data.get("userId") or self._env_first("TIM_DEFAULT_CLIENT_ID")).upper()
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_CUSTOMER_CONTESTATION_AUTH", ""),
@@ -428,7 +454,7 @@ class TimApiClient:
}
for key in ("reason1", "reason2", "reason3", "auditChecks", "attachments"):
if data.get(key): service_request[key] = data[key]
body = {"channel": data.get("channel") or "AIAGENTCR", "serviceRequest": service_request}
body = {"channel": data.get("channel") or self._env_first("TIM_DEFAULT_CLIENT_ID"), "serviceRequest": service_request}
for key in ("msisdn", "customers", "date"):
if data.get(key): body[key] = data[key]
message_id = str(data.get("messageId") or data.get("ura_call_id") or data.get("session_id") or uuid.uuid4())
@@ -436,7 +462,7 @@ class TimApiClient:
client_id=None,
auth=os.getenv("TIM_SERVICE_REQUEST_STATUS_AUTH", ""),
extra={
"clientId": str(data.get("clientId") or os.getenv("TIM_SERVICE_REQUEST_STATUS_CLIENT_ID", "AIAGENTCR")),
"clientId": str(data.get("clientId") or self._env_first("TIM_SERVICE_REQUEST_STATUS_CLIENT_ID")),
"messageId": message_id,
"Authorizationoam": data.get("authorization_oam") or os.getenv("TIM_SERVICE_REQUEST_STATUS_AUTHORIZATION_OAM", ""),
"Cn_field": data.get("cn_field") or os.getenv("TIM_SERVICE_REQUEST_STATUS_CN_FIELD", ""),
@@ -463,7 +489,7 @@ class TimApiClient:
"totalAmount": data.get("invoiceTotalAmount") or "",
}
body = {
"channel": data.get("channel") or os.getenv("TIM_TRACKING_ACTIVITIES_CHANNEL", "AIAGENTCR"),
"channel": data.get("channel") or self._env_first("TIM_TRACKING_ACTIVITIES_CHANNEL"),
"customer": {"socialSecNo": data.get("socialSecNo") or "", "msisdn": data.get("msisdn") or ""},
"protocolNumber": data.get("protocolNumber") or "",
"invoice": invoice,
@@ -472,12 +498,12 @@ class TimApiClient:
"status": data.get("activityStatus") or "",
"id": data.get("activityId") or "",
},
"user": {"login": data.get("userLogin") or os.getenv("TIM_TRACKING_ACTIVITIES_USER_LOGIN", "SIEBELPOS_INBOUND")},
"user": {"login": data.get("userLogin") or self._env_first("TIM_TRACKING_ACTIVITIES_USER_LOGIN")},
}
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_TRACKING_ACTIVITIES_AUTH", ""),
extra={"clientId": data.get("clientId") or os.getenv("TIM_TRACKING_ACTIVITIES_CLIENT_ID", "AIAGENTCR")},
extra={"clientId": data.get("clientId") or self._env_first("TIM_TRACKING_ACTIVITIES_CLIENT_ID")},
)
return self.request("POST", os.getenv("TIM_TRACKING_ACTIVITIES_URL", ""), payload=body, headers=headers, timeout=int(os.getenv("TIM_TRACKING_ACTIVITIES_TIMEOUT", "30")))
@@ -487,7 +513,7 @@ class TimApiClient:
payload: dict[str, Any] = {
"msisdn": msisdn,
"senderAddress": context.get("sender_address") or os.getenv("TIM_SMS_SENDER_ADDRESS", "324"),
"senderName": context.get("sender_name") or os.getenv("TIM_SMS_SENDER_NAME", "TIM Brasil"),
"senderName": context.get("sender_name") or self._env_first("TIM_SMS_SENDER_NAME"),
"message": message,
"longURL": context.get("long_url") or message,
}
@@ -496,7 +522,7 @@ class TimApiClient:
headers = self._headers(
client_id=None,
auth=os.getenv("TIM_SMS_AUTH", ""),
extra={"clientId": os.getenv("TIM_SMS_CLIENT_ID", "AIAGENTCR")},
extra={"clientId": self._env_first("TIM_SMS_CLIENT_ID")},
)
attempts: list[dict[str, Any]] = []
result = self.request("POST", os.getenv("TIM_SMS_URL", ""), payload=payload, headers=headers, timeout=int(os.getenv("TIM_SMS_TIMEOUT", "30")), attempt_log=attempts)
@@ -514,7 +540,7 @@ class TimApiClient:
headers=self._headers(
client_id=None,
auth=self._env_first("TIM_COMPLETE_INVOICES_AUTH", "TIM_PROFILE_BILL_AUTH"),
extra={"ClientID": self._env_first("TIM_COMPLETE_INVOICES_CLIENT_ID", "TIM_PROFILE_BILL_CLIENT_ID", default="AIAGENTCR")},
extra={"ClientID": self._env_first("TIM_COMPLETE_INVOICES_CLIENT_ID", "TIM_PROFILE_BILL_CLIENT_ID", )},
),
timeout=int(self._env_first("TIM_COMPLETE_INVOICES_TIMEOUT", "TIM_PROFILE_BILL_TIMEOUT", default="30")),
)
@@ -531,7 +557,7 @@ class TimApiClient:
headers=self._headers(
client_id=None,
auth=os.getenv("TIM_PROFILE_FULL_AUTH", ""),
extra={"ClientID": os.getenv("TIM_PROFILE_FULL_CLIENT_ID", "AIAGENTCR")},
extra={"ClientID": self._env_first("TIM_PROFILE_FULL_CLIENT_ID")},
),
timeout=int(self._env_first("TIM_PROFILE_FULL_TIMEOUT", default="30")),
)
@@ -581,7 +607,7 @@ class TimApiClient:
client_id=None,
auth=self._env_first("TIM_BILL_PDF_AUTH", "TIM_SECURE_PDF_AUTH", "TIM_INVOICE_RECOVER_AUTH"),
extra={
"clientId": self._env_first("TIM_BILL_PDF_CLIENT_ID", "TIM_SECURE_PDF_CLIENT_ID", default="AIAGENTCR"),
"clientId": self._env_first("TIM_BILL_PDF_CLIENT_ID", "TIM_SECURE_PDF_CLIENT_ID", ),
"Accept": "application/pdf",
},
)
@@ -621,7 +647,7 @@ class TimApiClient:
headers=self._headers(
client_id=None,
auth=self._env_first("TIM_SECURE_PDF_AUTH", "TIM_BILL_PDF_AUTH", "TIM_INVOICE_RECOVER_AUTH"),
extra={"clientid": self._env_first("TIM_INVOICE_RECOVER_CLIENT_ID", "TIM_SECURE_PDF_CLIENT_ID", default="AIAGENTCR")},
extra={"clientid": self._env_first("TIM_INVOICE_RECOVER_CLIENT_ID", "TIM_SECURE_PDF_CLIENT_ID", )},
),
timeout=int(os.getenv("TIM_INVOICE_RECOVER_TIMEOUT", "30")),
)

View File

@@ -0,0 +1,576 @@
from __future__ import annotations
from contextlib import nullcontext
from decimal import Decimal, ROUND_HALF_UP
import logging
import os
import re
import unicodedata as ud
from typing import Any
_CENT = Decimal("0.01")
_GUARDRAIL_ACTION = "abrir_contestacao_cliente"
_GUARDRAIL_CODE = "CVAL"
_STRATEGIC_SERVICE_ALIASES = (
"apple music",
"deezer",
"disney",
"fuze",
"forge",
"hbo",
"looke",
"netflix",
"paramount",
"paramount+",
"paramount plus",
"tim cloud gaming",
"youtube",
"youtube premium",
)
logger = logging.getLogger(__name__)
def _money(value: Decimal) -> Decimal:
return value.quantize(_CENT, rounding=ROUND_HALF_UP)
def _parse_amount(value: str) -> Decimal | None:
if not value:
return None
cleaned = (
str(value)
.replace("R$", "")
.replace(" ", "")
.replace(".", "")
.replace(",", ".")
)
try:
return Decimal(cleaned)
except Exception:
return None
def _decimal_from_any(value: Any) -> Decimal | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, Decimal):
return value
if isinstance(value, (int, float)):
return Decimal(str(value))
return _parse_amount(str(value or ""))
def _first_decimal_from_mapping(data: dict[str, Any], *keys: str) -> Decimal | None:
for key in keys:
if key not in data:
continue
value = _decimal_from_any(data.get(key))
if value is not None:
return value
return None
def _normalize_number_text(value: Any, *, default: str = "0") -> str:
text = str(value).strip()
if not text:
return default
cleaned = text.replace("R$", "").replace(" ", "")
if "," in cleaned:
cleaned = cleaned.replace(".", "").replace(",", ".")
try:
normalized = format(Decimal(cleaned), "f")
except Exception:
return default
if "." in normalized:
normalized = normalized.rstrip("0").rstrip(".")
return normalized or default
def _normalize_match_text(value: Any) -> str:
text = re.sub(r"\s*\([^)]*\)", "", str(value or "")).strip()
text = ud.normalize("NFKD", text)
text = "".join(ch for ch in text if not ud.combining(ch))
text = text.casefold()
text = re.sub(r"[^a-z0-9]+", " ", text)
return re.sub(r"\s+", " ", text).strip()
def _is_same_plan_name(left: Any, right: Any) -> bool:
left_key = _normalize_match_text(left)
right_key = _normalize_match_text(right)
if not left_key or not right_key:
return False
return left_key == right_key or left_key in right_key or right_key in left_key
def _normalize_service_name_for_match(value: Any) -> str:
normalized = ud.normalize("NFKD", str(value or "").lower())
without_accents = "".join(ch for ch in normalized if not ud.combining(ch))
return re.sub(r"[^a-z0-9]+", "", without_accents)
def _is_strategic_partner_service(value: Any) -> bool:
normalized = _normalize_service_name_for_match(value)
if not normalized:
return False
for alias in _STRATEGIC_SERVICE_ALIASES:
normalized_alias = _normalize_service_name_for_match(alias)
if normalized_alias and normalized_alias in normalized:
return True
return False
def _is_vas_section_name(section_name: str) -> bool:
normalized = _normalize_match_text(section_name)
return (
"vas" in normalized
or "valor adicionado" in normalized
or "servicos de valor adicionado" in normalized
or "servicos valor adicionado" in normalized
or "sva detalhe total" in normalized
or "servicos contratados de parceiros" in normalized
or "servico contratado de parceiro" in normalized
)
def _extract_invoice_total_geral(payload: Any) -> Decimal | None:
if isinstance(payload, dict):
desc = _normalize_match_text(payload.get("desc", ""))
if desc == "total geral":
total = _decimal_from_any(
payload.get("value")
if "value" in payload
else payload.get("valor")
)
if total is not None:
return total
for value in payload.values():
if isinstance(value, (dict, list, tuple)):
result = _extract_invoice_total_geral(value)
if result is not None:
return result
elif isinstance(payload, (list, tuple)):
for entry in payload:
if isinstance(entry, (dict, list, tuple)):
result = _extract_invoice_total_geral(entry)
if result is not None:
return result
return None
def _extract_contestation_invoice_items(
payload: Any,
*,
section_name: str = "",
) -> list[dict[str, Any]]:
found: list[dict[str, Any]] = []
if isinstance(payload, dict):
candidate_name = str(
payload.get("desc")
or payload.get("name")
or payload.get("service_name")
or payload.get("item_name")
or payload.get("itemName")
or payload.get("servico")
or ""
).strip()
candidate_amount = _first_decimal_from_mapping(
payload,
"valor_final",
"valor",
"price",
"amount",
"value",
"valor_bruto",
"claimedAmount",
"validatedAmount",
)
if candidate_name and candidate_amount is not None and candidate_amount > 0:
payload_type = str(payload.get("type") or payload.get("tipo") or "").strip()
payload_desc = str(payload.get("desc") or "").strip()
classe = str(payload.get("classe", "")).strip().lower()
is_vas = (
_is_vas_section_name(section_name)
or _is_vas_section_name(payload_type)
or classe in {"avulso", "estrategico"}
)
found.append(
{
"name": candidate_name,
"amount": _money(candidate_amount),
"is_vas": is_vas,
"section": section_name,
"source_type": payload_type,
"source_desc": payload_desc,
"classe": classe,
"estrategico": bool(payload.get("estrategico")),
"verb": str(payload.get("verb", "")).strip().lower(),
}
)
for key, value in payload.items():
next_section = section_name
if isinstance(key, str) and _is_vas_section_name(key):
next_section = key
if isinstance(value, (dict, list, tuple)):
found.extend(
_extract_contestation_invoice_items(
value,
section_name=next_section,
)
)
return found
if isinstance(payload, (list, tuple)):
for item in payload:
if isinstance(item, (dict, list, tuple)):
found.extend(
_extract_contestation_invoice_items(
item,
section_name=section_name,
)
)
return found
def _has_langfuse_credentials() -> bool:
return bool(
os.getenv("LANGFUSE_PUBLIC_KEY", "").strip()
and os.getenv("LANGFUSE_SECRET_KEY", "").strip()
)
def _start_guardrail_observation(
*,
name: str,
input: dict[str, Any] | None = None,
metadata: dict[str, Any] | None = None,
) -> Any:
if not _has_langfuse_credentials():
return nullcontext(None)
try:
from langfuse import get_client
return get_client().start_as_current_observation(
name=name,
as_type="span",
input=input,
metadata=metadata,
)
except Exception:
logger.debug(
"langfuse.contestation_guardrail_start_failed name=%s",
name,
exc_info=True,
)
return nullcontext(None)
def _summarize_requested_items(items: list[dict[str, Any]]) -> list[dict[str, str]]:
summary: list[dict[str, str]] = []
for item in items:
summary.append(
{
"item_name": str(item.get("item_name", "") or "").strip(),
"claimed_amount": _normalize_number_text(
item.get("claimed_amount", "0")
),
"validated_amount": _normalize_number_text(
item.get("validated_amount", "0")
),
}
)
return summary
def _validation_reason(validation_log: list[dict[str, Any]]) -> str:
for entry in validation_log:
reason = entry.get("erro")
if reason:
return str(reason).strip()
return ""
def _emit_contestation_validation_block_span(
*,
items: list[dict[str, Any]],
candidates: list[dict[str, Any]],
validation_log: list[dict[str, Any]],
validation_error: str,
) -> None:
reason = _validation_reason(validation_log)
approved_count = sum(
1 for entry in validation_log if entry.get("status") == "aprovado"
)
rejected_count = sum(
1 for entry in validation_log if entry.get("status") == "reprovado"
)
try:
with _start_guardrail_observation(
name=f"guardrail.{_GUARDRAIL_CODE}.blocked",
input={
"items_count": len(items),
"items": _summarize_requested_items(items),
"invoice_candidates_count": len(candidates),
},
metadata={
"mechanism": "guardrail_action_validation",
"code": _GUARDRAIL_CODE,
"action": _GUARDRAIL_ACTION,
"reason": reason,
},
) as obs:
if obs is None:
return
obs.update(
level="WARNING",
output={
"blocked": True,
"error": validation_error,
"items_validated_count": len(validation_log),
"items_approved_count": approved_count,
"items_rejected_count": rejected_count,
"validation_log": validation_log,
"code": _GUARDRAIL_CODE,
},
)
except Exception:
logger.debug(
"langfuse.contestation_guardrail_update_failed code=%s",
_GUARDRAIL_CODE,
exc_info=True,
)
def validate_contestation_items(
items: list[dict[str, Any]],
invoice_payload: dict[str, Any],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], str | None]:
candidates = _extract_contestation_invoice_items(invoice_payload)
validation_log: list[dict[str, Any]] = []
with _start_guardrail_observation(
name=f"guardrail.{_GUARDRAIL_CODE}.evaluated",
input={
"items_count": len(items),
"items": _summarize_requested_items(items),
"invoice_candidates_count": len(candidates),
},
metadata={
"mechanism": "guardrail_action_validation",
"code": _GUARDRAIL_CODE,
"action": _GUARDRAIL_ACTION,
},
) as obs:
def _safe_update(**kwargs: Any) -> None:
if obs is None:
return
try:
obs.update(**kwargs)
except Exception:
logger.debug(
"langfuse.contestation_guardrail_update_failed code=%s",
_GUARDRAIL_CODE,
exc_info=True,
)
first_error: str | None = None
def _record_failure(
item_log: dict[str, Any],
erro: str,
message: str,
) -> None:
nonlocal first_error
item_log["status"] = "reprovado"
item_log["erro"] = erro
validation_log.append(item_log)
if first_error is None:
first_error = message
for item in items:
claimed = Decimal(_normalize_number_text(item.get("claimed_amount", "0")))
validated = Decimal(
_normalize_number_text(item.get("validated_amount", "0"))
)
item_name = str(item.get("item_name", "")).strip()
if not item_name:
continue
item_log: dict[str, Any] = {
"item_name": item_name,
"item_na_fatura": False,
"item_confirmado": False,
"secao_vas": False,
"valor_item_fatura": "",
"valor_ajuste_solicitado": _normalize_number_text(
format(validated, "f")
),
"valor_ajuste_valido": False,
"vas_estrategico": False,
"status": "em_validacao",
}
matching_candidates = [
candidate
for candidate in candidates
if _is_same_plan_name(candidate.get("name", ""), item_name)
]
# A mesma cobrança pode aparecer em múltiplas visões da fatura.
# Prefira a evidência que traz classificação explícita de VAS em vez
# de aceitar a primeira ocorrência genérica e concluir incorretamente
# que o item está fora da seção VAS.
matching_candidates.sort(
key=lambda candidate: (
0 if (
str(candidate.get("classe", "")).strip().lower() in {"avulso", "estrategico"}
or bool(candidate.get("is_vas"))
) else 1,
0 if _normalize_match_text(candidate.get("name", "")) == _normalize_match_text(item_name) else 1,
)
)
matched_candidate = matching_candidates[0] if matching_candidates else None
if matched_candidate is None:
_record_failure(
item_log,
"item_nao_encontrado_na_fatura",
f"Item '{item_name}' nao encontrado no json da fatura.",
)
continue
item_log["item_na_fatura"] = True
item_log["item_confirmado"] = True
item_log["item_fatura_resolvido"] = str(matched_candidate.get("name", "") or "")
item_log["secao_fatura"] = str(matched_candidate.get("section", "") or "")
item_log["tipo_fatura"] = str(matched_candidate.get("source_type", "") or "")
classe = str(matched_candidate.get("classe", "")).strip().lower()
is_strategic = (
classe == "estrategico"
or bool(matched_candidate.get("estrategico"))
or _is_strategic_partner_service(item_name)
)
is_vas_avulso = classe == "avulso" or (
not classe
and not is_strategic
and bool(matched_candidate.get("is_vas"))
)
if not (is_vas_avulso or is_strategic):
_record_failure(
item_log,
"item_fora_secao_vas",
f"Item '{item_name}' nao e do tipo VAS no json da fatura.",
)
continue
item_log["secao_vas"] = True
item_amount = matched_candidate.get("amount")
if not isinstance(item_amount, Decimal) or item_amount <= 0:
_record_failure(
item_log,
"valor_item_invalido_na_fatura",
f"Nao foi possivel validar o valor do item '{item_name}' na fatura.",
)
continue
item_log["valor_item_fatura"] = _normalize_number_text(
format(item_amount, "f")
)
if is_strategic:
item_log["vas_estrategico"] = True
_record_failure(
item_log,
"vas_estrategico_nao_permitido",
f"Item '{item_name}' identificado como VAS estrategico e nao pode ser ajustado.",
)
continue
if claimed <= 0:
claimed = item_amount
if validated <= 0:
validated = claimed
if validated > item_amount:
_record_failure(
item_log,
"valor_ajuste_maior_que_item",
f"Valor de ajuste do item '{item_name}' excede o valor cobrado na fatura.",
)
continue
item_log["valor_ajuste_solicitado"] = _normalize_number_text(
format(validated, "f")
)
item_log["valor_ajuste_valido"] = True
item_log["status"] = "aprovado"
validation_log.append(item_log)
item["claimed_amount"] = _normalize_number_text(format(claimed, "f"))
item["validated_amount"] = _normalize_number_text(format(validated, "f"))
invoice_total = _extract_invoice_total_geral(invoice_payload)
if invoice_total is not None and invoice_total > 0:
total_ajustes = sum(
(
Decimal(
_normalize_number_text(entry.get("valor_ajuste_solicitado", "0"))
)
for entry in validation_log
if entry.get("status") == "aprovado"
),
Decimal("0"),
)
if total_ajustes > invoice_total:
total_log: dict[str, Any] = {
"item_name": "<total_ajustes>",
"status": "reprovado",
"erro": "total_ajustes_excede_fatura",
"valor_total_ajustes": _normalize_number_text(
format(_money(total_ajustes), "f")
),
"valor_total_fatura": _normalize_number_text(
format(_money(invoice_total), "f")
),
}
validation_log.append(total_log)
if first_error is None:
first_error = (
"Valor total de ajustes ("
f"{total_log['valor_total_ajustes']}) excede o "
f"valor total da fatura ({total_log['valor_total_fatura']})."
)
approved_count = sum(
1 for entry in validation_log if entry.get("status") == "aprovado"
)
rejected_count = sum(
1 for entry in validation_log if entry.get("status") == "reprovado"
)
if first_error is not None:
_emit_contestation_validation_block_span(
items=items,
candidates=candidates,
validation_log=validation_log,
validation_error=first_error,
)
_safe_update(
level="WARNING",
output={
"approved": False,
"items_count": len(items),
"items_validated_count": len(validation_log),
"items_approved_count": approved_count,
"items_rejected_count": rejected_count,
"validation_log": validation_log,
"error": first_error,
"reason": _validation_reason(validation_log),
},
)
return items, validation_log, first_error
_safe_update(
output={
"approved": True,
"items_count": len(items),
"items_validated_count": len(validation_log),
"items_approved_count": approved_count,
"items_rejected_count": rejected_count,
"validation_log": validation_log,
},
)
return items, validation_log, None

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
import os
from typing import Any
from .client import TimApiClient
@@ -225,7 +227,7 @@ class ContasDomainService:
"socialSecNo": customer.get("document") or args.get("social_sec_no") or "",
"customerId": customer.get("customerId") or customer.get("id") or args.get("customer_id") or "",
"invoiceNumber": args.get("invoice_id") or "",
"userId": "AIAGENTCR",
"userId": os.getenv("TIM_DEFAULT_CLIENT_ID"),
"items": [{"itemName": subject, "itemType": "VAS_AVULSO", "claimedAmount": str(valor), "validatedAmount": str(valor)}],
"description": motivo,
}
@@ -234,7 +236,7 @@ class ContasDomainService:
return {"success": True, "protocol": protocol, "contestation": contestation, "tracking": tracking, "invoices": invoices, "contract": contract, "profile": profile}
def consultar_status_solicitacao(self, *, msisdn: str = "", protocol: str = "", **_: Any) -> Any:
return self.client.status_sr({"msisdn": msisdn, "protocolNumber": protocol, "status": "CONSULTA", "channel": "AIAGENTCR"})
return self.client.status_sr({"msisdn": msisdn, "protocolNumber": protocol, "status": "CONSULTA", "channel": os.getenv("TIM_DEFAULT_CHANNEL")})
def enviar_sms(self, *, msisdn: str, message: str, **_: Any) -> Any:
return self.client.sms(msisdn, message)
@@ -295,11 +297,11 @@ class ContasDomainService:
response["service_request_status"] = self.client.status_sr({
"protocolNumber": protocol,
"status": "Fechado",
"channel": "AIAGENTCR",
"channel": os.getenv("TIM_DEFAULT_CHANNEL"),
"notes": normalized_summary,
})
return response
def os_source(args: dict[str, Any]) -> str:
return str(args.get("channel") or "AIAGENTCR")
return str(args.get("channel") or os.getenv("TIM_DEFAULT_CHANNEL"))

View File

@@ -13,7 +13,8 @@ from typing import Any
from agent_framework.workflows import WorkflowActionRegistry
from agent_framework.idempotency import InMemoryIdempotencyStore
from agent_framework.guardrails.calibrated import validate_contestation_items
from app.domain.contas.contestation_validation import validate_contestation_items
from app.domain.contas.client import get_tim_integration_value
from .client import TimApiError
from .contestation_rules import (
@@ -798,9 +799,9 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
"refundOption": str(params.get("refund_option") or ""),
"manualContaCertaIndicator": bool(params.get("manual_conta_certa_indicator", False)),
"doubleRefund": bool(params.get("double_refund", False)),
"userId": str(params.get("user_id") or "AIAGENTCR"),
"userId": str(params.get("user_id") or os.getenv("TIM_DEFAULT_CLIENT_ID")),
"messageId": str(params.get("message_id") or _first(params, state, "message_id") or ""),
"clientId": str(params.get("client_id") or "AIAGENTCR"),
"clientId": str(params.get("client_id") or os.getenv("TIM_DEFAULT_CLIENT_ID")),
"tipo_atendimento": "pro_rata",
"skip_invoice_item_validation": True,
"items": items,
@@ -1227,9 +1228,9 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
"doubleRefund": bool(params.get("double_refund")),
"description": params.get("descricao") or "",
"items": items,
"userId": params.get("user_id") or "AIAGENTCR",
"userId": params.get("user_id") or os.getenv("TIM_DEFAULT_CLIENT_ID"),
"messageId": params.get("message_id") or "",
"clientId": params.get("client_id") or "AIAGENTCR",
"clientId": params.get("client_id") or os.getenv("TIM_DEFAULT_CLIENT_ID"),
}
# CVAL é capability do framework: antes do side effect, valida que os
# itens existem na fatura, pertencem a VAS, não são estratégicos e que o
@@ -1357,8 +1358,8 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
try:
if hasattr(service, "client") and hasattr(service.client, "sms"):
result = service.client.sms(
msisdn, message, sender_name="TIM Brasil", long_url="https://meutim.com.br",
notify_url=os.getenv("TIM_SMS_NOTIFY_URL", "http://10.114.200.57:9003/teste"),
msisdn, message, sender_name=get_tim_integration_value("TIM_SMS_SENDER_NAME"), long_url=get_tim_integration_value("TIM_SMS_LONG_URL"),
notify_url=get_tim_integration_value("TIM_SMS_NOTIFY_URL"),
)
else:
result = service.enviar_sms(msisdn=msisdn, message=message)
@@ -1447,8 +1448,8 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
status = str(params.get("status") or "Fechado")
status_payload = {
"msisdn": msisdn, "socialSecNo": re.sub(r"\D", "", str(params.get("social_sec_no") or "")),
"protocolNumber": protocol_id, "status": status, "channel": "AIAGENTCR",
"clientId": params.get("client_id") or "AIAGENTCR",
"protocolNumber": protocol_id, "status": status, "channel": os.getenv("TIM_DEFAULT_CHANNEL"),
"clientId": params.get("client_id") or os.getenv("TIM_DEFAULT_CLIENT_ID"),
"reason1": "Reclamação", "reason2": "Conta", "reason3": "Valor", "notes": "",
}
try: