leonardo vor 1 Monat
Ursprung
Commit
30efad2a6f

+ 11 - 0
db/migrations/20260811100000_add_dados_erp_atendimento_clientes.cjs

@@ -0,0 +1,11 @@
+exports.up = function (knex) {
+  return knex.schema.alterTable("atendimento_clientes", (t) => {
+    t.json("DadosErp").nullable(); // cadastro completo do cliente no ERP (payload.Cliente.Dados.dadosERP do ifbot, já parseado)
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.alterTable("atendimento_clientes", (t) => {
+    t.dropColumn("DadosErp");
+  });
+};

+ 13 - 0
db/migrations/20260811110000_replace_dados_erp_por_id_cliente_ixc.cjs

@@ -0,0 +1,13 @@
+exports.up = function (knex) {
+  return knex.schema.alterTable("atendimento_clientes", (t) => {
+    t.dropColumn("DadosErp");
+    t.integer("IdClienteIxc").unsigned().nullable(); // id_cliente do IXC, extraído de payload.Cliente.Dados.dadosERP.Id (sem dados pessoais)
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.alterTable("atendimento_clientes", (t) => {
+    t.dropColumn("IdClienteIxc");
+    t.json("DadosErp").nullable();
+  });
+};

+ 114 - 0
scripts/backfillErpProtocoloTodos.js

@@ -0,0 +1,114 @@
+// Re-sincroniza TODOS os atendimentos já salvos no oráculo (não só SUP, abertos e
+// fechados) contra /protocolos/:id/mensagens do ifbot, atualizando ErpProtocolo/
+// ErpVinculoConfirmadoEm/IdClienteIxc quando o vínculo existir. Ao final, grava em
+// scripts/logs/ a lista dos atendimentos que GANHARAM ErpProtocolo novo (não tinham
+// antes) — essa lista alimenta scripts/reavaliarNovosVinculosErp.js.
+//
+// Uso: node scripts/backfillErpProtocoloTodos.js [--setor SUP] [--limite N]
+
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { config } from "../src/config/index.js";
+import { Atendimento } from "../src/models/Atendimento.model.js";
+import { importarProtocolo } from "../src/services/atendimentosService.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const setorIdx = process.argv.indexOf("--setor");
+const SETOR = setorIdx > -1 ? process.argv[setorIdx + 1] : null;
+const limiteIdx = process.argv.indexOf("--limite");
+const LIMITE = limiteIdx > -1 ? Number(process.argv[limiteIdx + 1]) : null;
+const CONCORRENCIA = 4;
+
+async function main() {
+  let query = Atendimento.query()
+    .withGraphFetched("cliente")
+    .orderBy("Abertura", "desc");
+  if (SETOR) query = query.where("Setor", SETOR);
+  if (LIMITE) query = query.limit(LIMITE);
+
+  const atendimentos = await query;
+  const total = atendimentos.length;
+  console.log(`[backfill-erp-todos] iniciando: ${total} atendimentos${SETOR ? ` (setor ${SETOR})` : " (todos os setores)"}`);
+
+  const baseUrl = config.ifbot.baseUrl.replace(/\/+$/, "");
+  let processados = 0;
+  let comVinculo = 0;
+  let erros = 0;
+  const novosVinculos = [];
+  const t0 = Date.now();
+
+  async function processar(a) {
+    const erpAntes = a.cliente?.ErpProtocolo ?? null;
+    try {
+      const item = {
+        Id: a.Id,
+        Codigo: a.Codigo,
+        Abertura: a.Abertura,
+        Cliente: { Id: a.cliente?.Id, Nome: a.cliente?.Nome, Telefone: a.cliente?.Telefone }
+      };
+      await importarProtocolo(item, baseUrl);
+
+      const atualizado = await Atendimento.query()
+        .findById(a.Id)
+        .withGraphFetched("cliente(soVinculo)")
+        .modifiers({ soVinculo: (q) => q.select("ErpProtocolo", "ErpVinculoConfirmadoEm", "IdClienteIxc") });
+
+      const erpDepois = atualizado?.cliente?.ErpProtocolo ?? null;
+      if (erpDepois) {
+        comVinculo += 1;
+        if (!erpAntes) {
+          novosVinculos.push({
+            Id: a.Id,
+            Codigo: a.Codigo,
+            Setor: a.Setor,
+            ErpProtocolo: erpDepois,
+            IdClienteIxc: atualizado.cliente.IdClienteIxc ?? null
+          });
+        }
+      }
+    } catch (err) {
+      erros += 1;
+      console.warn(`[backfill-erp-todos] erro em ${a.Codigo}: ${err.message}`);
+    } finally {
+      processados += 1;
+      if (processados % 100 === 0 || processados === total) {
+        const decorridoS = Math.round((Date.now() - t0) / 1000);
+        const ritmo = decorridoS / processados;
+        const restamMin = Math.round(((total - processados) * ritmo) / 60);
+        console.log(
+          `[backfill-erp-todos] progresso: ${processados}/${total} | com vínculo: ${comVinculo} | novos: ${novosVinculos.length} | erros: ${erros} | ${decorridoS}s decorridos | ~${restamMin}min restantes`
+        );
+      }
+    }
+  }
+
+  let idx = 0;
+  async function worker() {
+    while (idx < atendimentos.length) {
+      const a = atendimentos[idx++];
+      await processar(a);
+    }
+  }
+  await Promise.all(Array.from({ length: CONCORRENCIA }, worker));
+
+  const totalS = Math.round((Date.now() - t0) / 1000);
+  console.log(
+    `[backfill-erp-todos] concluído: ${processados}/${total} | com vínculo: ${comVinculo} | novos vínculos: ${novosVinculos.length} | erros: ${erros} | ${totalS}s total`
+  );
+
+  const outDir = path.resolve(__dirname, "logs");
+  fs.mkdirSync(outDir, { recursive: true });
+  const outPath = path.join(outDir, `backfill-erp-todos-novos-vinculos-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
+  fs.writeFileSync(outPath, JSON.stringify(novosVinculos, null, 2));
+  console.log(`[backfill-erp-todos] ${novosVinculos.length} novos vínculos salvos em ${outPath}`);
+  console.log(`[backfill-erp-todos] próximo passo: node scripts/reavaliarNovosVinculosErp.js --arquivo ${outPath}`);
+
+  process.exit(0);
+}
+
+main().catch((err) => {
+  console.error("[backfill-erp-todos] falha fatal:", err);
+  process.exit(1);
+});

+ 76 - 0
scripts/reavaliarCancelamentoComOsIxc.js

@@ -0,0 +1,76 @@
+import "dotenv/config";
+import { Atendimento } from "../src/models/Atendimento.model.js";
+import { avaliarAtendimento } from "../src/services/atendimentoAvaliacaoService.js";
+
+const limiteIdx = process.argv.indexOf("--limite");
+const LIMITE = limiteIdx > -1 ? Number(process.argv[limiteIdx + 1]) : null;
+
+const janelaIdx = process.argv.indexOf("--janela");
+const JANELA = janelaIdx > -1 ? process.argv[janelaIdx + 1] : null;
+const [JANELA_INICIO, JANELA_FIM] = JANELA ? JANELA.split("-").map(Number) : [null, null];
+
+function dentroDaJanela(inicioHora, fimHora) {
+  const horaSp = Number(
+    new Intl.DateTimeFormat("en-US", { timeZone: "America/Sao_Paulo", hour: "2-digit", hourCycle: "h23" }).format(new Date())
+  );
+  if (inicioHora <= fimHora) return horaSp >= inicioHora && horaSp < fimHora;
+  return horaSp >= inicioHora || horaSp < fimHora;
+}
+
+async function aguardarJanela(inicioHora, fimHora) {
+  let avisado = false;
+  while (!dentroDaJanela(inicioHora, fimHora)) {
+    if (!avisado) {
+      console.log(`[reavaliar-cancelamento-os-ixc] fora da janela (${inicioHora}h-${fimHora}h) — pausado até reabrir`);
+      avisado = true;
+    }
+    await new Promise((r) => setTimeout(r, 5 * 60 * 1000));
+  }
+  if (avisado) console.log("[reavaliar-cancelamento-os-ixc] janela reaberta, retomando");
+}
+
+async function main() {
+  let query = Atendimento.query()
+    .join("atendimento_avaliacoes as av", "av.AtendimentoId", "atendimentos.Id")
+    .where("av.Avaliavel", true)
+    .whereIn("av.StatusCancelamento", ["ameacou", "cancelou"])
+    .select("atendimentos.Id", "atendimentos.Codigo")
+    .orderBy("atendimentos.Abertura", "desc");
+  if (LIMITE) query = query.limit(LIMITE);
+
+  const alvos = await query;
+  const total = alvos.length;
+  console.log(`[reavaliar-cancelamento-os-ixc] iniciando: ${total} atendimentos`);
+
+  let processados = 0;
+  let erros = 0;
+  const t0 = Date.now();
+
+  for (const a of alvos) {
+    if (JANELA) await aguardarJanela(JANELA_INICIO, JANELA_FIM);
+    try {
+      await avaliarAtendimento(a.Id);
+    } catch (err) {
+      erros += 1;
+      console.warn(`[reavaliar-cancelamento-os-ixc] erro em ${a.Codigo}: ${err.message}`);
+    } finally {
+      processados += 1;
+      if (processados % 25 === 0 || processados === total) {
+        const s = Math.round((Date.now() - t0) / 1000);
+        const ritmo = s / processados;
+        const restamMin = Math.round(((total - processados) * ritmo) / 60);
+        console.log(
+          `[reavaliar-cancelamento-os-ixc] progresso: ${processados}/${total} | erros: ${erros} | ${Math.round(s / 60)}min decorridos | ~${restamMin}min restantes`
+        );
+      }
+    }
+  }
+
+  console.log(`[reavaliar-cancelamento-os-ixc] concluído: ${processados}/${total} | erros: ${erros} | ${Math.round((Date.now() - t0) / 60000)}min total`);
+  process.exit(0);
+}
+
+main().catch((err) => {
+  console.error("[reavaliar-cancelamento-os-ixc] falha fatal:", err);
+  process.exit(1);
+});

+ 68 - 0
scripts/reavaliarClienteSumiu.js

@@ -0,0 +1,68 @@
+// Reavalia atendimentos onde Sentimento="indefinido" (cliente parou de responder/sumiu no final),
+// após correção do prompt do juiz que penalizava indevidamente o atendente nesse cenário
+// (rubrica "cliente abandonado" confundia atendente abandonando o cliente com o inverso).
+//
+// Uso:
+//   node scripts/reavaliarClienteSumiu.js --limite 20
+//   node scripts/reavaliarClienteSumiu.js --score-max 4
+//   node scripts/reavaliarClienteSumiu.js
+
+import "dotenv/config";
+import { Atendimento } from "../src/models/Atendimento.model.js";
+import { avaliarAtendimento } from "../src/services/atendimentoAvaliacaoService.js";
+
+const limiteIdx = process.argv.indexOf("--limite");
+const LIMITE = limiteIdx > -1 ? Number(process.argv[limiteIdx + 1]) : null;
+
+const desdeIdx = process.argv.indexOf("--desde");
+const DESDE = desdeIdx > -1 ? process.argv[desdeIdx + 1] : null;
+
+const scoreMaxIdx = process.argv.indexOf("--score-max");
+const SCORE_MAX = scoreMaxIdx > -1 ? Number(process.argv[scoreMaxIdx + 1]) : null;
+
+async function main() {
+  let query = Atendimento.query()
+    .join("atendimento_avaliacoes as av", "av.AtendimentoId", "atendimentos.Id")
+    .where("av.Avaliavel", true)
+    .where("av.Sentimento", "indefinido")
+    .select("atendimentos.Id", "atendimentos.Codigo")
+    .orderBy("atendimentos.Id", "desc");
+  if (DESDE) query = query.where("av.UpdatedAt", "<", DESDE);
+  if (SCORE_MAX !== null) query = query.where("av.ScoreAtendente", "<=", SCORE_MAX);
+  if (LIMITE) query = query.limit(LIMITE);
+
+  const alvos = await query;
+  const total = alvos.length;
+  console.log(`[reavaliar-cliente-sumiu] iniciando: ${total} atendimentos`);
+
+  let processados = 0;
+  let erros = 0;
+  const t0 = Date.now();
+
+  for (const a of alvos) {
+    try {
+      await avaliarAtendimento(a.Id);
+    } catch (err) {
+      erros += 1;
+      console.warn(`[reavaliar-cliente-sumiu] erro em ${a.Codigo}: ${err.message}`);
+    } finally {
+      processados += 1;
+      if (processados % 25 === 0 || processados === total) {
+        const s = Math.round((Date.now() - t0) / 1000);
+        const ritmo = s / processados;
+        const restamMin = Math.round(((total - processados) * ritmo) / 60);
+        console.log(
+          `[reavaliar-cliente-sumiu] progresso: ${processados}/${total} | erros: ${erros} | ${Math.round(s / 60)}min decorridos | ~${restamMin}min restantes`
+        );
+      }
+    }
+  }
+
+  console.log(`[reavaliar-cliente-sumiu] concluído: ${processados}/${total} | erros: ${erros} | ${Math.round((Date.now() - t0) / 60000)}min total`);
+  process.exit(0);
+}
+
+main().catch((err) => {
+  console.error("[reavaliar-cliente-sumiu] falha fatal:", err);
+  process.exit(1);
+});

+ 4 - 0
scripts/reavaliarComTemperatureZero.js

@@ -10,6 +10,9 @@ const janelaIdx = process.argv.indexOf("--janela");
 const JANELA = janelaIdx > -1 ? process.argv[janelaIdx + 1] : null;
 const [JANELA_INICIO, JANELA_FIM] = JANELA ? JANELA.split("-").map(Number) : [null, null];
 
+const desdeIdx = process.argv.indexOf("--desde");
+const DESDE = desdeIdx > -1 ? process.argv[desdeIdx + 1] : null;
+
 function dentroDaJanela(inicioHora, fimHora) {
   const horaSp = Number(
     new Intl.DateTimeFormat("en-US", { timeZone: "America/Sao_Paulo", hour: "2-digit", hourCycle: "h23" }).format(new Date())
@@ -36,6 +39,7 @@ async function main() {
     .where("av.Avaliavel", true)
     .select("atendimentos.Id", "atendimentos.Codigo")
     .orderBy("atendimentos.Abertura", "desc");
+  if (DESDE) query = query.where("av.UpdatedAt", "<", DESDE);
   if (LIMITE) query = query.limit(LIMITE);
 
   const alvos = await query;

+ 76 - 0
scripts/reavaliarNovosVinculosErp.js

@@ -0,0 +1,76 @@
+import fs from "node:fs";
+import { avaliarAtendimento } from "../src/services/atendimentoAvaliacaoService.js";
+
+const arquivoIdx = process.argv.indexOf("--arquivo");
+const ARQUIVO = arquivoIdx > -1 ? process.argv[arquivoIdx + 1] : null;
+if (!ARQUIVO) {
+  console.error("uso: node scripts/reavaliarNovosVinculosErp.js --arquivo <caminho.json> [--limite N] [--janela HH-HH]");
+  process.exit(1);
+}
+
+const limiteIdx = process.argv.indexOf("--limite");
+const LIMITE = limiteIdx > -1 ? Number(process.argv[limiteIdx + 1]) : null;
+
+const janelaIdx = process.argv.indexOf("--janela");
+const JANELA = janelaIdx > -1 ? process.argv[janelaIdx + 1] : null;
+const [JANELA_INICIO, JANELA_FIM] = JANELA ? JANELA.split("-").map(Number) : [null, null];
+
+function dentroDaJanela(inicioHora, fimHora) {
+  const horaSp = Number(
+    new Intl.DateTimeFormat("en-US", { timeZone: "America/Sao_Paulo", hour: "2-digit", hourCycle: "h23" }).format(new Date())
+  );
+  if (inicioHora <= fimHora) return horaSp >= inicioHora && horaSp < fimHora;
+  return horaSp >= inicioHora || horaSp < fimHora;
+}
+
+async function aguardarJanela(inicioHora, fimHora) {
+  let avisado = false;
+  while (!dentroDaJanela(inicioHora, fimHora)) {
+    if (!avisado) {
+      console.log(`[reavaliar-novos-vinculos] fora da janela (${inicioHora}h-${fimHora}h) — pausado até reabrir`);
+      avisado = true;
+    }
+    await new Promise((r) => setTimeout(r, 5 * 60 * 1000));
+  }
+  if (avisado) console.log("[reavaliar-novos-vinculos] janela reaberta, retomando");
+}
+
+async function main() {
+  const novosVinculos = JSON.parse(fs.readFileSync(ARQUIVO, "utf8"));
+  const alvos = LIMITE ? novosVinculos.slice(0, LIMITE) : novosVinculos;
+
+  const total = alvos.length;
+  console.log(`[reavaliar-novos-vinculos] iniciando: ${total} atendimentos (de ${ARQUIVO})`);
+
+  let processados = 0;
+  let erros = 0;
+  const t0 = Date.now();
+
+  for (const a of alvos) {
+    if (JANELA) await aguardarJanela(JANELA_INICIO, JANELA_FIM);
+    try {
+      await avaliarAtendimento(a.Id);
+    } catch (err) {
+      erros += 1;
+      console.warn(`[reavaliar-novos-vinculos] erro em ${a.Codigo}: ${err.message}`);
+    } finally {
+      processados += 1;
+      if (processados % 25 === 0 || processados === total) {
+        const s = Math.round((Date.now() - t0) / 1000);
+        const ritmo = s / processados;
+        const restamMin = Math.round(((total - processados) * ritmo) / 60);
+        console.log(
+          `[reavaliar-novos-vinculos] progresso: ${processados}/${total} | erros: ${erros} | ${Math.round(s / 60)}min decorridos | ~${restamMin}min restantes`
+        );
+      }
+    }
+  }
+
+  console.log(`[reavaliar-novos-vinculos] concluído: ${processados}/${total} | erros: ${erros} | ${Math.round((Date.now() - t0) / 60000)}min total`);
+  process.exit(0);
+}
+
+main().catch((err) => {
+  console.error("[reavaliar-novos-vinculos] falha fatal:", err);
+  process.exit(1);
+});

+ 0 - 1
scripts/utils/estatistica.js

@@ -9,7 +9,6 @@ export function desvioPadrao(valores) {
   return Math.sqrt(variancia);
 }
 
-// coeficiente de correlação de Pearson entre dois vetores pareados
 export function correlacaoPearson(xs, ys) {
   if (xs.length < 2 || xs.length !== ys.length) return null;
   const mx = media(xs);

+ 10 - 0
src/controllers/Atendimentos.Controller.js

@@ -4,6 +4,7 @@ import {
   salvarAtendimento,
   sincronizarProtocolosAbertos,
   sincronizarTodosProtocolosAbertos,
+  sincronizarAtendimentoPorId,
   listarAtendimentos,
   obterAtendimento
 } from "../services/atendimentosService.js";
@@ -128,6 +129,15 @@ export const AtendimentosController = {
     }
   },
 
+  SincronizarUm: async function (req, res, next) {
+    try {
+      const result = await sincronizarAtendimentoPorId(req.params.id);
+      res.json({ ok: true, ...result });
+    } catch (err) {
+      next(err);
+    }
+  },
+
   ListarAvaliacoes: async function (req, res, next) {
     try {
       const page = Math.max(1, Number(req.query.page) || 1);

+ 1 - 0
src/routes/Atendimentos.Rotas.js

@@ -21,4 +21,5 @@ atendimentosRouter.get("/golden-set", requireUser, requireAdmin, AtendimentosCon
 atendimentosRouter.get("/", requireUser, AtendimentosController.Listar);
 atendimentosRouter.get("/:id", requireUser, parseId, AtendimentosController.Obter);
 atendimentosRouter.post("/:id/avaliar", requireUser, requireAdmin, parseId, AtendimentosController.AvaliarUm);
+atendimentosRouter.post("/:id/sync", requireUser, requireAdmin, parseId, AtendimentosController.SincronizarUm);
 atendimentosRouter.patch("/:id/golden-label", requireUser, requireAdmin, parseId, validate({ body: goldenLabelSchema }), AtendimentosController.SalvarGoldenLabel);

+ 128 - 25
src/services/atendimentoAvaliacaoService.js

@@ -1,7 +1,7 @@
 import { config } from "../config/index.js";
 import { chatCompletion } from "./ollamaClient.js";
 import { buildConversaTexto, calcularUltimaMensagemId, SCORE_RUIM_MAX } from "../utils/atendimentoFormat.js";
-import { buscarContextoIxc } from "./atendimentoIxcContextoService.js";
+import { buscarContextoIxc, buscarContextoCancelamentoIxc, conversaMencionaCancelamento } from "./atendimentoIxcContextoService.js";
 import { ingestarAtendimento } from "./atendimentoRagService.js";
 import { Atendimento } from "../models/Atendimento.model.js";
 import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
@@ -31,6 +31,17 @@ const AVALIACAO_SYSTEM_PROMPT = [
   "julgue nem penalize pelo desfecho técnico registrado no ERP, que pode depender de",
   "fatores fora do controle do atendente do chat.",
   "",
+  'Quando esse bloco do ERP incluir a seção "Situação comercial do contrato (verificação de',
+  'cancelamento)", ela é a fonte mais confiável sobre se um cancelamento mencionado na conversa',
+  "foi de fato efetivado — use-a para decidir status_cancelamento mesmo quando a conversa parecer",
+  'indicar que o atendente processaria o cancelamento. Se o contrato aparecer ATIVO e/ou houver OS',
+  "comercial de upgrade/alteração de plano após o atendimento sem nenhuma OS de cancelamento, isso",
+  'significa que o cliente foi RETIDO, não cancelou — use status_cancelamento="ameacou" (nunca',
+  '"cancelou") nesse caso, mesmo que a conversa em si pareça confirmar o cancelamento. Só use',
+  '"cancelou" quando o cancelamento for confirmado pela conversa E a seção do ERP não contradisser',
+  "(contrato ativo/OS de retenção), ou quando a própria seção do ERP mostrar diretamente um",
+  "cancelamento efetivado (contrato inativo, OS de cancelamento sem reversão).",
+  "",
   'Mensagens marcadas como "[áudio sem transcrição disponível]" representam um áudio que o',
   "sistema não conseguiu transcrever — o conteúdo é desconhecido, não silêncio nem falha de",
   "resposta. Não penalize score_atendente por essas mensagens especificamente e não presuma",
@@ -47,11 +58,24 @@ const AVALIACAO_SYSTEM_PROMPT = [
   "abandonado) ou quando tiver causado um desfecho ruim real (cliente foi embora sem solução por falta",
   "de retorno). Fora isso, mencione a demora só como detalhe na justificativa/sugestões, sem rebaixar o score.",
   "",
+  "ATENÇÃO, exceção estreita: quando é o CLIENTE quem para de responder/some no final da conversa —",
+  'situação refletida em sentimento_cliente_final="indefinido" — e, SOMENTE SE a conduta do atendente',
+  "ATÉ O PONTO em que o cliente sumiu foi boa (respondeu em tempo razoável, com clareza e correção,",
+  "sem erros), então o desfecho incompleto por si só não deve derrubar o score para a faixa 1-4; o",
+  "score pode ficar alto mesmo com resolvido=\"nao\"/\"indefinido\", porque a causa do desfecho incompleto",
+  "foi o cliente ter sumido, não uma falha do atendente. Essa exceção NÃO se aplica — e a nota baixa",
+  "continua valendo normalmente, como em qualquer atendimento malsucedido — se, antes do cliente sumir,",
+  "o atendente já demorou demais, respondeu de forma confusa ou incorreta, transferiu o atendimento sem",
+  "acompanhar ou sem dar encaminhamento efetivo, ou simplesmente não respondeu. Ou seja: o cliente sumir",
+  "só protege a nota se a atuação do atendente até ali foi realmente boa — nunca use isso para justificar",
+  "nota alta em um atendimento onde o atendente também falhou.",
+  "",
   "Primeiro escreva a justificativa_score (1 a 2 frases analisando a atuação do atendente) e só então",
   "dê o score_atendente (1 a 10), que deve ser coerente com a justificativa e com o desfecho:",
   "9-10 = exemplar; 7-8 = bom, resolveu com pequenas falhas; 5-6 = mediano; 3-4 = fraco (respostas",
-  "confusas, informação incorreta, cliente insistindo sem retorno adequado); 1-2 = muito ruim (cliente",
-  "abandonado, tratado mal ou induzido a erro).",
+  "confusas, informação incorreta, cliente insistindo sem retorno adequado); 1-2 = muito ruim (atendente",
+  "negligente: não respondeu, demorou de forma extrema, deu informação incorreta, transferiu o",
+  "atendimento sem acompanhar/sem solução, tratou mal, ou induziu a erro).",
   "Um atendimento resolvido com cliente satisfeito NUNCA recebe score abaixo de 6.",
   "Se nenhum atendente humano participou, use score_atendente = null.",
   "",
@@ -108,6 +132,32 @@ const AVALIACAO_SYSTEM_PROMPT = [
   "  foi processado.",
   '→ motivo_cancelamento: "Mudança de cidade."',
   "",
+  "Exemplo 6 — cliente pede cancelamento, atendente parece encaminhar o pedido, mas o ERP mostra",
+  'retenção (upgrade), não cancelamento efetivado (status_cancelamento = "ameacou", não "cancelou"',
+  "— o bloco do ERP tem prioridade sobre a impressão deixada pela conversa):",
+  'Cliente: "Quero cancelar, o plano está caro demais pro que eu uso."',
+  'Atendente: "Vou encaminhar sua solicitação para o setor responsável."',
+  "(bloco do ERP mostra: contrato ATIVO; OS comercial \"ALTERAÇÃO DE PLANO - UPGRADE\" aberta 3 dias",
+  "depois, sem nenhuma OS de cancelamento)",
+  '→ status_cancelamento: "ameacou" — o cliente pediu para cancelar, mas o ERP confirma que o',
+  "  contrato segue ativo e o desfecho real foi mudança de plano, não cancelamento.",
+  '→ motivo_cancelamento: "Achou o plano caro para o uso."',
+  "",
+  "Exemplo 7 — é o ATENDENTE/empresa quem cancela ou reagenda algo (não o cliente que ameaça",
+  '  cancelar o serviço) — status_cancelamento = "nao", mesmo com a palavra "cancelar" na conversa:',
+  'Atendente: "Devido ao mau tempo de hoje (chuva), não será possível fazer a sua instalação.',
+  '  Podemos agendar uma nova data?"',
+  'Cliente: "Tá bem, pode ser sim."',
+  'Atendente: "Perfeito, combinado então! Obrigado pela compreensão."',
+  '→ status_cancelamento: "nao" — quem cancelou/adiou foi o atendente (motivo operacional: mau',
+  "  tempo), não o cliente; o cliente só concordou com o reagendamento e em nenhum momento",
+  "  manifestou intenção própria de cancelar o serviço/contrato. O mesmo vale para qualquer outro",
+  "  motivo operacional do lado da empresa (falta de peça, técnico indisponível, atraso, etc.) —",
+  "  cancelamento/reagendamento de uma VISITA, INSTALAÇÃO ou AGENDAMENTO pontual decidido pela",
+  '  empresa não é sinal de risco de cancelamento do cliente. Não use a palavra "cancelar" sozinha',
+  "  como gatilho: sempre confira QUEM está cancelando o quê.",
+  '→ motivo_cancelamento: null',
+  "",
   "Campos:",
   '- resolvido: "sim" se o problema do cliente foi claramente resolvido na conversa; "parcial" se houve',
   '  encaminhamento/paliativo; "nao" se terminou sem solução; "indefinido" se não dá para concluir.',
@@ -120,13 +170,24 @@ const AVALIACAO_SYSTEM_PROMPT = [
   '- horario_visita_informado: "sim" se o atendente informou data/horário (mesmo aproximado) da visita;',
   '  "nao" se agendou visita sem informar quando; "nao_se_aplica" se não houve agendamento de visita.',
   "- horario_visita: a data/horário informado, copiado da conversa (ou null).",
-  '- status_cancelamento: "nao" (padrão) quando não há NENHUMA menção a cancelar o serviço/contrato',
-  '  na conversa; "ameacou" sempre que o cliente pede, menciona ou ameaça cancelar em QUALQUER momento',
-  '  da conversa — mesmo que a conversa não confirme se o cancelamento chegou a ser concluído (este é',
-  '  o valor mais comum quando há menção a cancelamento; na dúvida entre "ameacou" e "nao", prefira',
-  '  "ameacou"); "cancelou" apenas quando a própria conversa confirma que o cancelamento foi de fato',
-  '  processado/efetivado; "indefinido" só se não der para saber se houve menção a cancelamento.',
-  "  Não marque \"ameacou\" para reclamações genéricas que não mencionam cancelar o serviço/contrato.",
+  '- status_cancelamento: reflete APENAS o que o CLIENTE quer/ameaça em relação ao serviço/contrato —',
+  "  nunca conte uma fala do ATENDENTE. Antes de marcar qualquer valor diferente de \"nao\", confirme",
+  '  QUEM está falando em cancelar e O QUE está sendo cancelado: se for o atendente/empresa cancelando',
+  "  ou reagendando uma VISITA, INSTALAÇÃO ou AGENDAMENTO pontual (por mau tempo, falta de peça,",
+  '  técnico indisponível, atraso etc.), isso é operacional e NÃO conta — use "nao", mesmo que a',
+  '  palavra "cancelar"/"cancelamento" apareça várias vezes (ver Exemplo 7). Só considere "ameacou"/',
+  '  "cancelou" quando é o próprio CLIENTE que pede, menciona ou ameaça cancelar o SERVIÇO/CONTRATO em',
+  '  si (não uma visita/agendamento pontual). Valores: "nao" (padrão) quando não há nenhuma menção do',
+  '  cliente a cancelar o serviço/contrato; "ameacou" sempre que o cliente pede, menciona ou ameaça',
+  "  cancelar o serviço/contrato em QUALQUER momento da conversa — mesmo sem confirmação de que foi",
+  '  concluído (este é o valor mais comum quando há menção do cliente a cancelamento; na dúvida entre',
+  '  "ameacou" e "nao", prefira "ameacou"); "cancelou" apenas quando a própria conversa confirma que o',
+  '  cancelamento pedido pelo cliente foi de fato processado/efetivado E a seção "Situação comercial',
+  '  do contrato" do ERP, quando presente, não contradisser isso (ver regra acima — contrato ativo/OS',
+  '  de retenção rebaixa para "ameacou"); "indefinido" só se não der para saber se o cliente mencionou',
+  "  cancelamento. Não marque \"ameacou\"/\"cancelou\" para reclamações genéricas do cliente que não",
+  "  mencionam cancelar o serviço/contrato, nem para cancelamentos/reagendamentos decididos pela",
+  "  empresa (visita, instalação, agendamento).",
   '- motivo_cancelamento: o motivo relatado pelo cliente para cancelar/ameaçar cancelar, resumido em',
   '  1 frase copiada/parafraseada da conversa (ou null quando status_cancelamento for "nao").',
   "",
@@ -170,23 +231,60 @@ const AVALIACAO_JSON_SCHEMA = {
 const TRUNCATION_MARKER = "\n[... trecho intermediário da conversa omitido ...]\n";
 
 
-function formatarContextoIxc(contexto) {
+function formatarContextoIxc(contexto, contextoCancelamento) {
   const linhas = ["=== DADO FACTUAL DO ERP (IXC), fora da conversa ==="];
-  if (contexto.clienteNome) linhas.push(`Cliente cadastrado no ERP: ${contexto.clienteNome}`);
 
-  if (!contexto.osList.length) {
-    linhas.push("Nenhuma ordem de serviço técnica registrada para este atendimento no ERP.");
-  } else {
-    contexto.osList.forEach((os, i) => {
-      linhas.push(`OS ${i + 1}${os.assunto ? ` (${os.assunto})` : ""}:`);
+  if (contexto) {
+    if (contexto.clienteNome) linhas.push(`Cliente cadastrado no ERP: ${contexto.clienteNome}`);
+
+    if (!contexto.osList.length) {
+      linhas.push("Nenhuma ordem de serviço técnica registrada para este atendimento no ERP.");
+    } else {
+      contexto.osList.forEach((os, i) => {
+        linhas.push(`OS ${i + 1}${os.assunto ? ` (${os.assunto})` : ""}:`);
+        linhas.push(
+          `  aberta em ${os.dataAbertura ?? "?"}` +
+          (os.dataFechamento ? `, fechada em ${os.dataFechamento}` : ", ainda em aberto")
+        );
+        if (os.tecnico) linhas.push(`  técnico responsável: ${os.tecnico}`);
+        if (os.mensagemResposta) linhas.push(`  nota de resolução do técnico: "${os.mensagemResposta}"`);
+      });
+    }
+  }
+
+  if (contextoCancelamento) {
+    linhas.push("");
+    linhas.push("--- Situação comercial do contrato (verificação de cancelamento) ---");
+    if (!contextoCancelamento.contratos.length) {
+      linhas.push("Nenhum contrato encontrado para este cliente no ERP.");
+    } else {
       linhas.push(
-        `  aberta em ${os.dataAbertura ?? "?"}` +
-        (os.dataFechamento ? `, fechada em ${os.dataFechamento}` : ", ainda em aberto")
+        contextoCancelamento.contratoAtivo
+          ? "O cliente possui contrato ATIVO no ERP — não consta cancelamento efetivado até o momento."
+          : "O cliente NÃO possui nenhum contrato ativo no ERP — consistente com cancelamento efetivado."
       );
-      if (os.tecnico) linhas.push(`  técnico responsável: ${os.tecnico}`);
-      if (os.mensagemResposta) linhas.push(`  nota de resolução do técnico: "${os.mensagemResposta}"`);
-    });
+      contextoCancelamento.contratos.forEach((c) => {
+        linhas.push(
+          `  contrato "${c.contrato ?? "?"}": status ${c.status === "A" ? "ATIVO" : "inativo"}` +
+          ` (ativado em ${c.dataAtivacao ?? "?"})`
+        );
+      });
+    }
+
+    if (!contextoCancelamento.osComerciais.length) {
+      linhas.push("Nenhuma OS comercial de cancelamento/retenção/plano encontrada após este atendimento.");
+    } else {
+      linhas.push("OS comerciais de cancelamento/retenção/plano após este atendimento:");
+      contextoCancelamento.osComerciais.forEach((os, i) => {
+        linhas.push(
+          `  OS comercial ${i + 1}${os.assunto ? ` (${os.assunto})` : ""}: status ${os.status ?? "?"}` +
+          `, aberta em ${os.dataAbertura ?? "?"}`
+        );
+        if (os.notaResolucao) linhas.push(`    nota: "${os.notaResolucao}"`);
+      });
+    }
   }
+
   linhas.push("=== fim do dado do ERP ===");
   return linhas.join("\n");
 }
@@ -295,9 +393,14 @@ export async function gerarAvaliacaoViaLLM(atendimento, mensagens) {
     mensagens
   });
 
-  const { status: contextoIxcStatus, contexto: contextoIxc } = await buscarContextoIxc(atendimento);
-  const conteudoUsuario = contextoIxc
-    ? `${formatarContextoIxc(contextoIxc)}\n\n${truncateConversa(conversaTexto)}`
+  const mencionaCancelamento = conversaMencionaCancelamento(conversaTexto);
+  const [{ status: contextoIxcStatus, contexto: contextoIxc }, contextoCancelamentoIxc] = await Promise.all([
+    buscarContextoIxc(atendimento),
+    mencionaCancelamento ? buscarContextoCancelamentoIxc(atendimento.cliente, atendimento.Abertura) : Promise.resolve(null)
+  ]);
+
+  const conteudoUsuario = contextoIxc || contextoCancelamentoIxc
+    ? `${formatarContextoIxc(contextoIxc, contextoCancelamentoIxc)}\n\n${truncateConversa(conversaTexto)}`
     : truncateConversa(conversaTexto);
 
   const model = config.avaliacao.model || config.ollama.chatModel;

+ 76 - 1
src/services/atendimentoIxcContextoService.js

@@ -1,4 +1,4 @@
-import { buscarAtendimentoOsPorId } from "./ixcIntegradorService.js";
+import { buscarAtendimentoOsPorId, buscarContratosCliente, buscarOsAvancado } from "./ixcIntegradorService.js";
 
 // status: "ok" | "sem_erp_protocolo" | "sem_ticket" | "erro"
 // contexto só é preenchido quando status === "ok"; erro só quando status === "erro"
@@ -37,3 +37,78 @@ export async function buscarContextoIxc(atendimento) {
     }
   };
 }
+
+// IDs de assunto do catálogo os/assuntos do IXC relacionados a cancelamento, reversão de
+// cancelamento e retenção/mudança de plano — servem para checar se um pedido de cancelamento
+// ouvido na conversa foi de fato efetivado no ERP ou se o cliente foi retido (ex.: fez upgrade).
+const ASSUNTOS_CANCELAMENTO_RETENCAO = [
+  90, 237, 22, 31, 103, 62, 179, 108, 144, 209, 193, 115, 159, 154, // cancelamento / alerta / pós-retenção
+  216, 224, 35, 102, // reversão de cancelamento
+  217, 99, 12, // retirada de equipamento por cancelamento
+  54, 155, 36, 10, 222, 97, 58, 181 // upgrade / alteração de plano
+];
+
+// OS comerciais só valem a pena buscar até esse número de dias após o atendimento — depois
+// disso, qualquer cancelamento real já teria virado OS, e ampliar a janela só aumenta o risco
+// de pegar um cancelamento não relacionado a este atendimento específico.
+const JANELA_DIAS_POS_ATENDIMENTO = 60;
+
+const REGEX_MENCAO_CANCELAMENTO = /cancelar|cancelamento|encerrar\s+(o\s+)?(contrato|plano|servi[cç]o)|desist(ir|iu)/i;
+
+// checagem barata pra decidir se vale a pena buscar contrato/OS comercial no ERP — evita 2
+// chamadas extras ao Alfred em toda avaliação quando a conversa não tem nada a ver com cancelamento
+export function conversaMencionaCancelamento(texto) {
+  return REGEX_MENCAO_CANCELAMENTO.test(texto ?? "");
+}
+
+function toDateOnly(value) {
+  return new Date(value).toISOString().slice(0, 10);
+}
+
+// contexto comercial (contrato + OS de cancelamento/retenção) usado para validar se um
+// cancelamento mencionado na conversa foi de fato efetivado no ERP; melhor-esforço — falha
+// silenciosamente (retorna null) sem derrubar a avaliação, igual ao contexto técnico acima
+export async function buscarContextoCancelamentoIxc(cliente, dataAtendimento) {
+  if (!cliente?.IdClienteIxc) return null;
+
+  const inicio = toDateOnly(dataAtendimento);
+  const limiteJanela = new Date(dataAtendimento).getTime() + JANELA_DIAS_POS_ATENDIMENTO * 86400000;
+  const fim = toDateOnly(Math.min(Date.now(), limiteJanela));
+
+  try {
+    const [contratos, osResultado] = await Promise.all([
+      buscarContratosCliente(cliente.IdClienteIxc),
+      buscarOsAvancado({
+        id_cliente: cliente.IdClienteIxc,
+        assunto: ASSUNTOS_CANCELAMENTO_RETENCAO,
+        status: ["A", "AN", "EN", "AS", "AG", "DS", "EX", "RAG", "F"],
+        inicio,
+        fim,
+        limit: 20
+      })
+    ]);
+
+    const contratosList = Array.isArray(contratos) ? contratos : [];
+    const osComerciais = Array.isArray(osResultado?.data)
+      ? osResultado.data.map((os) => ({
+          assunto: os.assunto?.assunto ?? null,
+          status: os.status ?? null,
+          dataAbertura: os.data_abertura ?? null,
+          notaResolucao: os.mensagem_resposta ?? null
+        }))
+      : [];
+
+    return {
+      contratoAtivo: contratosList.some((c) => c.status === "A"),
+      contratos: contratosList.map((c) => ({
+        contrato: c.contrato ?? null,
+        status: c.status ?? null,
+        dataAtivacao: c.data_ativacao ?? null
+      })),
+      osComerciais
+    };
+  } catch (err) {
+    console.warn("[atendimentoIxcContextoService] buscarContextoCancelamentoIxc falhou:", err.message);
+    return null;
+  }
+}

+ 46 - 15
src/services/atendimentosService.js

@@ -5,7 +5,7 @@ import { AtendimentoCliente } from "../models/AtendimentoCliente.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
 import { atendimentoPayloadSchema, protocolosAbertosSchema, protocoloMensagensSchema } from "../middleware/schemas/Atendimento.Schema.js";
 import { assertPublicHttpsUrl } from "./ingestService.js";
-import { BadRequestError } from "../shared/errors/index.js";
+import { BadRequestError, NotFoundError } from "../shared/errors/index.js";
 
 const MAX_REDIRECTS = 5;
 const MENSAGENS_BATCH = 500;
@@ -213,24 +213,30 @@ export async function salvarAtendimento({ payload, sourceUrl, abertura, telefone
   }
 }
 
+// resposta do endpoint de mensagens pode vir completa (Protocolo + Cliente + Mensagens) ou
+// só a lista de mensagens — nesse segundo caso, Protocolo/Cliente vêm de `fallback`
+// (dados que já tínhamos de outra fonte: item da listagem de abertos, ou o próprio banco)
+function montarPayloadDeProtocolo(json, fallback) {
+  const full = atendimentoPayloadSchema.safeParse(json);
+  if (full.success) return full.data;
+
+  const soMensagens = protocoloMensagensSchema.safeParse(json);
+  if (!soMensagens.success) throw new BadRequestError("invalid_mensagens_payload");
+  return {
+    Protocolo: fallback.Protocolo,
+    Cliente: fallback.Cliente,
+    Mensagens: soMensagens.data.Mensagens
+  };
+}
+
 export async function importarProtocolo(item, baseUrl) {
   const mensagensUrl = `${baseUrl}/protocolos/${item.Id}/mensagens`;
   const json = await fetchJson(mensagensUrl, ifbotAuthHeaders());
 
-  // resposta completa (Protocolo + Cliente + Mensagens) ou só a lista de mensagens
-  let payload;
-  const full = atendimentoPayloadSchema.safeParse(json);
-  if (full.success) {
-    payload = full.data;
-  } else {
-    const soMensagens = protocoloMensagensSchema.safeParse(json);
-    if (!soMensagens.success) throw new BadRequestError("invalid_mensagens_payload");
-    payload = {
-      Protocolo: { Id: item.Id, Codigo: item.Codigo, Status: 1 },
-      Cliente: item.Cliente,
-      Mensagens: soMensagens.data.Mensagens
-    };
-  }
+  const payload = montarPayloadDeProtocolo(json, {
+    Protocolo: { Id: item.Id, Codigo: item.Codigo, Status: 1 },
+    Cliente: item.Cliente
+  });
 
   return salvarAtendimento({
     payload,
@@ -240,6 +246,31 @@ export async function importarProtocolo(item, baseUrl) {
   });
 }
 
+// re-sincroniza UM atendimento específico direto pelo Id, sem depender da listagem de
+// "protocolos abertos" — necessário porque essa listagem só cobre atendimentos ainda
+// em aberto no ifbot; um atendimento já fechado nunca é revisitado pelo sync em lote,
+// mesmo que alguém tenha transcrito um áudio manualmente nele depois de encerrado
+export async function sincronizarAtendimentoPorId(id) {
+  const atendimento = await Atendimento.query().findById(id).withGraphFetched("cliente");
+  if (!atendimento) throw new NotFoundError("atendimento_not_found");
+
+  const baseUrl = config.ifbot.baseUrl.replace(/\/+$/, "");
+  const mensagensUrl = atendimento.SourceUrl || `${baseUrl}/protocolos/${id}/mensagens`;
+  const json = await fetchJson(mensagensUrl, ifbotAuthHeaders());
+
+  const payload = montarPayloadDeProtocolo(json, {
+    Protocolo: { Id: atendimento.Id, Codigo: atendimento.Codigo, Status: atendimento.Status },
+    Cliente: atendimento.cliente
+  });
+
+  return salvarAtendimento({
+    payload,
+    sourceUrl: mensagensUrl,
+    abertura: atendimento.Abertura,
+    telefone: atendimento.cliente?.Telefone ?? null
+  });
+}
+
 export async function sincronizarProtocolosAbertos({ pagina = 1, limite = 50, inicio, fim } = {}) {
   const baseUrl = config.ifbot.baseUrl.replace(/\/+$/, "");
 

+ 5 - 11
src/utils/atendimentoFormat.js

@@ -3,12 +3,10 @@ import { estaForaExpediente } from "../config/expediente.js";
 
 const ATENDENTE_PREFIX_RE = /^\s*<b>\s*([^<:]+?)\s*:?\s*<\/b>\s*(?:<br\s*\/?>)?\s*/i;
 
-// sentinels de controle do ifbot em Transcricao — nunca são texto de fala real
+
 const TRANSCRICAO_SENTINELS = new Set(["__processando__", "__erro__"]);
 
-// tipos de mídia que são áudio (ifbot nunca transcreve ~97% deles — ver memória
-// cobertura_transcricao_audio_atendente); usados pra dar um placeholder autoexplicativo
-// em vez da sigla crua do codec, que juízes/LLMs não necessariamente reconhecem como áudio.
+
 export const AUDIO_MEDIA_TYPES = new Set(["mpga", "mp3", "audio", "oga"]);
 const PLACEHOLDER_AUDIO_SEM_TRANSCRICAO = "[áudio sem transcrição disponível]";
 
@@ -87,8 +85,7 @@ export function estruturarMensagem(m) {
   } else if (AUDIO_MEDIA_TYPES.has(m.Tipodemidia)) {
     texto = PLACEHOLDER_AUDIO_SEM_TRANSCRICAO;
   } else if (m.Tipodemidia && m.Tipodemidia !== "text" && m.Tipodemidia !== "ifstatus") {
-    // Body de mídia sem transcrição é só o caminho do arquivo (ex.: /data/aud/x.mp3), não texto
-    // real — sempre usa o placeholder aqui, em vez de só quando o stripHtml sair vazio.
+    
     texto = `[mídia: ${m.Tipodemidia}]`;
   }
 
@@ -192,9 +189,7 @@ export function calcularUltimaMensagemId(mensagens) {
 export const SCORE_RUIM_MAX = 5;
 export const SCORE_BOM_MIN = 8;
 
-// null = não avaliável/sem score; "boa" | "ruim" | "neutra" caso contrário.
-// Critério de "boa" consistente com o usado em scripts/exportarDatasetTreinamento.js
-// (exemplos usados no fine-tuning do star-atendente).
+
 export function classificarQualidadeAtendimento({ scoreAtendente, resolvido, sentimento }) {
   if (scoreAtendente === null || scoreAtendente === undefined) return null;
   if (scoreAtendente >= SCORE_BOM_MIN && ["sim", "parcial"].includes(resolvido) && sentimento !== "negativo") {
@@ -204,8 +199,7 @@ export function classificarQualidadeAtendimento({ scoreAtendente, resolvido, sen
   return "neutra";
 }
 
-// Bloco textual curto e autocontido com o resultado da avaliação por IA — pensado para
-// virar seu próprio chunk no RAG (denso em sinal, independente do tamanho da conversa).
+
 export function buildBlocoAvaliacaoParaRag(avaliacao) {
   if (!avaliacao?.Avaliavel) return null;
   const nota = avaliacao.ScoreAtendente != null ? `${avaliacao.ScoreAtendente}/10` : "não aplicável";