Bläddra i källkod

fix: zera temperature do avaliador de atendimentos para eliminar ruído entre execuções

leonardo 1 månad sedan
förälder
incheckning
300b52e49d

+ 1 - 0
package.json

@@ -32,6 +32,7 @@
     "objection": "^3.1.5",
     "objection-unique": "^1.2.2",
     "pdf-parse": "^1.1.1",
+    "pdfkit": "^0.19.1",
     "zod": "^3.23.8"
   },
   "engines": {

+ 172 - 0
scripts/medirVarianciaAvaliacao.js

@@ -0,0 +1,172 @@
+import "dotenv/config";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { Atendimento } from "../src/models/Atendimento.model.js";
+import { AtendimentoAvaliacao } from "../src/models/AtendimentoAvaliacao.model.js";
+import { gerarAvaliacaoViaLLM } from "../src/services/atendimentoAvaliacaoService.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+function argValor(nome, padrao) {
+  const idx = process.argv.indexOf(`--${nome}`);
+  return idx > -1 ? process.argv[idx + 1] : padrao;
+}
+
+const AMOSTRA = Number(argValor("amostra", 20));
+const REPETICOES = Number(argValor("repeticoes", 5));
+const SETOR = argValor("setor", null);
+const IDS = argValor("ids", null)
+  ?.split(",")
+  .map((s) => Number(s.trim()))
+  .filter(Number.isFinite) ?? null;
+
+const CAMPOS_CATEGORICOS = ["Resolvido", "Sentimento", "StatusCancelamento"];
+
+function media(valores) {
+  return valores.reduce((a, b) => a + b, 0) / valores.length;
+}
+
+function desvioPadrao(valores) {
+  if (valores.length < 2) return 0;
+  const m = media(valores);
+  const variancia = media(valores.map((v) => (v - m) ** 2));
+  return Math.sqrt(variancia);
+}
+
+async function selecionarAmostra() {
+  if (IDS?.length) return IDS.map((Id) => ({ Id }));
+
+  let query = AtendimentoAvaliacao.query()
+    .where("Avaliavel", true)
+    .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
+    .select("atendimento_avaliacoes.AtendimentoId as Id")
+    .orderByRaw("RAND()")
+    .limit(AMOSTRA);
+
+  if (SETOR) query = query.where("a.Setor", SETOR);
+
+  return query;
+}
+
+async function avaliarRepetidamente(atendimentoId) {
+  const atendimento = await Atendimento.query()
+    .findById(atendimentoId)
+    .withGraphFetched("[cliente, mensagens]")
+    .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
+
+  const mensagens = atendimento.mensagens ?? [];
+  const execucoes = [];
+
+  for (let i = 0; i < REPETICOES; i++) {
+    const { dados } = await gerarAvaliacaoViaLLM(atendimento, mensagens);
+    execucoes.push(dados);
+  }
+
+  return { atendimento, execucoes };
+}
+
+function metricasAtendimento(codigo, execucoes) {
+  const scores = execucoes.map((e) => e.ScoreAtendente).filter((s) => s !== null);
+  const scoreRange = scores.length ? Math.max(...scores) - Math.min(...scores) : null;
+  const scoreDesvio = scores.length ? Number(desvioPadrao(scores).toFixed(2)) : null;
+
+  const concordancia = {};
+  for (const campo of CAMPOS_CATEGORICOS) {
+    const valores = execucoes.map((e) => e[campo]);
+    concordancia[campo] = new Set(valores).size === 1;
+  }
+
+  return {
+    codigo,
+    scores,
+    scoreRange,
+    scoreDesvio,
+    concordancia
+  };
+}
+
+function resumoAgregado(metricas) {
+  const comScore = metricas.filter((m) => m.scoreRange !== null);
+  const rangeMedio = comScore.length ? Number(media(comScore.map((m) => m.scoreRange)).toFixed(2)) : null;
+  const desvioMedio = comScore.length ? Number(media(comScore.map((m) => m.scoreDesvio)).toFixed(2)) : null;
+
+  const distribuicaoRange = { range0: 0, range1: 0, range2mais: 0 };
+  for (const m of comScore) {
+    if (m.scoreRange === 0) distribuicaoRange.range0 += 1;
+    else if (m.scoreRange === 1) distribuicaoRange.range1 += 1;
+    else distribuicaoRange.range2mais += 1;
+  }
+
+  const taxaConcordancia = {};
+  for (const campo of CAMPOS_CATEGORICOS) {
+    const concordantes = metricas.filter((m) => m.concordancia[campo]).length;
+    taxaConcordancia[campo] = Number(((concordantes / metricas.length) * 100).toFixed(1));
+  }
+
+  return {
+    totalAtendimentos: metricas.length,
+    scoreRangeMedio: rangeMedio,
+    scoreDesvioMedio: desvioMedio,
+    distribuicaoRange,
+    taxaConcordanciaPercent: taxaConcordancia
+  };
+}
+
+async function main() {
+  const alvos = await selecionarAmostra();
+  const total = alvos.length;
+  console.log(`[variancia-avaliacao] amostra: ${total} atendimentos | ${REPETICOES} repetições cada | ${total * REPETICOES} chamadas ao LLM`);
+
+  const metricas = [];
+  const detalhes = [];
+  let processados = 0;
+  let erros = 0;
+  const t0 = Date.now();
+
+  for (const { Id } of alvos) {
+    try {
+      const { atendimento, execucoes } = await avaliarRepetidamente(Id);
+      const m = metricasAtendimento(atendimento.Codigo, execucoes);
+      metricas.push(m);
+      detalhes.push({ atendimentoId: Id, codigo: atendimento.Codigo, execucoes });
+    } catch (err) {
+      erros += 1;
+      console.warn(`[variancia-avaliacao] erro no atendimento ${Id}: ${err.message}`);
+    } finally {
+      processados += 1;
+      const s = Math.round((Date.now() - t0) / 1000);
+      const ritmo = s / processados;
+      const restamMin = Math.round(((total - processados) * ritmo) / 60);
+      console.log(
+        `[variancia-avaliacao] progresso: ${processados}/${total} | erros: ${erros} | ${Math.round(s / 60)}min decorridos | ~${restamMin}min restantes`
+      );
+    }
+  }
+
+  const resumo = resumoAgregado(metricas);
+
+  console.log("\n=== Resumo ===");
+  console.log(`Atendimentos processados: ${resumo.totalAtendimentos} (erros: ${erros})`);
+  console.log(`Score — range médio: ${resumo.scoreRangeMedio} | desvio padrão médio: ${resumo.scoreDesvioMedio}`);
+  console.log(
+    `Distribuição de range: estável (0) = ${resumo.distribuicaoRange.range0} | range 1 = ${resumo.distribuicaoRange.range1} | range ≥2 = ${resumo.distribuicaoRange.range2mais}`
+  );
+  console.log("Concordância entre execuções (campos categóricos):");
+  for (const [campo, pct] of Object.entries(resumo.taxaConcordanciaPercent)) {
+    console.log(`  ${campo}: ${pct}%`);
+  }
+
+  const backupDir = path.resolve(__dirname, "logs");
+  fs.mkdirSync(backupDir, { recursive: true });
+  const backupPath = path.join(backupDir, `variancia-avaliacao-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
+  fs.writeFileSync(backupPath, JSON.stringify({ config: { amostra: AMOSTRA, repeticoes: REPETICOES, setor: SETOR, ids: IDS }, resumo, metricas, detalhes }, null, 2));
+  console.log(`\n[variancia-avaliacao] resultado completo salvo em ${backupPath}`);
+
+  process.exit(0);
+}
+
+main().catch((err) => {
+  console.error("[variancia-avaliacao] falha fatal:", err);
+  process.exit(1);
+});

+ 76 - 0
scripts/reavaliarComTemperatureZero.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-temp0] 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-temp0] janela reaberta, retomando");
+}
+
+async function main() {
+  let query = Atendimento.query()
+    .join("atendimento_avaliacoes as av", "av.AtendimentoId", "atendimentos.Id")
+    .where("av.Avaliavel", true)
+    .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-temp0] 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-temp0] 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-temp0] progresso: ${processados}/${total} | erros: ${erros} | ${Math.round(s / 60)}min decorridos | ~${restamMin}min restantes`
+        );
+      }
+    }
+  }
+
+  console.log(`[reavaliar-temp0] concluído: ${processados}/${total} | erros: ${erros} | ${Math.round((Date.now() - t0) / 60000)}min total`);
+  process.exit(0);
+}
+
+main().catch((err) => {
+  console.error("[reavaliar-temp0] falha fatal:", err);
+  process.exit(1);
+});

