"""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()