Projeto do Agent Contas ORACLE

This commit is contained in:
2026-08-19 09:35:50 -03:00
commit 950a2bcd33
1366 changed files with 177217 additions and 0 deletions

View 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, 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()