+ 82 - 0
scripts/statusBackfillTemperature.js

@@ -0,0 +1,82 @@
+import fs from "node:fs";
+import path from "node:path";
+import { execSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const LOGS_DIR = path.resolve(__dirname, "logs");
+
+function logMaisRecente() {
+  const candidatos = fs.readdirSync(LOGS_DIR)
+    .filter((f) => f.startsWith("reavaliar-temperature-zero-") && f.endsWith(".log"))
+    .map((f) => ({ f, mtime: fs.statSync(path.join(LOGS_DIR, f)).mtimeMs }))
+    .sort((a, b) => b.mtime - a.mtime);
+  return candidatos[0]?.f ?? null;
+}
+
+function processoRodando() {
+  try {
+    const out = execSync("ps aux | grep reavaliarComTemperatureZero | grep -v grep", { encoding: "utf8" });
+    return out.trim().length > 0;
+  } catch {
+    return false; 
+  }
+}
+
+function barraProgresso(pct, largura = 30) {
+  const preenchido = Math.round((pct / 100) * largura);
+  return "█".repeat(preenchido) + "░".repeat(largura - preenchido);
+}
+
+function main() {
+  const arquivo = logMaisRecente();
+  if (!arquivo) {
+    console.log("Nenhum log de reavaliar-temperature-zero encontrado em scripts/logs/.");
+    return;
+  }
+
+  const conteudo = fs.readFileSync(path.join(LOGS_DIR, arquivo), "utf8");
+  const linhas = conteudo.trim().split("\n");
+
+  const concluido = linhas.find((l) => l.includes("[reavaliar-temp0] concluído"));
+  const falha = linhas.find((l) => l.includes("falha fatal"));
+  const progressoLinhas = linhas.filter((l) => l.includes("[reavaliar-temp0] progresso:"));
+  const ultimaJanela = [...linhas].reverse().find((l) => l.includes("janela"));
+
+  console.log(`Log: ${arquivo}`);
+  console.log(`Processo rodando agora: ${processoRodando() ? "sim" : "não"}`);
+
+  if (falha) {
+    console.log(`\n⚠ FALHA FATAL: ${falha}`);
+    return;
+  }
+
+  if (concluido) {
+    console.log(`\n✔ ${concluido}`);
+    return;
+  }
+
+  if (!progressoLinhas.length) {
+    console.log("\nAinda sem nenhuma linha de progresso (ou está fora da janela, aguardando reabrir).");
+    if (ultimaJanela) console.log(ultimaJanela);
+    return;
+  }
+
+  const ultima = progressoLinhas[progressoLinhas.length - 1];
+  const m = /progresso: (\d+)\/(\d+) \| erros: (\d+) \| (\d+)min decorridos \| ~(\d+)min restantes/.exec(ultima);
+  if (!m) {
+    console.log("\nNão consegui parsear a última linha de progresso:", ultima);
+    return;
+  }
+
+  const [, processados, total, erros, decorridos, restantes] = m.map(Number);
+  const pct = Math.round((processados / total) * 100);
+
+  console.log(`\n${barraProgresso(pct)} ${pct}%`);
+  console.log(`${processados}/${total} atendimentos | erros: ${erros}`);
+  console.log(`${decorridos}min decorridos | ~${restantes}min restantes (~${Math.round(restantes / 60)}h)`);
+  if (ultimaJanela && ultimaJanela !== ultima) console.log(`\n${ultimaJanela}`);
+}
+
+main();

+ 23 - 45
src/controllers/Atendimentos.Controller.js

@@ -14,7 +14,9 @@ import {
   estatisticasAvaliacoes,
   listarSetores
 } from "../services/atendimentoAvaliacaoService.js";
+import { gerarRelatorioNaoResolvidosPdf } from "../services/relatorioNaoResolvidosPdfService.js";
 import { estruturarMensagem, montarUrlMidia } from "../utils/atendimentoFormat.js";
+import { parseFiltrosAvaliacoes } from "../utils/atendimentoAvaliacaoFiltros.js";
 import { config } from "../config/index.js";
 
 export const AtendimentosController = {
@@ -81,8 +83,7 @@ export const AtendimentosController = {
       const atendimento = await obterAtendimento(req.params.id);
       if (!atendimento) throw new NotFoundError("atendimento_not_found");
 
-      // o Body cru vem com HTML do ifbot (nome do atendente embutido em "<b>Nome:</b>");
-      // entrega já estruturado para a tela de conversa não repetir esse parse
+      
       const mensagens = (atendimento.mensagens ?? [])
         .map((m) => {
           const { papel, autor, texto } = estruturarMensagem(m);
@@ -130,35 +131,9 @@ export const AtendimentosController = {
     try {
       const page = Math.max(1, Number(req.query.page) || 1);
       const pageSize = Math.min(100, Math.max(1, Number(req.query.pageSize) || 20));
-      const setor = typeof req.query.setor === "string" && req.query.setor.trim() ? req.query.setor.trim() : undefined;
-      const resolvido = typeof req.query.resolvido === "string" && req.query.resolvido.trim() ? req.query.resolvido.trim() : undefined;
-      const sentimento = typeof req.query.sentimento === "string" && req.query.sentimento.trim() ? req.query.sentimento.trim() : undefined;
-      const statusCancelamento = typeof req.query.statusCancelamento === "string" && req.query.statusCancelamento.trim() ? req.query.statusCancelamento.trim() : undefined;
-      const scoreMax = req.query.scoreMax !== undefined ? Number(req.query.scoreMax) : undefined;
-      const busca = typeof req.query.busca === "string" && req.query.busca.trim() ? req.query.busca.trim() : undefined;
-      const ordenacao = req.query.ordenacao === "asc" ? "asc" : "desc";
-      const dataInicio =
-        typeof req.query.dataInicio === "string" && /^\d{4}-\d{2}-\d{2}$/.test(req.query.dataInicio.trim())
-          ? req.query.dataInicio.trim()
-          : undefined;
-      const dataFim =
-        typeof req.query.dataFim === "string" && /^\d{4}-\d{2}-\d{2}$/.test(req.query.dataFim.trim())
-          ? req.query.dataFim.trim()
-          : undefined;
-
-      const result = await listarAvaliacoes({
-        page,
-        pageSize,
-        setor,
-        resolvido,
-        sentimento,
-        statusCancelamento,
-        scoreMax: Number.isFinite(scoreMax) ? scoreMax : undefined,
-        busca,
-        ordenacao,
-        dataInicio,
-        dataFim
-      });
+      const filtros = parseFiltrosAvaliacoes(req.query);
+
+      const result = await listarAvaliacoes({ page, pageSize, ...filtros });
       res.json(result);
     } catch (err) {
       next(err);
@@ -167,20 +142,8 @@ export const AtendimentosController = {
 
   EstatisticasAvaliacoes: async function (req, res, next) {
     try {
-      const setor = typeof req.query.setor === "string" && req.query.setor.trim() ? req.query.setor.trim() : undefined;
-      const resolvido = typeof req.query.resolvido === "string" && req.query.resolvido.trim() ? req.query.resolvido.trim() : undefined;
-      const sentimento = typeof req.query.sentimento === "string" && req.query.sentimento.trim() ? req.query.sentimento.trim() : undefined;
-      const statusCancelamento = typeof req.query.statusCancelamento === "string" && req.query.statusCancelamento.trim() ? req.query.statusCancelamento.trim() : undefined;
-      const busca = typeof req.query.busca === "string" && req.query.busca.trim() ? req.query.busca.trim() : undefined;
-      const dataInicio =
-        typeof req.query.dataInicio === "string" && /^\d{4}-\d{2}-\d{2}$/.test(req.query.dataInicio.trim())
-          ? req.query.dataInicio.trim()
-          : undefined;
-      const dataFim =
-        typeof req.query.dataFim === "string" && /^\d{4}-\d{2}-\d{2}$/.test(req.query.dataFim.trim())
-          ? req.query.dataFim.trim()
-          : undefined;
-      const result = await estatisticasAvaliacoes({ setor, resolvido, sentimento, statusCancelamento, busca, dataInicio, dataFim });
+      const filtros = parseFiltrosAvaliacoes(req.query);
+      const result = await estatisticasAvaliacoes(filtros);
       res.json(result);
     } catch (err) {
       next(err);
@@ -194,6 +157,21 @@ export const AtendimentosController = {
     } catch (err) {
       next(err);
     }
+  },
+
+  GerarRelatorioNaoResolvidos: async function (req, res, next) {
+    try {
+      const filtros = parseFiltrosAvaliacoes(req.query);
+      const { buffer } = await gerarRelatorioNaoResolvidosPdf({ ...filtros, resolvido: "nao" });
+
+      const dataArquivo = new Date().toISOString().slice(0, 10);
+      res.setHeader("Content-Type", "application/pdf");
+      res.setHeader("Content-Disposition", `attachment; filename="atendimentos-nao-resolvidos-${dataArquivo}.pdf"`);
+      res.setHeader("Content-Length", buffer.length);
+      res.send(buffer);
+    } catch (err) {
+      next(err);
+    }
   }
 };
 

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

@@ -13,6 +13,7 @@ atendimentosRouter.post("/url", requireUser, requireAdmin, validate({ body: aten
 atendimentosRouter.post("/sync", requireUser, requireAdmin, validate({ body: atendimentoSyncSchema }), AtendimentosController.Sincronizar);
 atendimentosRouter.post("/sync-all", requireUser, requireAdmin, validate({ body: atendimentoSyncAllSchema }), AtendimentosController.SincronizarTudo);
 atendimentosRouter.post("/avaliar", requireUser, requireAdmin, validate({ body: atendimentoAvaliarSchema }), AtendimentosController.AvaliarPendentes);
+atendimentosRouter.get("/avaliacoes/relatorio", requireUser, requireAdmin, AtendimentosController.GerarRelatorioNaoResolvidos);
 atendimentosRouter.get("/avaliacoes", requireUser, requireAdmin, AtendimentosController.ListarAvaliacoes);
 atendimentosRouter.get("/avaliacoes/estatisticas", requireUser, requireAdmin, AtendimentosController.EstatisticasAvaliacoes);
 atendimentosRouter.get("/avaliacoes/setores", requireUser, requireAdmin, AtendimentosController.ListarSetoresAvaliacoes);

+ 72 - 38
src/services/atendimentoAvaliacaoService.js

@@ -6,9 +6,11 @@ import { ingestarAtendimento } from "./atendimentoRagService.js";
 import { Atendimento } from "../models/Atendimento.model.js";
 import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
-import { NotFoundError } from "../shared/errors/index.js";
+import { NotFoundError, UnprocessableEntityError } from "../shared/errors/index.js";
 import { nomeSetor } from "../config/setores.js";
 
+const LIMITE_RELATORIO_PADRAO = 150;
+
 const AVALIACAO_SYSTEM_PROMPT = [
   "Você é um auditor de qualidade de atendimento ao cliente de um provedor de internet (Star).",
   "Avalie a conversa abaixo entre cliente e atendente(s), extraída do sistema de atendimento (ifbot).",
@@ -286,6 +288,41 @@ async function upsertAvaliacao(atendimentoId, dados) {
   return AtendimentoAvaliacao.query().findById(atendimentoId);
 }
 
+export async function gerarAvaliacaoViaLLM(atendimento, mensagens) {
+  const conversaTexto = buildConversaTexto({
+    atendimento,
+    cliente: atendimento.cliente,
+    mensagens
+  });
+
+  const { status: contextoIxcStatus, contexto: contextoIxc } = await buscarContextoIxc(atendimento);
+  const conteudoUsuario = contextoIxc
+    ? `${formatarContextoIxc(contextoIxc)}\n\n${truncateConversa(conversaTexto)}`
+    : truncateConversa(conversaTexto);
+
+  const model = config.avaliacao.model || config.ollama.chatModel;
+  const { content } = await chatCompletion({
+    model,
+    messages: [
+      { role: "system", content: AVALIACAO_SYSTEM_PROMPT },
+      { role: "user", content: conteudoUsuario }
+    ],
+    format: AVALIACAO_JSON_SCHEMA,
+    options: { temperature: 0, num_ctx: 8192, num_predict: 1024 }
+  });
+
+  let dados;
+  try {
+    dados = parseAvaliacao(content);
+  } catch {
+    const err = new Error("avaliacao_json_invalido");
+    err.statusCode = 502;
+    throw err;
+  }
+
+  return { dados, contextoIxcStatus, model };
+}
+
 export async function avaliarAtendimento(atendimentoId) {
   const atendimento = await Atendimento.query()
     .findById(atendimentoId)
@@ -298,8 +335,6 @@ export async function avaliarAtendimento(atendimentoId) {
   const ultimaMensagemId = calcularUltimaMensagemId(mensagens);
   const { cliente: msgsCliente, atendente: msgsAtendente } = contarInteracoes(mensagens);
 
-  // sem diálogo real não há o que avaliar; grava como não-avaliável para sair da fila
-  // (volta a ficar pendente se chegarem mensagens novas)
   if (msgsCliente === 0 || msgsAtendente === 0) {
     const avaliacao = await upsertAvaliacao(atendimento.Id, {
       Avaliavel: false,
@@ -323,36 +358,7 @@ export async function avaliarAtendimento(atendimentoId) {
     return { avaliado: false, motivo: "sem_dialogo", avaliacao };
   }
 
-  const conversaTexto = buildConversaTexto({
-    atendimento,
-    cliente: atendimento.cliente,
-    mensagens
-  });
-
-  const { status: contextoIxcStatus, contexto: contextoIxc } = await buscarContextoIxc(atendimento);
-  const conteudoUsuario = contextoIxc
-    ? `${formatarContextoIxc(contextoIxc)}\n\n${truncateConversa(conversaTexto)}`
-    : truncateConversa(conversaTexto);
-
-  const model = config.avaliacao.model || config.ollama.chatModel;
-  const { content } = await chatCompletion({
-    model,
-    messages: [
-      { role: "system", content: AVALIACAO_SYSTEM_PROMPT },
-      { role: "user", content: conteudoUsuario }
-    ],
-    format: AVALIACAO_JSON_SCHEMA,
-    options: { temperature: 0.1, num_ctx: 8192, num_predict: 1024 }
-  });
-
-  let dados;
-  try {
-    dados = parseAvaliacao(content);
-  } catch {
-    const err = new Error("avaliacao_json_invalido");
-    err.statusCode = 502;
-    throw err;
-  }
+  const { dados, contextoIxcStatus, model } = await gerarAvaliacaoViaLLM(atendimento, mensagens);
 
   const avaliacao = await upsertAvaliacao(atendimento.Id, {
     Avaliavel: true,
@@ -447,13 +453,14 @@ function parseDataBusca(termo) {
   return null;
 }
 
-export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao, dataInicio, dataFim } = {}) {
+// monta a query base de avaliações (filtros comuns a listagem, estatísticas e relatório);
+// não pagina — quem chama decide se aplica .page()/.limit()
+function construirQueryAvaliacoes({ setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao, dataInicio, dataFim } = {}) {
   // ordena por Abertura (data do atendimento, é o que aparece no card) e não por
   // UpdatedAt (data da avaliação da IA) — os dois divergem e ordenar pelo errado
   // deixa a lista com datas aparentemente fora de ordem
   const query = AtendimentoAvaliacao.query()
     .where("Avaliavel", true)
-    .page(page - 1, pageSize)
     .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
     .select("atendimento_avaliacoes.*")
     .orderBy("a.Abertura", ordenacao === "asc" ? "asc" : "desc");
@@ -481,16 +488,18 @@ export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvi
     });
   }
 
-  const { results, total } = await query;
+  return query;
+}
 
-  // junta código/cliente/setor do atendimento para exibição
+// junta código/cliente/setor do atendimento para exibição
+async function enriquecerComAtendimento(results) {
   const ids = results.map((r) => r.AtendimentoId);
   const atendimentos = ids.length
     ? await Atendimento.query().findByIds(ids).withGraphFetched("cliente")
     : [];
   const byId = new Map(atendimentos.map((a) => [a.Id, a]));
 
-  const items = results.map((r) => {
+  return results.map((r) => {
     const a = byId.get(r.AtendimentoId);
     return {
       ...r,
@@ -501,10 +510,35 @@ export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvi
       ClienteNome: a?.cliente?.Nome ?? null
     };
   });
+}
+
+export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao, dataInicio, dataFim } = {}) {
+  const query = construirQueryAvaliacoes({ setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao, dataInicio, dataFim })
+    .page(page - 1, pageSize);
+
+  const { results, total } = await query;
+  const items = await enriquecerComAtendimento(results);
 
   return { results: items, total, page, pageSize };
 }
 
+// variante sem paginação para o relatório em PDF: busca até `limite` itens (checando +1 pra
+// detectar estouro sem precisar de uma 2ª query de count)
+export async function listarAvaliacoesParaRelatorio({ setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao, dataInicio, dataFim, limite = LIMITE_RELATORIO_PADRAO } = {}) {
+  const query = construirQueryAvaliacoes({ setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao, dataInicio, dataFim })
+    .limit(limite + 1);
+
+  const results = await query;
+
+  if (results.length > limite) {
+    const err = new UnprocessableEntityError("relatorio_muito_grande");
+    err.details = { limite, dica: "Estreite o período ou aplique mais filtros (setor, sentimento, busca) para gerar o relatório." };
+    throw err;
+  }
+
+  return enriquecerComAtendimento(results);
+}
+
 // setores com pelo menos uma avaliação, para popular o filtro da tela de avaliações
 export async function listarSetores() {
   const rows = await AtendimentoAvaliacao.query()

+ 88 - 0
src/services/relatorioNaoResolvidosPdfService.js

@@ -0,0 +1,88 @@
+import PDFDocument from "pdfkit";
+import { listarAvaliacoesParaRelatorio } from "./atendimentoAvaliacaoService.js";
+import { nomeSetor } from "../config/setores.js";
+
+const MARGEM_INFERIOR_PAGINA = 700;
+
+const RESOLVIDO_LABEL = { sim: "Resolvido", parcial: "Parcial", nao: "Não resolvido", indefinido: "Indefinido" };
+const SENTIMENTO_LABEL = { positivo: "Positivo", neutro: "Neutro", negativo: "Negativo", indefinido: "Indefinido" };
+
+function formatData(value) {
+  if (!value) return "—";
+  const d = new Date(value);
+  if (Number.isNaN(d.getTime())) return "—";
+  return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
+}
+
+function formatDataHora(value) {
+  const d = new Date(value);
+  return `${d.toLocaleDateString("pt-BR")} ${d.toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" })}`;
+}
+
+function descreverFiltros({ setor, sentimento, busca, dataInicio, dataFim }) {
+  const partes = [];
+  partes.push(`Período: ${dataInicio ? formatData(dataInicio) : "início"} a ${dataFim ? formatData(dataFim) : "hoje"}`);
+  if (setor) partes.push(`Setor: ${nomeSetor(setor)}`);
+  if (sentimento) partes.push(`Sentimento: ${SENTIMENTO_LABEL[sentimento] ?? sentimento}`);
+  if (busca) partes.push(`Busca: "${busca}"`);
+  return partes.join(" · ");
+}
+
+function montarMotivo(item) {
+  const partes = [item.Resumo, item.JustificativaScore].filter((p) => typeof p === "string" && p.trim());
+  return partes.length ? partes.join("\n\n") : "Sem resumo ou justificativa registrados.";
+}
+
+function renderizarItem(doc, item, isPrimeiro) {
+  if (!isPrimeiro) {
+    if (doc.y > MARGEM_INFERIOR_PAGINA) doc.addPage();
+    else {
+      doc.moveDown(0.5);
+      doc.moveTo(doc.x, doc.y).lineTo(555, doc.y).strokeColor("#ddd").stroke();
+      doc.moveDown(0.5);
+    }
+  }
+
+  doc.fontSize(12).fillColor("#000").text(`${item.Codigo ?? `#${item.AtendimentoId}`} — ${item.ClienteNome ?? "Cliente desconhecido"}`);
+  doc.fontSize(9).fillColor("#666").text(
+    `Abertura: ${formatData(item.Abertura)}   Setor: ${item.Setor ? nomeSetor(item.Setor) : "—"}   ` +
+      `Sentimento: ${SENTIMENTO_LABEL[item.Sentimento] ?? item.Sentimento ?? "—"}   ` +
+      `Resolvido: ${RESOLVIDO_LABEL[item.Resolvido] ?? item.Resolvido ?? "—"}   ` +
+      `Nota: ${item.ScoreAtendente ?? "—"}`
+  );
+
+  doc.moveDown(0.3).fontSize(10).fillColor("#000").text("Motivo:", { underline: true });
+  doc.fontSize(9).fillColor("#333").text(montarMotivo(item).slice(0, 1200));
+}
+
+function renderizarPdf({ itens, filtros }) {
+  return new Promise((resolve, reject) => {
+    const doc = new PDFDocument({ margin: 40, size: "A4", bufferPages: true });
+    const chunks = [];
+    doc.on("data", (chunk) => chunks.push(chunk));
+    doc.on("end", () => resolve(Buffer.concat(chunks)));
+    doc.on("error", reject);
+
+    doc.fontSize(16).fillColor("#000").text("Relatório de Atendimentos Não Resolvidos (IA)");
+    doc.fontSize(9).fillColor("#666")
+      .text(`Gerado em ${formatDataHora(new Date())}`)
+      .text(descreverFiltros(filtros))
+      .text(`Total de itens: ${itens.length}`);
+    doc.moveDown(1);
+    doc.fillColor("#000");
+
+    if (itens.length === 0) {
+      doc.fontSize(10).fillColor("#666").text("Nenhum atendimento não resolvido encontrado para os filtros selecionados.");
+    } else {
+      itens.forEach((item, idx) => renderizarItem(doc, item, idx === 0));
+    }
+
+    doc.end();
+  });
+}
+
+export async function gerarRelatorioNaoResolvidosPdf(filtros) {
+  const itens = await listarAvaliacoesParaRelatorio(filtros);
+  const buffer = await renderizarPdf({ itens, filtros });
+  return { buffer, totalItens: itens.length };
+}

+ 6 - 0
src/shared/errors/AppError.js

@@ -28,3 +28,9 @@ export class InternalServerError extends AppError {
     super(message, 500);
   }
 }
+
+export class UnprocessableEntityError extends AppError {
+  constructor(message = "Unprocessable Entity") {
+    super(message, 422);
+  }
+}

+ 2 - 1
src/shared/errors/index.js

@@ -3,5 +3,6 @@ export {
   NotFoundError,
   UnauthorizedError,
   BadRequestError,
-  InternalServerError
+  InternalServerError,
+  UnprocessableEntityError
 } from "./AppError.js";

+ 27 - 0
src/utils/atendimentoAvaliacaoFiltros.js

@@ -0,0 +1,27 @@
+function strOrUndef(value) {
+  return typeof value === "string" && value.trim() ? value.trim() : undefined;
+}
+
+function numOrUndef(value) {
+  if (value === undefined) return undefined;
+  const n = Number(value);
+  return Number.isFinite(n) ? n : undefined;
+}
+
+function dateOrUndef(value) {
+  return typeof value === "string" && /^\d{4}-\d{2}-\d{2}$/.test(value.trim()) ? value.trim() : undefined;
+}
+
+export function parseFiltrosAvaliacoes(query = {}) {
+  return {
+    setor: strOrUndef(query.setor),
+    resolvido: strOrUndef(query.resolvido),
+    sentimento: strOrUndef(query.sentimento),
+    statusCancelamento: strOrUndef(query.statusCancelamento),
+    scoreMax: numOrUndef(query.scoreMax),
+    busca: strOrUndef(query.busca),
+    ordenacao: query.ordenacao === "asc" ? "asc" : "desc",
+    dataInicio: dateOrUndef(query.dataInicio),
+    dataFim: dateOrUndef(query.dataFim)
+  };
+}