Forráskód Böngészése

feat: consultar cancelamentos reais do IXC dentro do modo Atendimentos

leonardo 1 hónapja
szülő
commit
dbbe481ae9

+ 23 - 4
src/chat/chatChain.js

@@ -32,7 +32,9 @@ const ATENDIMENTOS_SYSTEM_PROMPT = [
   "Se a pergunta mencionar um código de protocolo específico e o CONTEXTO não contiver exatamente esse código (ou contiver um código diferente), diga claramente que não encontrou esse atendimento — nunca responda como se fosse sobre o código pedido.",
   "Mensagens marcadas como '[áudio] texto' JÁ SÃO a transcrição real do que foi dito — trate esse texto como conteúdo legítimo da conversa, cite e resuma normalmente, igual a uma mensagem de texto comum. Já mensagens marcadas como '[mídia: tipo]' (sem transcrição) são anexos cujo conteúdo você realmente não conhece — só mencione que foram enviados, sem descrever ou supor o que mostram.",
   "Nunca invente números, datas, horários, prazos de espera, falas, nomes de clientes, códigos de protocolo ou desfechos que não estejam literalmente escritos no CONTEXTO — mas dentro do que está escrito, seja o mais completo possível.",
-  "Para atendimentos longos (muitas mensagens), NÃO se limite a 'alguns pontos importantes' — percorra a conversa cronologicamente e descreva o que aconteceu em cada fase/assunto tratado, com o máximo de detalhe real disponível. Evite frases genéricas de ressalva como 'a conversa foi muito extensa', 'pode haver informações faltando' ou 'os áudios não são transcrições exatas' — se o CONTEXTO tem a informação, relate-a com confiança; só sinalize incerteza quando algo específico realmente não estiver claro no texto."
+  "Para atendimentos longos (muitas mensagens), NÃO se limite a 'alguns pontos importantes' — percorra a conversa cronologicamente e descreva o que aconteceu em cada fase/assunto tratado, com o máximo de detalhe real disponível. Evite frases genéricas de ressalva como 'a conversa foi muito extensa', 'pode haver informações faltando' ou 'os áudios não são transcrições exatas' — se o CONTEXTO tem a informação, relate-a com confiança; só sinalize incerteza quando algo específico realmente não estiver claro no texto.",
+  "(6) um registro real de cancelamento vindo AO VIVO do ERP (IXC) — cliente, plano, valor, data de ativação/cancelamento, meses de contrato, motivo, vendedor e local — ou um resumo agregado desses cancelamentos reais (ranking por motivo/cidade/vendedor); trate como o dado definitivo e confirmado de cancelamento, diferente de qualquer avaliação automática por IA;",
+  "(7) um histórico de risco de cancelamento inferido por IA em atendimentos de um cliente específico — uma análise automática separada do registro real do ERP; compare os dois quando ambos aparecerem juntos (ex: se a IA tinha marcado risco e o cliente de fato cancelou, ou cancelou por um motivo diferente do dito no atendimento), mas nunca confunda um com o outro na resposta."
 ].join("\n");
 
 function buildContextBlock(hits) {
@@ -340,7 +342,7 @@ function createTimer() {
   };
 }
 
