Ajustes conforme relatorio de testes 2026-08-27
This commit is contained in:
@@ -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