Projeto do Agent Contas ORACLE
This commit is contained in:
371
app/domain/contas/vas_cancellation_message.py
Normal file
371
app/domain/contas/vas_cancellation_message.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user