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.
Binary file not shown.
BIN
app/domain/contas/__pycache__/line_reference.cpython-313.pyc
Normal file
BIN
app/domain/contas/__pycache__/line_reference.cpython-313.pyc
Normal file
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.
@@ -402,7 +402,44 @@ class TimApiClient:
|
||||
|
||||
def contestar(self, payload: dict[str, Any]) -> Any:
|
||||
if self.mock:
|
||||
return self.fixture("contestacao_tool")
|
||||
# The mock must behave like the real provider for the current request.
|
||||
# Returning the whole static fixture leaked unrelated invoice items into
|
||||
# a one-item transaction and also exposed contradictory fixture fields.
|
||||
fixture = self.fixture("contestacao_tool")
|
||||
provider = fixture.get("body") if isinstance(fixture, dict) and isinstance(fixture.get("body"), dict) else fixture
|
||||
provider = dict(provider or {})
|
||||
requested = [x for x in (payload.get("items") or []) if isinstance(x, dict)]
|
||||
fixture_rows = [x for x in (provider.get("itemsResponse") or provider.get("items_response") or []) if isinstance(x, dict)]
|
||||
|
||||
def norm(value: Any) -> str:
|
||||
import unicodedata
|
||||
text = unicodedata.normalize("NFKD", str(value or "").casefold())
|
||||
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
||||
return " ".join("".join(ch if ch.isalnum() else " " for ch in text).split())
|
||||
|
||||
selected: list[dict[str, Any]] = []
|
||||
for req in requested:
|
||||
name = str(req.get("itemName") or req.get("item_name") or req.get("name") or "").strip()
|
||||
row = next((dict(x) for x in fixture_rows if norm(x.get("itemName") or x.get("item_name")) == norm(name)), None)
|
||||
if row is None:
|
||||
row = {
|
||||
"correctAccountStatus": "NAO_CRIAR",
|
||||
"itemName": name,
|
||||
"message": "Item não existe na fatura com o valor informado",
|
||||
"status": "NAO_INICIADA",
|
||||
}
|
||||
selected.append(row)
|
||||
if requested:
|
||||
provider["itemsResponse"] = selected
|
||||
provider["sr"] = str(payload.get("sr") or provider.get("sr") or "")
|
||||
# Keep useful provider response fields, but never return the fixture's
|
||||
# stale top-level normalized/result/protocol fields as if they came
|
||||
# from the remote API.
|
||||
if isinstance(fixture, dict):
|
||||
for key in ("barcode", "codigo_boleto", "contestation_id", "contestationId", "manualContaCertaIndicator"):
|
||||
if key in fixture and key not in provider:
|
||||
provider[key] = fixture[key]
|
||||
return provider
|
||||
data = dict(payload)
|
||||
data.setdefault("userId", self._env_first("TIM_CUSTOMER_CONTESTATION_USER_ID", ))
|
||||
data.setdefault("customerIdCurrent", data.get("customerId") or "")
|
||||
|
||||
@@ -36,15 +36,54 @@ def _money(value: Decimal) -> Decimal:
|
||||
|
||||
|
||||
def _parse_amount(value: str) -> Decimal | None:
|
||||
if not value:
|
||||
"""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)
|
||||
.replace("R$", "")
|
||||
.replace(" ", "")
|
||||
.replace(".", "")
|
||||
.replace(",", ".")
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -427,7 +466,48 @@ def validate_contestation_items(
|
||||
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
|
||||
|
||||
# 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,
|
||||
|
||||
23
app/domain/contas/fixtures/authorized_lines.json
Normal file
23
app/domain/contas/fixtures/authorized_lines.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"accounts": [
|
||||
{
|
||||
"authenticated_msisdn": "11999999999",
|
||||
"customer_key": "11999999999",
|
||||
"contract_key": "3000131180",
|
||||
"authorized_lines": [
|
||||
{
|
||||
"msisdn": "11999999999",
|
||||
"relationship": "titular",
|
||||
"status": "ACTIVE",
|
||||
"authorized": true
|
||||
},
|
||||
{
|
||||
"msisdn": "11988884321",
|
||||
"relationship": "dependente",
|
||||
"status": "ACTIVE",
|
||||
"authorized": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
52
app/domain/contas/fixtures/discount_history.json
Normal file
52
app/domain/contas/fixtures/discount_history.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"accounts": [
|
||||
{
|
||||
"msisdn": "11999999999",
|
||||
"customer_key": "11999999999",
|
||||
"contract_key": "3000131180",
|
||||
"discounts": [
|
||||
{
|
||||
"discount_id": "DESC-FIDEL-80-BLACK",
|
||||
"discount_name": "Desc Fidel 80 TIM Black A compartilhado 8.0",
|
||||
"discount_type": "FIDELITY",
|
||||
"plan_name": "TIM Black A 8.0",
|
||||
"previous_value": 80.0,
|
||||
"current_value": 0.0,
|
||||
"start_date": "2024-11-20",
|
||||
"end_date": "2025-11-20",
|
||||
"discount_status": "EXPIRED",
|
||||
"termination_reason": "FIM_PERIODO_FIDELIDADE",
|
||||
"termination_reason_description": "O período de fidelidade contratado foi encerrado.",
|
||||
"current_value_reference": "contract_as_of_date",
|
||||
"as_of_date": "2025-11-20",
|
||||
"last_billed_discount_value": 80.0,
|
||||
"last_billed_period": "14/10 a 13/11",
|
||||
"last_invoice_issue_date": "2025-11-20",
|
||||
"last_billed_status": "APPLIED"
|
||||
},
|
||||
{
|
||||
"discount_id": "DESC-FIDEL-33-CTRL",
|
||||
"discount_name": "Desc Fidel 33 TIM CTRL Redes Sociais 8.0",
|
||||
"discount_type": "FIDELITY",
|
||||
"plan_name": "TIM CTRL Redes Sociais 8.0",
|
||||
"previous_value": 33.0,
|
||||
"current_value": 33.0,
|
||||
"start_date": "2025-04-20",
|
||||
"end_date": "2026-04-20",
|
||||
"discount_status": "ACTIVE",
|
||||
"termination_reason": null,
|
||||
"termination_reason_description": null,
|
||||
"current_value_reference": "contract_as_of_date",
|
||||
"as_of_date": "2025-11-20",
|
||||
"last_billed_discount_value": 33.0,
|
||||
"last_billed_period": "14/10 a 13/11",
|
||||
"last_invoice_issue_date": "2025-11-20",
|
||||
"last_billed_status": "APPLIED"
|
||||
}
|
||||
],
|
||||
"as_of_date": "2025-11-20",
|
||||
"last_invoice_issue_date": "2025-11-20",
|
||||
"last_billed_period": "14/10 a 13/11"
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
55
app/domain/contas/line_reference.py
Normal file
55
app/domain/contas/line_reference.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
_DIGIT_WORDS = {
|
||||
"zero": "0", "um": "1", "uma": "1", "dois": "2", "duas": "2",
|
||||
"tres": "3", "quatro": "4", "cinco": "5", "seis": "6", "sete": "7",
|
||||
"oito": "8", "nove": "9",
|
||||
}
|
||||
|
||||
|
||||
def _norm(text: Any) -> str:
|
||||
value = unicodedata.normalize("NFKD", str(text or "").casefold())
|
||||
value = "".join(ch for ch in value if not unicodedata.combining(ch))
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
|
||||
def extract_requested_line_reference(text: Any) -> dict[str, str] | None:
|
||||
"""Extrai somente uma referência explícita de linha citada pelo usuário.
|
||||
|
||||
Não transforma a referência em identidade autorizada. Essa decisão pertence à
|
||||
política de linha ativa no domínio Contas.
|
||||
"""
|
||||
raw = str(text or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
norm = _norm(raw)
|
||||
|
||||
# Número completo explicitamente presente no texto (10 a 13 dígitos, com
|
||||
# separadores opcionais). Evita capturar valores monetários ou protocolos curtos.
|
||||
for match in re.finditer(r"(?<!\d)(?:\+?\d[\s().-]*){10,13}(?!\d)", raw):
|
||||
digits = "".join(ch for ch in match.group(0) if ch.isdigit())
|
||||
if 10 <= len(digits) <= 13:
|
||||
return {"kind": "full", "value": digits, "raw": match.group(0).strip()}
|
||||
|
||||
# Referência por final da linha: "final 4321", "final quatro três dois um",
|
||||
# "termina em 4321". Só a referência é extraída; nunca é assumida como MSISDN.
|
||||
marker = re.search(r"\b(?:final|termina(?:ndo)?\s+em|terminado\s+em)\b(.{0,45})", norm)
|
||||
if marker:
|
||||
tail = marker.group(1)
|
||||
numeric = re.search(r"\b(\d{4})\b", tail)
|
||||
if numeric:
|
||||
return {"kind": "suffix", "value": numeric.group(1), "raw": numeric.group(1)}
|
||||
tokens = re.findall(r"[a-z]+", tail)
|
||||
digits: list[str] = []
|
||||
for token in tokens:
|
||||
if token in _DIGIT_WORDS:
|
||||
digits.append(_DIGIT_WORDS[token])
|
||||
if len(digits) == 4:
|
||||
return {"kind": "suffix", "value": "".join(digits), "raw": " ".join(tokens[:4])}
|
||||
elif digits:
|
||||
break
|
||||
return None
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -114,9 +114,26 @@ class ContasDomainService:
|
||||
return {
|
||||
"complete_invoices": complete,
|
||||
"billing_analysis": billing,
|
||||
"invoice_detail": args.get("invoice_detail") if isinstance(args.get("invoice_detail"), dict) else {},
|
||||
"invoice_amount": args.get("invoice_amount"),
|
||||
"invoice_period": args.get("invoice_period"),
|
||||
"invoice_emissao": args.get("invoice_emissao"),
|
||||
"instruction": "Use estes dados como evidência para explicar composição/variação da fatura. Não invente cobranças ausentes.",
|
||||
}
|
||||
|
||||
def buscar_informacao(self, *, queries: list[str] | None = None, **_: Any) -> dict[str, Any]:
|
||||
"""Preserva a capability legada sem reimplementar RAG no domínio.
|
||||
|
||||
O agente/framework é o dono da recuperação. Esta tool apenas converte o
|
||||
pedido legado em um contrato explícito de RAG, mantendo paridade de API.
|
||||
"""
|
||||
normalized = [str(q).strip() for q in (queries or []) if str(q).strip()]
|
||||
return {
|
||||
"requires_rag": True,
|
||||
"source": "agent_framework.rag",
|
||||
"rag_queries": normalized,
|
||||
}
|
||||
|
||||
def consultar_vas(self, *, msisdn: str, **_: Any) -> Any:
|
||||
return self.client.consultar_vas(msisdn)
|
||||
|
||||
|
||||
@@ -267,6 +267,31 @@ def _successful_contestation_item(item: dict[str, Any]) -> bool:
|
||||
return any(str(x or "").strip().upper() in {"ENVIADA", "CRIAR", "INICIADA"} for x in statuses)
|
||||
|
||||
|
||||
def _money_decimal(value: Any) -> Decimal:
|
||||
"""Parse TIM monetary values without turning 14.99 into 1499.
|
||||
|
||||
Accepts canonical decimal-dot values (14.99), pt-BR decimal-comma values
|
||||
(14,99), and values with thousands separators (1.234,56 / 1,234.56).
|
||||
"""
|
||||
text = str(value if value is not None else "0").strip()
|
||||
if not text:
|
||||
return Decimal("0")
|
||||
text = re.sub(r"[^0-9,.-]", "", text)
|
||||
if "," in text and "." in text:
|
||||
if text.rfind(",") > text.rfind("."):
|
||||
text = text.replace(".", "").replace(",", ".")
|
||||
else:
|
||||
text = text.replace(",", "")
|
||||
elif "," in text:
|
||||
text = text.replace(".", "").replace(",", ".")
|
||||
# Dot-only input is already the canonical decimal representation used by
|
||||
# the migrated agent/MCP contract. Do not strip it as a thousands marker.
|
||||
try:
|
||||
return Decimal(text)
|
||||
except InvalidOperation:
|
||||
return Decimal("0")
|
||||
|
||||
|
||||
def _classify_contestation_items(requested: list[dict[str, Any]], response_items: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str], str, str]:
|
||||
contested = [x for x in response_items if _successful_contestation_item(x) and not _already_contested_item(x)]
|
||||
already = [str(x.get("itemName") or x.get("item_name") or "").strip() for x in response_items if _already_contested_item(x)]
|
||||
@@ -293,13 +318,10 @@ def _classify_contestation_items(requested: list[dict[str, Any]], response_items
|
||||
name = str(req.get("item_name") or req.get("itemName") or req.get("name") or "")
|
||||
if not any(_same_contestation_name(name, x) for x in contested_names):
|
||||
continue
|
||||
try:
|
||||
c = str(req.get("claimed_amount", req.get("claimedAmount", "0"))).replace(".", "").replace(",", ".")
|
||||
v = str(req.get("validated_amount", req.get("validatedAmount", req.get("claimed_amount", req.get("claimedAmount", "0"))))).replace(".", "").replace(",", ".")
|
||||
claimed += Decimal(c)
|
||||
validated += Decimal(v)
|
||||
except InvalidOperation:
|
||||
pass
|
||||
claimed += _money_decimal(req.get("claimed_amount", req.get("claimedAmount", "0")))
|
||||
validated += _money_decimal(
|
||||
req.get("validated_amount", req.get("validatedAmount", req.get("claimed_amount", req.get("claimedAmount", "0"))))
|
||||
)
|
||||
return contested, not_contested, already, f"{claimed:.2f}", f"{validated:.2f}"
|
||||
|
||||
|
||||
@@ -645,7 +667,15 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
|
||||
message = base
|
||||
if trailer and trailer.lower() not in message.lower():
|
||||
message = f"{message.rstrip()} {trailer}".strip()
|
||||
return {"mensagem": message}
|
||||
return {
|
||||
"mensagem": message,
|
||||
"await_user_input": True,
|
||||
"requires_llm_composition": True,
|
||||
"response_instruction": (
|
||||
"Componha a resposta somente com a evidência fornecida; não invente cobranças, "
|
||||
"causas, políticas ou valores ausentes. Preserve a pergunta final prevista pelo workflow."
|
||||
),
|
||||
}
|
||||
|
||||
@reg.action("checar_tentativa_cvn")
|
||||
def checar_tentativa_cvn(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -660,6 +690,26 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
|
||||
event_params = {**params, "customerMessage": str(_first(params, state, "resposta_usuario", "customer_message") or path)}
|
||||
return {"success": True, "accepted": accepted, "business_events": _events_ctx(MPITag.EXPLICACAO_SIM if accepted else MPITag.EXPLICACAO_NAO, event_params, state)}
|
||||
|
||||
@reg.action("preparar_handoff_invoice_explanation")
|
||||
def preparar_handoff_invoice_explanation(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Materializa a decisão de handoff declarada no workflow do domínio.
|
||||
|
||||
A action não decide quando transferir: ela apenas transforma a configuração
|
||||
do nó em um resultado estrutural consumível pelo agente/framework.
|
||||
"""
|
||||
message = str(params.get("mensagem") or "Para continuar com a sua solicitação, aguarde um instante.").strip()
|
||||
reason = str(params.get("reason") or "invoice_explanation_not_resolved").strip()
|
||||
return {
|
||||
"success": True,
|
||||
"mensagem": message,
|
||||
"session_control": "HUMAN_HANDOFF",
|
||||
"human_handoff_requested": True,
|
||||
"handoff": True,
|
||||
"session_ended": True,
|
||||
"terminal_status": "human_handoff",
|
||||
"handoff_reason": reason,
|
||||
}
|
||||
|
||||
@reg.action("registrar_protocolo_inicio")
|
||||
@reg.action("registrar_protocolo")
|
||||
def registrar_protocolo(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -686,7 +736,18 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
|
||||
or (result or {}).get("protocolo")
|
||||
or ""
|
||||
) if isinstance(result, dict) else ""
|
||||
return {"success": bool(protocol or result), "protocolo_id": protocol, "protocol_number": protocol, "result": result}
|
||||
response = {"success": bool(protocol or result), "protocolo_id": protocol, "protocol_number": protocol, "result": result}
|
||||
# A protocol-opening action is reused by several workflows. Only nodes
|
||||
# that explicitly request a final workflow response opt into this
|
||||
# presentation contract; session terminality remains independent.
|
||||
if bool(params.get("workflow_response_final")):
|
||||
response["workflow_response_final"] = True
|
||||
configured = str(params.get("mensagem_final") or "").strip()
|
||||
if configured:
|
||||
response["mensagem"] = configured.replace("{protocol}", protocol)
|
||||
elif protocol:
|
||||
response["mensagem"] = f"Seu número de protocolo é {protocol}."
|
||||
return response
|
||||
|
||||
@reg.action("checar_vas_variado")
|
||||
def checar_vas_variado(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -1500,15 +1561,185 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
|
||||
"business_events": _events_ctx(VAATag.STATUS_SR_OK, params, state, agentProtocolId=protocol_id, adjustedProtocol=protocol_id, **status_meta),
|
||||
}
|
||||
|
||||
def _discount_evidence_context(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
|
||||
source = state.get("input") if isinstance(state.get("input"), dict) else {}
|
||||
return {**source, **params}
|
||||
|
||||
def _first_explicit_discount_reason(value: Any) -> tuple[str, str]:
|
||||
"""Return (reason, source_key) only from explicit backend evidence.
|
||||
|
||||
This deliberately does not infer expiration from installment counters,
|
||||
absence of a discount, plan names or the customer's wording. Causal
|
||||
claims must be supplied by a system of record/mock contract.
|
||||
"""
|
||||
reason_keys = {
|
||||
"discount_reason", "discountReason", "motivo_desconto", "motivoDesconto",
|
||||
"termination_reason", "terminationReason", "motivo_termino", "motivoTermino",
|
||||
"promotion_end_reason", "promotionEndReason",
|
||||
}
|
||||
status_keys = {"discount_status", "discountStatus", "promotion_status", "promotionStatus"}
|
||||
end_date_keys = {"promotion_end_date", "promotionEndDate", "discount_end_date", "discountEndDate"}
|
||||
|
||||
def walk(obj: Any) -> tuple[str, str]:
|
||||
if isinstance(obj, dict):
|
||||
for key, item in obj.items():
|
||||
if key in reason_keys and isinstance(item, (str, int, float)) and str(item).strip():
|
||||
return str(item).strip(), key
|
||||
for key, item in obj.items():
|
||||
if key in status_keys and str(item or "").strip().upper() in {
|
||||
"EXPIRED", "ENDED", "TERMINATED", "ENCERRADO", "EXPIRADO", "FINALIZADO"
|
||||
}:
|
||||
return f"status explícito: {str(item).strip()}", key
|
||||
for key, item in obj.items():
|
||||
if key in end_date_keys and isinstance(item, (str, int, float)) and str(item).strip():
|
||||
return f"data de término registrada: {str(item).strip()}", key
|
||||
for item in obj.values():
|
||||
found = walk(item)
|
||||
if found[0]:
|
||||
return found
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
found = walk(item)
|
||||
if found[0]:
|
||||
return found
|
||||
return "", ""
|
||||
|
||||
return walk(value)
|
||||
|
||||
def _discount_record_from_evidence(value: Any) -> dict[str, Any]:
|
||||
"""Select the most relevant discount record from authoritative evidence."""
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
rows = value.get("discounts") if isinstance(value.get("discounts"), list) else []
|
||||
candidates = [row for row in rows if isinstance(row, dict)]
|
||||
if not candidates:
|
||||
return {}
|
||||
terminal_statuses = {"EXPIRED", "ENDED", "TERMINATED", "ENCERRADO", "EXPIRADO", "FINALIZADO"}
|
||||
for row in candidates:
|
||||
if str(row.get("discount_status") or row.get("status") or "").strip().upper() in terminal_statuses:
|
||||
return row
|
||||
return candidates[0]
|
||||
|
||||
@staticmethod
|
||||
def _format_brl(value: Any) -> str:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
return f"R$ {number:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")
|
||||
|
||||
def _format_iso_date_br(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", text):
|
||||
year, month, day = text.split("-")
|
||||
return f"{day}/{month}/{year}"
|
||||
return text
|
||||
|
||||
def _plan_names_from_invoice_detail(invoice_detail: Any) -> list[str]:
|
||||
names: list[str] = []
|
||||
if not isinstance(invoice_detail, dict):
|
||||
return names
|
||||
for bucket in invoice_detail.values():
|
||||
if not isinstance(bucket, dict):
|
||||
continue
|
||||
planos = bucket.get("Planos")
|
||||
if isinstance(planos, dict):
|
||||
for name in planos:
|
||||
text = str(name or "").strip()
|
||||
if text and text not in names:
|
||||
names.append(text)
|
||||
return names
|
||||
|
||||
@reg.action("formatar_capability_resposta")
|
||||
def formatar_capability_resposta(params: dict[str, Any], state: dict[str, Any]) -> dict[str, Any]:
|
||||
typ = str(params.get("tipo") or "")
|
||||
if typ == "termino_desconto":
|
||||
plan = str(params.get("nome_plano") or "seu plano")
|
||||
msg = f"Identifiquei que a variação está relacionada ao término de um desconto do {plan}."
|
||||
ev = [MPITag.TERMINO_DESCONTO]
|
||||
context = _discount_evidence_context(params, state)
|
||||
evidence = {
|
||||
"discount_evidence": context.get("discount_evidence"),
|
||||
"invoice_detail": context.get("invoice_detail"),
|
||||
"billing_analysis": context.get("billing_analysis"),
|
||||
"plan_data": context.get("plan_data"),
|
||||
}
|
||||
reason, reason_source = _first_explicit_discount_reason(evidence)
|
||||
record = _discount_record_from_evidence(context.get("discount_evidence"))
|
||||
requested_plan = str(context.get("nome_plano") or "").strip()
|
||||
discovered_plans = _plan_names_from_invoice_detail(context.get("invoice_detail"))
|
||||
plan = str(record.get("plan_name") or requested_plan or (discovered_plans[0] if len(discovered_plans) == 1 else "")).strip()
|
||||
plan_text = f" do plano {plan}" if plan else ""
|
||||
|
||||
# Prefer the human-readable causal description from the system of
|
||||
# record. The code remains audit metadata, not customer-facing prose.
|
||||
reason_description = str(
|
||||
record.get("termination_reason_description")
|
||||
or record.get("discount_reason_description")
|
||||
or ""
|
||||
).strip()
|
||||
if reason_description:
|
||||
reason = reason_description
|
||||
reason_source = "termination_reason_description"
|
||||
|
||||
if reason:
|
||||
discount_name = str(record.get("discount_name") or "").strip()
|
||||
previous_value = _format_brl(record.get("previous_value"))
|
||||
end_date = _format_iso_date_br(record.get("end_date") or record.get("discount_end_date"))
|
||||
subject = f"do desconto {discount_name}" if discount_name else "do desconto"
|
||||
details: list[str] = []
|
||||
if previous_value:
|
||||
details.append(f"no valor de {previous_value}")
|
||||
if end_date:
|
||||
details.append(f"em {end_date}")
|
||||
detail_text = (", " + ", ".join(details)) if details else ""
|
||||
clean_reason = reason.rstrip(" .")
|
||||
msg = f"Identifiquei o término {subject}{plan_text}{detail_text}. Motivo informado pelo sistema: {clean_reason}."
|
||||
|
||||
# O histórico de desconto descreve a situação contratual em uma
|
||||
# data de referência, enquanto a última fatura pode cobrir um
|
||||
# período anterior. Quando o backend fornece ambas as referências
|
||||
# temporais, explicite a diferença sem inferir causalidade.
|
||||
last_billed_value = _format_brl(record.get("last_billed_discount_value"))
|
||||
last_billed_period = str(record.get("last_billed_period") or "").strip()
|
||||
last_invoice_issue_date = _format_iso_date_br(record.get("last_invoice_issue_date"))
|
||||
as_of_date = _format_iso_date_br(record.get("as_of_date"))
|
||||
current_value = record.get("current_value")
|
||||
current_value_reference = str(record.get("current_value_reference") or "").strip()
|
||||
if (
|
||||
current_value_reference == "contract_as_of_date"
|
||||
and current_value in (0, 0.0, "0", "0.0", "0.00")
|
||||
and last_billed_value
|
||||
and last_billed_period
|
||||
):
|
||||
billed_parts = [
|
||||
f"O último período faturado com esse desconto foi {last_billed_period}",
|
||||
f"com {last_billed_value} de desconto",
|
||||
]
|
||||
if last_invoice_issue_date:
|
||||
billed_parts.append(f"na fatura emitida em {last_invoice_issue_date}")
|
||||
contract_ref = f"; a situação contratual em {as_of_date} já consta como encerrada" if as_of_date else "; a situação contratual atual já consta como encerrada"
|
||||
msg += " " + ", ".join(billed_parts) + contract_ref + "."
|
||||
grounded = True
|
||||
else:
|
||||
msg = (
|
||||
f"Identifiquei dados de desconto{plan_text}, mas os dados disponíveis não informam "
|
||||
"o motivo da retirada ou do término do desconto."
|
||||
)
|
||||
grounded = False
|
||||
|
||||
return {
|
||||
"mensagem": msg,
|
||||
"business_events": _events(MPITag.TERMINO_DESCONTO),
|
||||
"discount_reason_grounded": grounded,
|
||||
"discount_reason": reason or None,
|
||||
"discount_reason_source": reason_source or None,
|
||||
"discount_record": record or None,
|
||||
"evidence_policy": "explicit_reason_only",
|
||||
"epistemic_status": "grounded_fact" if grounded else "insufficient_evidence",
|
||||
}
|
||||
elif typ == "valor_divergente":
|
||||
msg = "Identifiquei divergência de valor na fatura. Vou considerar os dados da fatura e do billing analysis para orientar a tratativa."
|
||||
msisdn = str(params.get("msisdn") or "").strip()
|
||||
suffix = msisdn[-2:] if len(msisdn) >= 2 else msisdn
|
||||
line = f" na linha final {suffix}" if suffix else ""
|
||||
msg = f"Identifiquei uma alteração no valor do plano{line}."
|
||||
ev = [MPITag.VALOR_DIVERGENTE]
|
||||
else:
|
||||
msg = str(params.get("mensagem") or "")
|
||||
|
||||
Reference in New Issue
Block a user