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: """Parse monetary values without assuming that every dot is a thousands separator. Accepted examples include Brazilian and API/JSON representations such as ``R$ 19,99``, ``19.99``, ``1.999,99`` and ``1,999.99``. """ if value is None: return None cleaned = str(value).strip().replace("R$", "").replace(" ", "") if not cleaned: return None # Keep only a numeric sign and decimal/grouping separators. This avoids # accidentally feeding currency labels or other text to Decimal. cleaned = re.sub(r"[^0-9, .+\-]", "", cleaned).replace(" ", "") if not cleaned: return None comma = cleaned.rfind(",") dot = cleaned.rfind(".") if comma >= 0 and dot >= 0: # The rightmost separator is the decimal separator; the other one is # grouping. This supports both 1.999,99 and 1,999.99. if comma > dot: cleaned = cleaned.replace(".", "").replace(",", ".") else: cleaned = cleaned.replace(",", "") elif comma >= 0: # pt-BR decimal notation. Multiple commas are treated conservatively # by preserving only the last one as decimal separator. if cleaned.count(",") > 1: head, tail = cleaned.rsplit(",", 1) cleaned = head.replace(",", "") + "." + tail else: cleaned = cleaned.replace(",", ".") elif dot >= 0: # A single dot followed by 1-2 digits is decimal notation (the form # normally returned by JSON/backends). For multiple dots, keep the # last one as decimal only when it looks like cents; otherwise treat # them as grouping separators. if cleaned.count(".") > 1: head, tail = cleaned.rsplit(".", 1) if 1 <= len(tail) <= 2: cleaned = head.replace(".", "") + "." + tail else: cleaned = cleaned.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, ) ) # When the same subject appears more than once on the invoice, the # requested amount is useful evidence for selecting the correct # occurrence. Prefer an exact amount match. For a partial adjustment # choose the smallest invoice occurrence that can cover the requested # amount. If none can, select the largest occurrence so the generic # ``validated > item_amount`` rule below rejects the request. matched_candidate = None if matching_candidates: requested_amount = validated if validated > 0 else claimed if requested_amount > 0 and len(matching_candidates) > 1: monetary_candidates = [ candidate for candidate in matching_candidates if isinstance(candidate.get("amount"), Decimal) and candidate.get("amount") > 0 ] exact_matches = [ candidate for candidate in monetary_candidates if _money(candidate["amount"]) == _money(requested_amount) ] if exact_matches: matched_candidate = exact_matches[0] else: sufficient = sorted( ( candidate for candidate in monetary_candidates if candidate["amount"] >= requested_amount ), key=lambda candidate: candidate["amount"], ) if sufficient: matched_candidate = sufficient[0] elif monetary_candidates: matched_candidate = max( monetary_candidates, key=lambda candidate: candidate["amount"], ) if matched_candidate is None: matched_candidate = matching_candidates[0] 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": "", "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