Ajustes conforme relatorio de testes 2026-08-27

This commit is contained in:
2026-08-29 09:53:32 -03:00
parent 0ecff719b7
commit 88e1f070d7
791 changed files with 27040 additions and 29038 deletions

View File

@@ -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 "")