372 lines
15 KiB
Python
372 lines
15 KiB
Python
"""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, 2–5] → (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/2–5.
|
||
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/2–5. 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()
|