leonardo há 1 mês atrás
pai
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 = janelaIdx > -1 ? process.argv[janelaIdx + 1] : null;
 const [JANELA_INICIO, JANELA_FIM] = JANELA ? JANELA.split("-").map(Number) : [null, 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) {
 function dentroDaJanela(inicioHora, fimHora) {
   const horaSp = Number(
   const horaSp = Number(
     new Intl.DateTimeFormat("en-US", { timeZone: "America/Sao_Paulo", hour: "2-digit", hourCycle: "h23" }).format(new Date())
     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)
     .where("av.Avaliavel", true)
     .select("atendimentos.Id", "atendimentos.Codigo")
     .select("atendimentos.Id", "atendimentos.Codigo")
     .orderBy("atendimentos.Abertura", "desc");
     .orderBy("atendimentos.Abertura", "desc");
+  if (DESDE) query = query.where("av.UpdatedAt", "<", DESDE);
   if (LIMITE) query = query.limit(LIMITE);
   if (LIMITE) query = query.limit(LIMITE);
 
 
   const alvos = await query;
   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);
   return Math.sqrt(variancia);
 }
 }
 
 
-// coeficiente de correlação de Pearson entre dois vetores pareados
 export function correlacaoPearson(xs, ys) {
 export function correlacaoPearson(xs, ys) {
   if (xs.length < 2 || xs.length !== ys.length) return null;
   if (xs.length < 2 || xs.length !== ys.length) return null;
   const mx = media(xs);
   const mx = media(xs);

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

@@ -4,6 +4,7 @@ import {
   salvarAtendimento,
   salvarAtendimento,
   sincronizarProtocolosAbertos,
   sincronizarProtocolosAbertos,
   sincronizarTodosProtocolosAbertos,
   sincronizarTodosProtocolosAbertos,
+  sincronizarAtendimentoPorId,
   listarAtendimentos,
   listarAtendimentos,
   obterAtendimento
   obterAtendimento
 } from "../services/atendimentosService.js";
 } 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) {
   ListarAvaliacoes: async function (req, res, next) {
     try {
     try {
       const page = Math.max(1, Number(req.query.page) || 1);
       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("/", requireUser, AtendimentosController.Listar);
 atendimentosRouter.get("/:id", requireUser, parseId, AtendimentosController.Obter);
 atendimentosRouter.get("/:id", requireUser, parseId, AtendimentosController.Obter);
 atendimentosRouter.post("/:id/avaliar", requireUser, requireAdmin, parseId, AtendimentosController.AvaliarUm);
 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);
 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 { config } from "../config/index.js";
 import { chatCompletion } from "./ollamaClient.js";
 import { chatCompletion } from "./ollamaClient.js";
 import { buildConversaTexto, calcularUltimaMensagemId, SCORE_RUIM_MAX } from "../utils/atendimentoFormat.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 { ingestarAtendimento } from "./atendimentoRagService.js";
 import { Atendimento } from "../models/Atendimento.model.js";
 import { Atendimento } from "../models/Atendimento.model.js";
 import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.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",
   "julgue nem penalize pelo desfecho técnico registrado no ERP, que pode depender de",
   "fatores fora do controle do atendente do chat.",
   "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',
   '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",
   "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",
   "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",
   "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.",
   "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",
   "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:",
   "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",
   "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.",
   "Um atendimento resolvido com cliente satisfeito NUNCA recebe score abaixo de 6.",
   "Se nenhum atendente humano participou, use score_atendente = null.",
   "Se nenhum atendente humano participou, use score_atendente = null.",
   "",
   "",
@@ -108,6 +132,32 @@ const AVALIACAO_SYSTEM_PROMPT = [
   "  foi processado.",
   "  foi processado.",
   '→ motivo_cancelamento: "Mudança de cidade."',
   '→ 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:",
   "Campos:",
   '- resolvido: "sim" se o problema do cliente foi claramente resolvido na conversa; "parcial" se houve',
   '- 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.',
   '  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;',
   '- 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.',
   '  "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).",
   "- 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',
   '- 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").',
   '  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";
 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 ==="];
   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(
       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 ===");
   linhas.push("=== fim do dado do ERP ===");
   return linhas.join("\n");
   return linhas.join("\n");
 }
 }
@@ -295,9 +393,14 @@ export async function gerarAvaliacaoViaLLM(atendimento, mensagens) {
     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);
     : truncateConversa(conversaTexto);
 
 
   const model = config.avaliacao.model || config.ollama.chatModel;
   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"
 // status: "ok" | "sem_erp_protocolo" | "sem_ticket" | "erro"
 // contexto só é preenchido quando status === "ok"; erro só quando status === "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 { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
 import { atendimentoPayloadSchema, protocolosAbertosSchema, protocoloMensagensSchema } from "../middleware/schemas/Atendimento.Schema.js";
 import { atendimentoPayloadSchema, protocolosAbertosSchema, protocoloMensagensSchema } from "../middleware/schemas/Atendimento.Schema.js";
 import { assertPublicHttpsUrl } from "./ingestService.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 MAX_REDIRECTS = 5;
 const MENSAGENS_BATCH = 500;
 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) {
 export async function importarProtocolo(item, baseUrl) {
   const mensagensUrl = `${baseUrl}/protocolos/${item.Id}/mensagens`;
   const mensagensUrl = `${baseUrl}/protocolos/${item.Id}/mensagens`;
   const json = await fetchJson(mensagensUrl, ifbotAuthHeaders());
   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({
   return salvarAtendimento({
     payload,
     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 } = {}) {
 export async function sincronizarProtocolosAbertos({ pagina = 1, limite = 50, inicio, fim } = {}) {
   const baseUrl = config.ifbot.baseUrl.replace(/\/+$/, "");
   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;
 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__"]);
 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"]);
 export const AUDIO_MEDIA_TYPES = new Set(["mpga", "mp3", "audio", "oga"]);
 const PLACEHOLDER_AUDIO_SEM_TRANSCRICAO = "[áudio sem transcrição disponível]";
 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)) {
   } else if (AUDIO_MEDIA_TYPES.has(m.Tipodemidia)) {
     texto = PLACEHOLDER_AUDIO_SEM_TRANSCRICAO;
     texto = PLACEHOLDER_AUDIO_SEM_TRANSCRICAO;
   } else if (m.Tipodemidia && m.Tipodemidia !== "text" && m.Tipodemidia !== "ifstatus") {
   } 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}]`;
     texto = `[mídia: ${m.Tipodemidia}]`;
   }
   }
 
 
@@ -192,9 +189,7 @@ export function calcularUltimaMensagemId(mensagens) {
 export const SCORE_RUIM_MAX = 5;
 export const SCORE_RUIM_MAX = 5;
 export const SCORE_BOM_MIN = 8;
 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 }) {
 export function classificarQualidadeAtendimento({ scoreAtendente, resolvido, sentimento }) {
   if (scoreAtendente === null || scoreAtendente === undefined) return null;
   if (scoreAtendente === null || scoreAtendente === undefined) return null;
   if (scoreAtendente >= SCORE_BOM_MIN && ["sim", "parcial"].includes(resolvido) && sentimento !== "negativo") {
   if (scoreAtendente >= SCORE_BOM_MIN && ["sim", "parcial"].includes(resolvido) && sentimento !== "negativo") {
@@ -204,8 +199,7 @@ export function classificarQualidadeAtendimento({ scoreAtendente, resolvido, sen
   return "neutra";
   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) {
 export function buildBlocoAvaliacaoParaRag(avaliacao) {
   if (!avaliacao?.Avaliavel) return null;
   if (!avaliacao?.Avaliavel) return null;
   const nota = avaliacao.ScoreAtendente != null ? `${avaliacao.ScoreAtendente}/10` : "não aplicável";
   const nota = avaliacao.ScoreAtendente != null ? `${avaliacao.ScoreAtendente}/10` : "não aplicável";