-export async function answerWithContext({ message, conversationId, userId, options, mode }) {
+export async function answerWithContext({ message, conversationId, userId, options, mode, isAdmin = false }) {
   const modeConfig = resolveMode(mode);
   const timer = createTimer();
   const history = await loadHistory(conversationId, userId);
@@ -348,7 +350,7 @@ export async function answerWithContext({ message, conversationId, userId, optio
 
   let hits;
   if (mode === "atendimentos") {
-    hits = await resolverContextoAtendimentos(message, history);
+    hits = await resolverContextoAtendimentos(message, history, { isAdmin });
     timer.mark("atendimentos_query");
   } else {
     const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer);
@@ -360,6 +362,12 @@ export async function answerWithContext({ message, conversationId, userId, optio
     }
   }
 
+  const skipLlmHit = hits.find((h) => h.metadata?.skipLlm);
+  if (skipLlmHit) {
+    timer.log(`conversationId=${conversationId ?? "-"} (skip_llm)`);
+    return { answer: skipLlmHit.text, sources: hitsToSources(hits) };
+  }
+
   const context = buildContextBlock(hits);
   const messages = buildMessages(message, context, history, modeConfig);
   const modeDefaults =
@@ -386,6 +394,7 @@ export async function answerWithContextStream({
   userId,
   options,
   mode,
+  isAdmin = false,
   onChunk,
   onSources,
   onStatus,
@@ -399,7 +408,7 @@ export async function answerWithContextStream({
 
   let hits;
   if (mode === "atendimentos") {
-    hits = await resolverContextoAtendimentos(message, history);
+    hits = await resolverContextoAtendimentos(message, history, { isAdmin });
     timer.mark("atendimentos_query");
     onStatus?.("encontrou", hits.length);
   } else {
@@ -416,6 +425,16 @@ export async function answerWithContextStream({
 
   const sources = hitsToSources(hits);
   onSources?.(sources);
+
+  const skipLlmHit = hits.find((h) => h.metadata?.skipLlm);
+  if (skipLlmHit) {
+    onStatus?.("gerando");
+    onChunk?.(skipLlmHit.text);
+    timer.mark("skip_llm");
+    timer.log(`conversationId=${conversationId ?? "-"} (skip_llm)`);
+    return { answer: skipLlmHit.text, sources };
+  }
+
   const context = buildContextBlock(hits);
   const messages = buildMessages(message, context, history, modeConfig);
   onStatus?.("gerando");

+ 3 - 1
src/controllers/Chat.Controller.js

@@ -23,7 +23,8 @@ export const ChatController = {
         conversationId: body.conversationId,
         userId: req.userId,
         options: body.options,
-        mode: body.mode
+        mode: body.mode,
+        isAdmin: String(req.user?.nivel) === "3"
       });
 
       await tryPersistMessages(body.conversationId, req.userId, body.message, result);
@@ -53,6 +54,7 @@ export const ChatController = {
         userId: req.userId,
         options: body.options,
         mode: body.mode,
+        isAdmin: String(req.user?.nivel) === "3",
         signal: abortController.signal,
         onStatus: (stage, count) => {
           const payload = count !== undefined ? { type: "status", stage, count } : { type: "status", stage };

+ 127 - 13
src/services/atendimentosQueryService.js

@@ -6,8 +6,17 @@ import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
 import { formatMensagem, formatDateTime, parseAtendenteBody, calcularUltimaMensagemId } from "../utils/atendimentoFormat.js";
 import { buscarAtendimentosSemelhantes } from "./atendimentoRagService.js";
 import { agregarQualidade, buscarAvaliacoesParaLicoes, SCORE_RUIM_MAX } from "./atendimentoAvaliacaoService.js";
+import {
+  buscarContratosCancelados,
+  aplicarFiltrosCancelamento,
+  formatarListaCancelamentos,
+  formatarResumoCancelamentos,
+  buscarStatusCancelamentoIA,
+  formatarStatusIA
+} from "./cancelamentosQueryService.js";
 
 const BUSCA_LIMIT_ATENDIMENTOS = 5;
+const MENCIONA_CANCELAMENTO_RE = /cancel/i;
 
 
 const CODIGO_PROTOCOLO_RE = /\b[A-Z]{2,4}\d{4,}\/\d{4}\b/gi;
@@ -54,30 +63,60 @@ function classificacaoSystemPrompt() {
     "Você classifica perguntas sobre atendimentos de suporte ao cliente de uma empresa.",
     `Hoje é ${hoje}.`,
     "Responda APENAS com um objeto JSON no formato:",
-    '{"relacionado": true | false, "tipo": "agregacao" | "busca" | "licoes", "metrica": "quantidade" | "nota_media" | null, "setor": "..." | null, "status": 0 | null, "dataInicio": "YYYY-MM-DD" | null, "dataFim": "YYYY-MM-DD" | null, "clienteNome": "..." | null, "termos": "..." | null}',
+    '{"relacionado": true | false, "tipo": "agregacao" | "busca" | "licoes" | "cancelamento_real", "metrica": "quantidade" | "nota_media" | null, "setor": "..." | null, "status": 0 | null, "dataInicio": "YYYY-MM-DD" | null, "dataFim": "YYYY-MM-DD" | null, "clienteNome": "..." | null, "termos": "..." | null, "motivo": "..." | null, "cidade": "..." | null, "bairro": "..." | null, "vendedor": "..." | null}',
     '"relacionado": true se a pergunta tiver QUALQUER relação com atendimento ao cliente/suporte',
     "dessa empresa, mesmo que indireta ou informal (ex: sobre um cliente, um problema relatado, um",
-    "setor, uma reclamação, uma conversa de suporte, uma anedota sobre atendimento). false SÓ se a",
+    "setor, uma reclamação, uma conversa de suporte, uma anedota sobre atendimento, ou cancelamento",
+    "real de cliente/contrato). false SÓ se a",
     "pergunta for sobre um assunto completamente alheio, sem nenhuma relação com atendimento/suporte",
     "(ex: previsão do tempo, receita de culinária, esporte, cultura geral, geografia). Na dúvida,",
     "prefira true — só marque false quando tiver certeza de que não há relação nenhuma.",
-    '"tipo"="agregacao" com "metrica"="quantidade" (default): a pergunta pede contagem, total ou quantidade de atendimentos (ex: "quantos atendimentos...", "total de...", "quantos no setor X").',
+    "REGRA DE PRIORIDADE, A MAIS IMPORTANTE DE TODAS: se a pergunta mencionar a palavra \"cancelamento\"/\"cancelamentos\"/\"cancelou\"/\"cancelar\"",
+    "DE QUALQUER FORMA (um cliente cancelou, quantos cancelamentos, motivo de cancelamento, ranking de cancelamento por",
+    "cidade/vendedor, cancelamento em tal período etc.), use SEMPRE \"tipo\"=\"cancelamento_real\" — NUNCA \"agregacao\", \"busca\"",
+    "ou \"licoes\" para esses casos, mesmo que a pergunta esteja no formato \"quantos...\", \"principais motivos...\" ou",
+    "\"que problemas...\", que normalmente indicariam outro tipo. \"agregacao\"/\"licoes\" são só para perguntas sobre",
+    "ATENDIMENTOS/SUPORTE em si (quantidade, nota, problemas recorrentes de atendimento) SEM nenhuma menção a cancelamento.",
+    "Exemplos de classificação correta que você DEVE seguir à risca, exatamente assim:",
+    '- "quais os principais motivos de cancelamento?" -> tipo="cancelamento_real" (a palavra "cancelamento" está presente; NÃO é "licoes")',
+    '- "quais os principais problemas relatados no setor SUP?" -> tipo="licoes" (sem menção a cancelamento)',
+    '- "quantos cancelamentos tivemos esse mês?" -> tipo="cancelamento_real" (a palavra "cancelamentos" está presente; NÃO é "agregacao")',
+    '- "quantos atendimentos tivemos esse mês?" -> tipo="agregacao" (sem menção a cancelamento)',
+    '- "por que os clientes têm cancelado?" -> tipo="cancelamento_real"',
+    '- "o cliente Fulano cancelou?" -> tipo="cancelamento_real"',
+    '"tipo"="agregacao" com "metrica"="quantidade" (default): a pergunta pede contagem, total ou quantidade de atendimentos, SEM mencionar cancelamento (ex: "quantos atendimentos...", "total de...", "quantos no setor X").',
     '"tipo"="agregacao" com "metrica"="nota_media": pergunta pede nota média, avaliação, qualidade agregada, quantos foram mal/bem avaliados (ex: "nota média do setor X", "quantos atendimentos mal avaliados esse mês").',
     '"tipo"="busca": a pergunta pede conteúdo de conversas específicas (ex: "o que o cliente reclamou", "o que aconteceu no atendimento X", "última conversa com o cliente Y", "esse atendimento foi bem resolvido?").',
-    '"tipo"="licoes": pergunta pede padrões, problemas recorrentes ou sugestões de melhoria mais comuns (ex: "quais os principais problemas do setor X", "que sugestões de melhoria mais aparecem").',
+    '"tipo"="licoes": pergunta pede padrões, problemas recorrentes ou sugestões de melhoria mais comuns SOBRE O ATENDIMENTO em si, SEM mencionar cancelamento (ex: "quais os principais problemas do setor X", "que sugestões de melhoria mais aparecem").',
+    '"tipo"="cancelamento_real": pergunta sobre cancelamento REAL e confirmado de um cliente/contrato, registrado no sistema da empresa (ERP) — se um cliente específico cancelou de fato, motivo real do cancelamento, quando cancelou, quantos clientes cancelaram, ranking de motivos/cidades/vendedores de cancelamento (ex: "o cliente X cancelou?", "quantos cancelamentos tivemos esse mês", "principais motivos de cancelamento", "cancelamentos em Porto Alegre"). Ver REGRA DE PRIORIDADE acima.',
     '"metrica": só preencha quando "tipo"="agregacao" ("quantidade" ou "nota_media"); null nos outros tipos.',
     '"setor": sigla do setor mencionado na pergunta (ex: SUP, POS), ou null se não mencionado.',
     '"status": SÓ preencha se a pergunta mencionar um número de status explícito (ex: "status 1"). Não tente adivinhar a partir de palavras como "aberto"/"fechado/"pendente" — não sabemos o mapeamento desses valores, então nesses casos deixe null.',
     '"dataInicio"/"dataFim": intervalo de datas mencionado, resolvendo referências relativas ("essa semana", "esse mês", "hoje") com base na data de hoje informada acima. Null se não houver período.',
     '"clienteNome": nome (completo ou parcial) do cliente mencionado na pergunta, ou null se não mencionado. Preste atenção em frases como "atendimento de [Nome]", "atendimento da [Nome]", "conversa com [Nome]" — o texto após "de"/"da"/"do"/"com" quase sempre É o nome do cliente, mesmo sem a palavra "cliente" explícita antes dele (ex: "atendimento de MOTEL DISKRETUS LTDA" -> clienteNome="MOTEL DISKRETUS LTDA"). Só deixe null quando for claramente uma categoria/setor genérico, não um nome próprio (ex: "atendimento de suporte", "atendimento de vendas"). Use o HISTÓRICO DA CONVERSA (se houver) para resolver referências como "ele", "esse cliente", "o mesmo cliente de antes".',
     '"termos": para tipo="busca" SEM clienteNome, palavras-chave relevantes para buscar no texto das mensagens (nome do problema, etc). Se clienteNome estiver preenchido, termos pode ser null.',
+    '"motivo"/"cidade"/"bairro"/"vendedor": só preencha com um VALOR ESPECÍFICO mencionado na pergunta, para FILTRAR o resultado (ex: "cancelamentos em Gravataí" -> cidade="Gravataí"; "cancelamentos vendidos pela Maria" -> vendedor="Maria"; "cancelamentos por insatisfação" -> motivo="insatisfação"). Se a pergunta pedir um RANKING/lista geral (ex: "quais os vendedores com mais cancelamentos", "principais cidades", "por motivo") SEM mencionar um valor específico, deixe TODOS esses campos null — o resumo agregado já traz o ranking completo automaticamente, não é preciso (e seria ERRADO) copiar parte da pergunta pro campo.',
     "Não inclua explicações, texto extra ou markdown fora do JSON."
   ].join("\n");
 }
 
 export async function classificarPergunta(message, history = []) {
   
-  const fallback = { relacionado: true, tipo: "busca", metrica: null, setor: null, status: null, dataInicio: null, dataFim: null, clienteNome: null, termos: message };
+  const fallback = {
+    relacionado: true,
+    tipo: "busca",
+    metrica: null,
+    setor: null,
+    status: null,
+    dataInicio: null,
+    dataFim: null,
+    clienteNome: null,
+    termos: message,
+    motivo: null,
+    cidade: null,
+    bairro: null,
+    vendedor: null
+  };
   try {
     const hasHistory = history.length > 0;
     const messages = [
@@ -99,17 +138,30 @@ export async function classificarPergunta(message, history = []) {
     if (!relacionado) {
       return { ...fallback, relacionado: false };
     }
-    if (!["agregacao", "busca", "licoes"].includes(parsed?.tipo)) return fallback;
+    if (!["agregacao", "busca", "licoes", "cancelamento_real"].includes(parsed?.tipo)) return fallback;
+    // O classificador (LLM local) erra com frequência entre "licoes"/"agregacao" e "cancelamento_real" quando a
+    // pergunta menciona cancelamento (ex: "principais motivos de cancelamento" cai em "licoes") — força
+    // determinística abaixo funciona como rede de segurança, sem depender só do julgamento do modelo.
+    const tipo =
+      ["agregacao", "licoes"].includes(parsed.tipo) && MENCIONA_CANCELAMENTO_RE.test(message)
+        ? "cancelamento_real"
+        : parsed.tipo;
+    const isCancelamento = tipo === "cancelamento_real";
+    const campoTexto = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
     return {
       relacionado,
-      tipo: parsed.tipo,
-      metrica: parsed.tipo === "agregacao" && parsed.metrica === "nota_media" ? "nota_media" : parsed.tipo === "agregacao" ? "quantidade" : null,
+      tipo,
+      metrica: tipo === "agregacao" && parsed.metrica === "nota_media" ? "nota_media" : tipo === "agregacao" ? "quantidade" : null,
       setor: typeof parsed.setor === "string" && parsed.setor.trim() ? parsed.setor.trim().toUpperCase() : null,
       status: Number.isInteger(parsed.status) ? parsed.status : null,
-      dataInicio: typeof parsed.dataInicio === "string" && parsed.dataInicio.trim() ? parsed.dataInicio.trim() : null,
-      dataFim: typeof parsed.dataFim === "string" && parsed.dataFim.trim() ? parsed.dataFim.trim() : null,
-      clienteNome: typeof parsed.clienteNome === "string" && parsed.clienteNome.trim() ? parsed.clienteNome.trim() : null,
-      termos: typeof parsed.termos === "string" && parsed.termos.trim() ? parsed.termos.trim() : message
+      dataInicio: campoTexto(parsed.dataInicio),
+      dataFim: campoTexto(parsed.dataFim),
+      clienteNome: campoTexto(parsed.clienteNome),
+      termos: campoTexto(parsed.termos) ?? message,
+      motivo: isCancelamento ? campoTexto(parsed.motivo) : null,
+      cidade: isCancelamento ? campoTexto(parsed.cidade) : null,
+      bairro: isCancelamento ? campoTexto(parsed.bairro) : null,
+      vendedor: isCancelamento ? campoTexto(parsed.vendedor) : null
     };
   } catch (err) {
     console.warn("[atendimentosQueryService] classificarPergunta falhou, usando busca textual:", err.message);
@@ -375,7 +427,65 @@ function formatarTrechoAtendimento(atendimento) {
   return [...cabecalho, "", ...(blocoAvaliacao ? [blocoAvaliacao, ""] : []), ...linhasMensagens].join("\n").trim();
 }
 
-export async function resolverContextoAtendimentos(message, history = []) {
+const CANCELAMENTO_RESTRITO_TEXTO =
+  "Os dados reais de cancelamento (cliente, contrato, valor, motivo) vêm do ERP (IXC) e são restritos a " +
+  "administradores — não é possível consultá-los com o seu nível de acesso atual. Peça para um administrador " +
+  "verificar, se precisar dessa informação.";
+
+
+async function resolverCancelamentoRestrito(filtros) {
+  const statusIA = filtros.clienteNome ? await buscarStatusCancelamentoIA(filtros.clienteNome) : [];
+  const textoIA = formatarStatusIA(statusIA);
+  const texto = [CANCELAMENTO_RESTRITO_TEXTO, textoIA].filter(Boolean).join("\n\n");
+  return [
+    {
+      id: "cancelamento_real_restrito",
+      source: null,
+      text: texto,
+      chunkIndex: 0,
+      metadata: { type: "cancelamento_real_restrito", skipLlm: true },
+      score: null
+    }
+  ];
+}
+
+async function resolverCancelamentoReal(filtros, { isAdmin } = {}) {
+  if (!isAdmin) return resolverCancelamentoRestrito(filtros);
+
+  const contratos = await buscarContratosCancelados(filtros);
+  const filtrados = aplicarFiltrosCancelamento(contratos, filtros);
+  const hits = [
+    {
+      id: filtros.clienteNome ? "cancelamento_real_busca" : "cancelamento_real_resumo",
+      source: null,
+      text: filtros.clienteNome
+        ? formatarListaCancelamentos(filtrados, filtros)
+        : formatarResumoCancelamentos(filtrados, filtros),
+      chunkIndex: 0,
+      metadata: { type: "cancelamento_real", ...filtros, total: filtrados.length },
+      score: null
+    }
+  ];
+
+  if (filtros.clienteNome) {
+    const statusIA = await buscarStatusCancelamentoIA(filtros.clienteNome);
+    const texto = formatarStatusIA(statusIA);
+    if (texto) {
+      hits.push({
+        id: "cancelamento_status_ia",
+        source: null,
+        text: texto,
+        chunkIndex: 0,
+        metadata: { type: "cancelamento_status_ia", clienteNome: filtros.clienteNome },
+        score: null
+      });
+    }
+  }
+
+  return hits;
+}
+
+export async function resolverContextoAtendimentos(message, history = [], { isAdmin = false } = {}) {
   const filtros = await classificarPergunta(message, history);
 
   if (filtros.tipo === "agregacao" && filtros.metrica === "nota_media") {
@@ -420,6 +530,10 @@ export async function resolverContextoAtendimentos(message, history = []) {
     ];
   }
 
+  if (filtros.tipo === "cancelamento_real") {
+    return resolverCancelamentoReal(filtros, { isAdmin });
+  }
+
 
   let codigos = extrairCodigosProtocolo(message);
   if (!codigos.length && history.length) {

+ 152 - 0
src/services/cancelamentosQueryService.js

@@ -0,0 +1,152 @@
+import { buscarBi } from "./ixcBiService.js";
+import { Atendimento } from "../models/Atendimento.model.js";
+import { formatDateTime } from "../utils/atendimentoFormat.js";
+
+const STATUS_RISCO = ["ameacou", "cancelou"];
+const LISTA_LIMIT = 20;
+const STATUS_IA_LIMIT = 3;
+
+export async function buscarContratosCancelados({ dataInicio, dataFim }) {
+  const query = {};
+  if (dataInicio) query.inicio = dataInicio;
+  if (dataFim) query.fim = dataFim;
+  const resultado = await buscarBi("cancelamentos/detalhe", query);
+  return resultado?.contratos ?? [];
+}
+
+function normalizar(texto) {
+  return String(texto ?? "")
+    .toLowerCase()
+    .normalize("NFD")
+    .replace(/[̀-ͯ]/g, "");
+}
+
+export function aplicarFiltrosCancelamento(contratos, { clienteNome, motivo, cidade, bairro, vendedor }) {
+  return contratos.filter((c) => {
+    if (clienteNome && !normalizar(c.cliente).includes(normalizar(clienteNome))) return false;
+    if (motivo && !normalizar(c.motivo).includes(normalizar(motivo))) return false;
+    if (cidade && !normalizar(c.cidade).includes(normalizar(cidade))) return false;
+    if (bairro && !normalizar(c.bairro).includes(normalizar(bairro))) return false;
+    if (vendedor && !normalizar(c.vendedor).includes(normalizar(vendedor))) return false;
+    return true;
+  });
+}
+
+function descreverFiltros(filtros) {
+  const partes = [];
+  if (filtros.clienteNome) partes.push(`cliente "${filtros.clienteNome}"`);
+  if (filtros.motivo) partes.push(`motivo contendo "${filtros.motivo}"`);
+  if (filtros.cidade) partes.push(`cidade "${filtros.cidade}"`);
+  if (filtros.bairro) partes.push(`bairro "${filtros.bairro}"`);
+  if (filtros.vendedor) partes.push(`vendedor "${filtros.vendedor}"`);
+  if (filtros.dataInicio || filtros.dataFim) {
+    partes.push(`período ${filtros.dataInicio ?? "início"} a ${filtros.dataFim ?? "hoje"}`);
+  }
+  return partes.length ? `filtrando por ${partes.join(", ")}` : null;
+}
+
+function formatarValor(v) {
+  return typeof v === "number" ? `R$ ${v.toFixed(2)}` : String(v ?? "não informado");
+}
+
+function formatarContrato(c) {
+  return [
+    `Cliente: ${c.cliente}`,
+    `Plano: ${c.plano ?? "não informado"} (${formatarValor(c.valorContrato)}/mês)`,
+    `Ativado em: ${c.ativacao ?? "?"} — Cancelado em: ${c.cancelamento ?? "?"} (${c.mesesContrato ?? "?"} meses de contrato)`,
+    `Motivo do cancelamento: ${c.motivo ?? "não informado"}`,
+    `Vendedor: ${c.vendedor ?? "não informado"} — Local: ${c.bairro ?? "?"}, ${c.cidade ?? "?"}/${c.uf ?? "?"}`
+  ].join("\n");
+}
+
+export function formatarListaCancelamentos(contratos, filtros) {
+  const descricao = descreverFiltros(filtros);
+  if (!contratos.length) {
+    return descricao
+      ? `Nenhum cancelamento encontrado no ERP (IXC) para esse filtro (${descricao}).`
+      : "Nenhum cancelamento encontrado no ERP (IXC).";
+  }
+  const amostra = contratos.slice(0, LISTA_LIMIT);
+  const cabecalho =
+    `Cancelamentos reais encontrados no ERP (dados ao vivo do IXC)${descricao ? ` ${descricao}` : ""}: ` +
+    `${contratos.length} contrato${contratos.length === 1 ? "" : "s"}` +
+    (contratos.length > amostra.length ? ` — mostrando os ${amostra.length} primeiros` : "") +
+    ".";
+  return [cabecalho, "", ...amostra.map((c, i) => `--- ${i + 1} ---\n${formatarContrato(c)}`)].join("\n");
+}
+
+function agrupar(contratos, campo) {
+  const mapa = new Map();
+  for (const c of contratos) {
+    const chave = c[campo] || "não informado";
+    mapa.set(chave, (mapa.get(chave) ?? 0) + 1);
+  }
+  return [...mapa.entries()].sort((a, b) => b[1] - a[1]);
+}
+
+function formatarRanking(pares, limit = 10) {
+  return pares
+    .slice(0, limit)
+    .map(([chave, total]) => `${chave}: ${total}`)
+    .join("\n");
+}
+
+export function formatarResumoCancelamentos(contratos, filtros) {
+  const descricao = descreverFiltros(filtros);
+  if (!contratos.length) {
+    return descricao
+      ? `Nenhum cancelamento encontrado no ERP (IXC) para esse filtro (${descricao}).`
+      : "Nenhum cancelamento encontrado no ERP (IXC).";
+  }
+  const cabecalho =
+    `Cancelamentos reais no ERP (dados ao vivo do IXC)${descricao ? ` ${descricao}` : ""}: ` +
+    `${contratos.length} contrato${contratos.length === 1 ? "" : "s"}.`;
+
+  return [
+    cabecalho,
+    "",
+    "Por motivo:",
+    formatarRanking(agrupar(contratos, "motivo")),
+    "",
+    "Por cidade:",
+    formatarRanking(agrupar(contratos, "cidade")),
+    "",
+    "Por vendedor:",
+    formatarRanking(agrupar(contratos, "vendedor"))
+  ].join("\n");
+}
+
+export async function buscarStatusCancelamentoIA(clienteNome) {
+  const termo = String(clienteNome ?? "").trim();
+  if (!termo) return [];
+
+  return Atendimento.query()
+    .join("atendimento_clientes", "atendimento_clientes.Id", "atendimentos.ClienteId")
+    .join("atendimento_avaliacoes", "atendimento_avaliacoes.AtendimentoId", "atendimentos.Id")
+    .where("atendimento_clientes.Nome", "like", `%${termo}%`)
+    .whereIn("atendimento_avaliacoes.StatusCancelamento", STATUS_RISCO)
+    .select(
+      "atendimentos.Codigo as codigo",
+      "atendimentos.Abertura as abertura",
+      "atendimento_clientes.Nome as clienteNome",
+      "atendimento_avaliacoes.StatusCancelamento as status",
+      "atendimento_avaliacoes.MotivoCancelamento as motivo"
+    )
+    .orderBy("atendimentos.Abertura", "desc")
+    .limit(STATUS_IA_LIMIT);
+}
+
+const STATUS_LABEL = { ameacou: "ameaçou cancelar", cancelou: "cancelou (segundo a IA)" };
+
+export function formatarStatusIA(linhas) {
+  if (!linhas.length) return null;
+  const cabecalho =
+    "Histórico de risco de cancelamento inferido por IA em atendimentos deste cliente " +
+    "(avaliação automática de conversas de suporte — não é o registro real confirmado pelo ERP):";
+  const itens = linhas.map((l) => {
+    const data = formatDateTime(l.abertura);
+    const status = STATUS_LABEL[l.status] ?? l.status;
+    return `- Atendimento ${l.codigo}${data ? ` (${data})` : ""}: cliente ${status}${l.motivo ? ` — motivo apontado pela IA: ${l.motivo}` : ""}`;
+  });
+  return [cabecalho, ...itens].join("\n");
+}