Files
agent_contas/app/domain/contas/vas_cancellation_message.py

372 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Composição determinística da resposta ao cliente após cancelamento de VAS avulso.
FONTE ÚNICA das regras da fala ao cliente neste fluxo. Antes, o texto era gerado por
uma chamada LLM (capability ``fluxo_vas_cancelamento_resposta_cliente``, hoje
REMOVIDA) que apenas renderizava prosa a partir do payload. Como cada ramo é função
pura de campos já conhecidos (``sms_sent``, ``contested_items``,
``contestation_error_description`` etc.), a composição vive aqui: mesma saída
canônica, sem prompt, sem LLM, sem não-determinismo. O runtime entrega o texto
verbatim ao cliente.
Regras (mantenha esta seção como o spec ao alterar comportamento):
- Ordem de montagem: (a) resultado [regras 1.1, 25] → (b) SMS [regra 6] →
(c) protocolo [regra 7].
- Regra 1.1: itens já contestados + itens contestados nesta solicitação (partes
independentes, concordância por lista); destino do valor por ``sms_sent``.
- Regra 2 (``sms_sent=false``): crédito na próxima fatura.
- Regra 3 (``sms_sent=true``): a confirmação canônica de cancelamento, retirada
do valor e envio do novo código por SMS prevalece mesmo sem itens novos.
- Regra 5: itens que não puderam ser contestados.
- Regra 6: SMS 6.1 (ok) / 6.2 (falha) mutuamente exclusivas por ``sms_not_send_error``.
- Regra 7: protocolo(s) em forma canônica, sempre ao final.
- Regra 8: erro terminal → copia ``contestation_error_description`` literal + protocolo;
para item já contestado, informa antes os cancelamentos efetivados.
- No-match de cancelamento (``nao_encontrados`` não vazio): serviço não localizado
na plataforma por divergência de nome → cancelamento não executado, mas o valor
foi contestado. Estrutura própria (``_compose_no_match``): (a) valor total
creditado/retirado [+ SMS] → (b) cancelamentos efetivados (``cancelados``, caso
misto) → (c) no-match + orientação App Meu TIM (opção Gerenciar Benefícios) → protocolo.
- Residual: sucesso sem itens detalhados → confirmação genérica neutra + protocolo.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Any
def _clean(value: Any) -> str:
return str(value or "").strip()
def _item_name(item: Any) -> str:
if isinstance(item, Mapping):
return _clean(
item.get("itemName")
or item.get("item_name")
or item.get("name")
or item.get("servico")
or item.get("service")
)
return _clean(item)
def _names(items: Any) -> list[str]:
if not isinstance(items, Sequence) or isinstance(items, (str, bytes)):
return []
return [name for name in (_item_name(item) for item in items) if name]
def _join_e(names: Sequence[str]) -> str:
"""Junção natural pt-BR: "A"; "A e B"; "A, B e C"."""
names = list(names)
if not names:
return ""
if len(names) == 1:
return names[0]
return f"{', '.join(names[:-1])} e {names[-1]}"
def _is_already_disputed_error(description: str) -> bool:
normalized = description.casefold()
return any(
marker in normalized
for marker in (
"já contestado",
"já foi contestado",
"já foram contestados",
)
)
def _cancellation_success_sentence(canceled: Sequence[str]) -> str:
if len(canceled) == 1:
return f"O cancelamento do item {canceled[0]} foi concluído com sucesso."
return (
f"O cancelamento dos itens {_join_e(canceled)} foi concluído com sucesso."
)
def _protocol_sentence(payload: Mapping[str, Any]) -> str:
"""Regra 7 — protocolo em forma canônica (ex.: ``PRT475D8F1C1F``)."""
protocols = [_clean(p) for p in (payload.get("contestacao_protocols") or [])]
protocols = [p for p in protocols if p]
single = _clean(payload.get("contestacao_protocol"))
if not protocols and single:
protocols = [single]
if not protocols:
return ""
if len(protocols) == 1:
return f"Seu número de protocolo é {protocols[0]}."
return f"Seus números de protocolo são {_join_e(protocols)}."
def _sms_sentence(payload: Mapping[str, Any]) -> str:
"""Regra 6 — mensagens 6.1/6.2 mutuamente exclusivas por ``sms_not_send_error``."""
if bool(payload.get("sms_not_send_error")):
return (
"Identificamos uma instabilidade no envio do SMS com o novo código "
"para pagamento. Você pode consultar o código atualizado e o prazo "
"de pagamento diretamente no app Meu TIM."
)
return (
"Enviamos um SMS com o novo código de barras e o valor atualizado. "
"O prazo para pagamento é de 4 dias."
)
def _compose_no_match(
payload: Mapping[str, Any],
*,
amount: str,
sms_sent: bool,
contested: list[str],
not_contested: list[str],
canceled: list[str],
no_match: list[str],
protocol: str,
) -> str:
"""No-match de cancelamento: serviço(s) não localizado(s) na plataforma por
divergência de nome → o cancelamento não foi executado pelo canal, mas o valor
foi contestado (ajuste em fatura). Estrutura: (a) valor [+ SMS] → (b) lista de
cancelamentos com sucesso (se houver) → (c) no-match + orientação App Meu TIM →
(d) protocolo. A frase de encerramento é anexada pelo runtime, não aqui.
"""
parts: list[str] = []
# (a) Destino do valor contestado — só quando houve contestação de fato.
if contested or amount not in ("", "0,00"):
if sms_sent:
parts.append(f"O valor total de R$ {amount} foi retirado da sua fatura.")
parts.append(_sms_sentence(payload))
else:
parts.append(
f"O valor total de R$ {amount} ficou registrado como crédito para "
"sua próxima fatura."
)
# (b) Cancelamentos efetivados (caso misto).
if canceled:
if len(canceled) == 1:
parts.append(
f"O cancelamento do serviço {canceled[0]} foi realizado com sucesso."
)
else:
parts.append(
f"O cancelamento dos serviços {_join_e(canceled)} foram realizados "
"com sucesso."
)
# (c) No-match + orientação para o App Meu TIM. A forma da frase difere entre
# o caso misto (há lista de sucesso antes) e o caso tudo-no-match.
if canceled:
if len(no_match) == 1:
parts.append(
f"Não consegui realizar por aqui o cancelamento do serviço "
f"{no_match[0]}. Você pode cancelá-lo pelo App Meu TIM, na opção "
"Gerenciar Benefícios."
)
else:
parts.append(
f"Não consegui realizar por aqui o cancelamento dos serviços "
f"{_join_e(no_match)}. Você pode cancelá-los pelo App Meu TIM, na "
"opção Gerenciar Benefícios."
)
else:
if len(no_match) == 1:
parts.append(
f"Sobre o cancelamento do serviço {no_match[0]}, não consegui "
"realizar por aqui. Você pode cancelar pelo App Meu TIM, na opção "
"Gerenciar Benefícios."
)
else:
parts.append(
f"Sobre o cancelamento dos serviços {_join_e(no_match)}, não "
"consegui realizar por aqui. Você pode cancelar pelo App Meu TIM, "
"na opção Gerenciar Benefícios."
)
# Regra 5 — itens que não puderam ser contestados (raro combinado com no-match).
if not_contested:
parts.append(
f"Não foi possível contestar o item {', '.join(not_contested)}."
)
if protocol:
parts.append(protocol)
return " ".join(part.strip() for part in parts if part.strip()).strip()
def compose_vas_cancellation_message(payload: Mapping[str, Any]) -> str:
"""Monta a resposta ao cliente a partir do payload estruturado.
Retorna ``""`` apenas quando não há sucesso nem nenhuma regra aplicável
(ex.: erro sem descrição). Nesse caso o backend aplica sua mensagem de erro
genérica — não há chamada LLM em nenhum caminho.
"""
if not isinstance(payload, Mapping):
return ""
error_desc = _clean(payload.get("contestation_error_description"))
protocol = _protocol_sentence(payload)
canceled = _names(payload.get("cancelados"))
# Regra 8 — contestação recusada pela API: preserva os cancelamentos que já
# ocorreram, mas deixa claro que uma nova contestação não pôde ser registrada.
if error_desc:
parts: list[str] = []
if canceled and _is_already_disputed_error(error_desc):
parts.append(_cancellation_success_sentence(canceled))
if not error_desc.endswith((".", "!", "?")) and protocol:
error_desc = f"{error_desc}."
parts.append(error_desc)
if protocol:
parts.append(protocol)
return " ".join(parts)
amount = _clean(payload.get("contested_invoice_amount_open"))
sms_sent = bool(payload.get("sms_sent"))
contested = _names(payload.get("contested_items"))
not_contested = _names(payload.get("not_contested_items"))
already = [_clean(x) for x in (payload.get("itens_ja_contestados") or [])]
already = [x for x in already if x]
no_match = _names(payload.get("nao_encontrados"))
# No-match de cancelamento (nome divergente na plataforma): o cancelamento não
# foi executado, mas o valor foi contestado. Assume a estrutura própria
# (lidera pelo valor + orienta App Meu TIM), distinta das regras 1.1/25.
if no_match:
return _compose_no_match(
payload,
amount=amount,
sms_sent=sms_sent,
contested=contested,
not_contested=not_contested,
canceled=canceled,
no_match=no_match,
protocol=protocol,
)
# Regra 3 — o envio do novo boleto por SMS é o sinal definitivo sobre o
# destino do ajuste. Ele prevalece sobre ``contested_items`` vazio (por
# exemplo, quando a API não devolve os itens detalhados) e nunca pode cair
# no texto de crédito para a próxima fatura.
if sms_sent and not bool(payload.get("sms_not_send_error")):
parts = [
"O cancelamento foi concluído com sucesso. O valor contestado foi "
"retirado da sua fatura.",
_sms_sentence(payload),
]
if not_contested:
parts.append(
f"Não foi possível contestar o item {', '.join(not_contested)}."
)
if protocol:
parts.append(protocol)
return " ".join(part.strip() for part in parts if part.strip()).strip()
# Sem nenhum item detalhado (contestado, não contestado ou já contestado):
# não há como aplicar as regras 1.1/25. Se o cancelamento teve sucesso,
# devolve uma confirmação genérica neutra (não promete crédito nem boleto,
# que dependem dos itens); o protocolo entra pela regra 7 (ou pelo append do
# backend, quando só há protocolo de cancelamento). Sem sucesso, devolve ""
# para o backend aplicar sua mensagem de erro genérica.
if not (contested or not_contested or already):
if not bool(payload.get("success")):
return ""
generico = "O cancelamento foi concluído com sucesso."
return f"{generico} {protocol}".strip() if protocol else generico
def _ja_contestados_notice() -> str:
# Parte 1 da regra 1.1 (concordância pela própria lista).
if len(already) == 1:
return (
f"Identifiquei que o item {already[0]} já havia sido contestado "
"anteriormente e, por esse motivo, não é possível registrar uma "
"nova contestação para ele."
)
return (
f"Identifiquei que os itens {_join_e(already)} já haviam sido "
"contestados anteriormente e, por esse motivo, não é possível "
"registrar uma nova contestação para eles."
)
parts: list[str] = []
emit_sms = False
if already and contested:
# Regra 1.1 — misto: parte por lista própria (concordância independente).
parts.append(_ja_contestados_notice())
if len(contested) == 1:
parts.append(
f"Já a contestação do item {contested[0]} no valor de R$ {amount} "
"foi concluída com sucesso."
)
else:
parts.append(
f"Já a contestação dos itens {_join_e(contested)} no valor total "
f"de R$ {amount} foi concluída com sucesso."
)
if sms_sent:
parts.append("O valor contestado já foi retirado da sua fatura.")
emit_sms = True
else:
parts.append(
"O valor contestado ficou registrado como crédito para sua "
"próxima fatura."
)
# Regras 2 e 3 NÃO se aplicam neste caso.
elif already and not contested:
# Gap fora das regras 1.1/2/3/4 (só itens já contestados, sem SMS): nada de
# novo foi contestado, então só o aviso "já contestado" — não afirma
# cancelamento/crédito que não ocorreu.
parts.append(_ja_contestados_notice())
elif sms_sent:
# Regra 3 — valor retirado da fatura (novo boleto por SMS).
if len(contested) == 1:
parts.append(
f"O cancelamento do item {contested[0]} no valor de R$ {amount} "
"foi concluído com sucesso. O valor contestado já foi retirado da "
"sua fatura."
)
else:
parts.append(
"O cancelamento dos itens foi concluído com sucesso. O valor total "
f"de R$ {amount} foi retirado da sua fatura."
)
emit_sms = True
else:
# Regra 2 — crédito na próxima fatura (sms_sent=false).
if len(contested) == 1:
parts.append(
f"O cancelamento do item {contested[0]} no valor de R$ {amount} "
"foi concluído com sucesso. O valor contestado ficou registrado "
"como crédito para sua próxima fatura."
)
else:
parts.append(
"O cancelamento dos itens foi feito com sucesso. O crédito no valor "
f"total de R$ {amount} ficou registrado como crédito para sua "
"próxima fatura."
)
if not parts:
return ""
# Regra 5 — itens que não puderam ser contestados (parte (a) do resultado).
if not_contested:
parts.append(
f"Não foi possível contestar o item {', '.join(not_contested)}."
)
# (b) Notificação de SMS.
if emit_sms:
parts.append(_sms_sentence(payload))
# (c) Protocolo, sempre.
if protocol:
parts.append(protocol)
return " ".join(part.strip() for part in parts if part.strip()).strip()