2 Commit-ok 809ee9d2bf ... 99dff3681b

Szerző SHA1 Üzenet Dátum
  GabrielRamison 99dff3681b Merge remote-tracking branch 'origin/main': modo atendimentos via MySQL + avaliação IA/treino 2 hónapja
  GabrielRamison 698a859e0b implementando treino agente 2 hónapja

+ 30 - 0
db/migrations/20260709120000_create_atendimento_avaliacoes_table.cjs

@@ -0,0 +1,30 @@
+exports.up = function (knex) {
+  return knex.schema.createTable("atendimento_avaliacoes", (t) => {
+    t.integer("AtendimentoId").unsigned().primary();
+    t.boolean("Avaliavel").notNullable().defaultTo(true); // false = conversa sem conteúdo suficiente para avaliar
+    t.tinyint("ScoreAtendente").unsigned().nullable(); // 1 a 10
+    t.text("JustificativaScore").nullable();
+    t.string("Resolvido", 12).notNullable().defaultTo("indefinido"); // sim | nao | parcial | indefinido
+    t.string("Sentimento", 12).notNullable().defaultTo("indefinido"); // positivo | neutro | negativo | indefinido
+    t.text("Resumo").nullable();
+    t.text("Sugestoes").nullable(); // JSON array de strings
+    t.text("Atendentes").nullable(); // JSON array com nomes dos atendentes identificados
+    t.boolean("VisitaAgendada").nullable();
+    t.string("HorarioVisitaInformado", 15).nullable(); // sim | nao | nao_se_aplica
+    t.string("HorarioVisita", 150).nullable();
+    t.string("Modelo", 100).nullable();
+    t.bigInteger("UltimaMensagemId").unsigned().nullable(); // última mensagem considerada; avaliação fica pendente de novo se chegarem mensagens mais novas
+    t.integer("TotalMensagens").unsigned().nullable();
+    t.timestamp("CreatedAt").notNullable().defaultTo(knex.fn.now());
+    t.timestamp("UpdatedAt").notNullable().defaultTo(knex.fn.now());
+
+    t.foreign("AtendimentoId").references("atendimentos.Id").onDelete("CASCADE");
+    t.index(["ScoreAtendente"]);
+    t.index(["Resolvido"]);
+    t.index(["Sentimento"]);
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.dropTable("atendimento_avaliacoes");
+};

+ 36 - 0
scripts/avaliarTudo.js

@@ -0,0 +1,36 @@
+// Roda a avaliação (estágio 1) em lotes até zerar a fila de pendentes.
+// Uso: node scripts/avaliarTudo.js [--lote 20]
+
+import { avaliarPendentes, contarPendentes } from "../src/services/atendimentoAvaliacaoService.js";
+
+const loteArg = process.argv.indexOf("--lote");
+const LOTE = loteArg > -1 ? Number(process.argv[loteArg + 1]) : 20;
+
+async function main() {
+  let restantes = await contarPendentes();
+  console.log(`[avaliar-tudo] ${restantes} pendentes, lotes de ${LOTE}`);
+
+  while (restantes > 0) {
+    const t0 = Date.now();
+    const r = await avaliarPendentes({ limite: LOTE });
+    restantes = await contarPendentes();
+    console.log(
+      `[avaliar-tudo] +${r.avaliados} avaliados, ${r.semDialogo} sem diálogo, ${r.erros.length} erros ` +
+      `(${((Date.now() - t0) / 1000).toFixed(0)}s) — restam ${restantes}`
+    );
+    if (r.erros.length) r.erros.slice(0, 3).forEach((e) => console.log("  erro:", e.atendimentoId, e.erro));
+    // lote sem nenhum progresso = algo estrutural errado; para em vez de girar para sempre
+    if (r.avaliados === 0 && r.semDialogo === 0) {
+      console.error("[avaliar-tudo] lote inteiro falhou, abortando");
+      process.exit(1);
+    }
+  }
+
+  console.log("[avaliar-tudo] fila zerada");
+  process.exit(0);
+}
+
+main().catch((e) => {
+  console.error("[avaliar-tudo] falha:", e.message);
+  process.exit(1);
+});

+ 196 - 0
scripts/exportarDatasetTreinamento.js

@@ -0,0 +1,196 @@
+// Exporta atendimentos bem avaliados (estágio 1) como dataset de SFT em JSONL
+// no formato chat ({"messages":[...]}), aceito por mlx-lm, unsloth e axolotl.
+//
+// Uso: node scripts/exportarDatasetTreinamento.js [opções]
+//   --score-min 8        nota mínima do atendente (default 8)
+//   --incluir-parcial    inclui Resolvido="parcial" além de "sim"
+//   --max-chars 16000    descarta conversas maiores que isso (contexto de treino)
+//   --valid-frac 0.1     fração para validação (default 0.1)
+//   --out ../training/data   diretório de saída (default training/data na raiz do repo)
+
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { AtendimentoAvaliacao } from "../src/models/AtendimentoAvaliacao.model.js";
+import { Atendimento } from "../src/models/Atendimento.model.js";
+import { stripHtml, parseAtendenteBody } from "../src/utils/atendimentoFormat.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+function parseArgs(argv) {
+  const args = {
+    scoreMin: 8,
+    incluirParcial: false,
+    maxChars: 16000,
+    validFrac: 0.1,
+    out: path.resolve(__dirname, "../../training/data")
+  };
+  for (let i = 2; i < argv.length; i += 1) {
+    const a = argv[i];
+    if (a === "--score-min") args.scoreMin = Number(argv[++i]);
+    else if (a === "--incluir-parcial") args.incluirParcial = true;
+    else if (a === "--max-chars") args.maxChars = Number(argv[++i]);
+    else if (a === "--valid-frac") args.validFrac = Number(argv[++i]);
+    else if (a === "--out") args.out = path.resolve(process.cwd(), argv[++i]);
+  }
+  return args;
+}
+
+const SETOR_NOMES = {
+  SUP: "suporte técnico",
+  FIN: "financeiro",
+  ATE: "atendimento geral",
+  VEN: "vendas",
+  POS: "pós-venda",
+  PLA: "planos",
+  SUI: "suporte interno"
+};
+
+function systemPrompt(setor) {
+  const area = SETOR_NOMES[setor] ?? (setor ? setor.toLowerCase() : "atendimento ao cliente");
+  return [
+    `Você é um atendente do setor de ${area} da Star Internet, atendendo clientes pelo WhatsApp.`,
+    "Seja cordial, objetivo e resolutivo: entenda o problema, faça as perguntas necessárias,",
+    "explique os passos com clareza e conduza o atendimento até a solução ou encaminhamento correto.",
+    "Responda em português brasileiro."
+  ].join(" ");
+}
+
+// remove telefones do texto (privacidade); preserva números de protocolo/valores curtos
+function scrubTelefones(text) {
+  return text
+    .replace(/\+?55\s?\(?\d{2}\)?\s?9?\d{4}[-\s]?\d{4}/g, "[telefone]")
+    .replace(/\(?\d{2}\)?\s9\d{4}[-\s]?\d{4}/g, "[telefone]");
+}
+
+function mensagemTexto(m) {
+  let texto;
+  if (m.Resposta === 1) {
+    texto = parseAtendenteBody(m.Body).texto;
+  } else {
+    texto = stripHtml(m.Body);
+  }
+  if (!texto && m.Tipodemidia && m.Tipodemidia !== "text") {
+    texto = `[mídia: ${m.Tipodemidia}]`;
+  }
+  return texto ? scrubTelefones(texto) : null;
+}
+
+// converte a conversa em turnos user/assistant: 0 = cliente (user), 1 = atendente (assistant);
+// mensagens de sistema (9) ficam de fora; consecutivas do mesmo papel são mescladas
+export function conversaParaMessages(atendimento, mensagens) {
+  const ordenadas = [...mensagens].sort((a, b) => {
+    const ta = a.Timestamp ? new Date(a.Timestamp).getTime() : 0;
+    const tb = b.Timestamp ? new Date(b.Timestamp).getTime() : 0;
+    return ta - tb || (Number(a.Id) || 0) - (Number(b.Id) || 0);
+  });
+
+  const turnos = [];
+  for (const m of ordenadas) {
+    if (m.Resposta !== 0 && m.Resposta !== 1) continue;
+    const texto = mensagemTexto(m);
+    if (!texto) continue;
+    const role = m.Resposta === 1 ? "assistant" : "user";
+    const anterior = turnos[turnos.length - 1];
+    if (anterior && anterior.role === role) anterior.content += `\n${texto}`;
+    else turnos.push({ role, content: texto });
+  }
+
+  // a amostra deve começar no cliente e terminar na resposta do atendente
+  while (turnos.length && turnos[0].role !== "user") turnos.shift();
+  while (turnos.length && turnos[turnos.length - 1].role !== "assistant") turnos.pop();
+
+  if (turnos.length < 4) return null; // exige pelo menos 2 trocas reais
+
+  return [{ role: "system", content: systemPrompt(atendimento.Setor) }, ...turnos];
+}
+
+// embaralhamento determinístico para split train/valid reproduzível
+function shuffleDeterministico(arr) {
+  let seed = 42;
+  const rand = () => {
+    seed = (seed * 1103515245 + 12345) % 2147483648;
+    return seed / 2147483648;
+  };
+  const out = [...arr];
+  for (let i = out.length - 1; i > 0; i -= 1) {
+    const j = Math.floor(rand() * (i + 1));
+    [out[i], out[j]] = [out[j], out[i]];
+  }
+  return out;
+}
+
+async function main() {
+  const args = parseArgs(process.argv);
+
+  const resolvidos = args.incluirParcial ? ["sim", "parcial"] : ["sim"];
+  const avaliacoes = await AtendimentoAvaliacao.query()
+    .where("Avaliavel", true)
+    .where("ScoreAtendente", ">=", args.scoreMin)
+    .whereIn("Resolvido", resolvidos)
+    .whereNot("Sentimento", "negativo");
+
+  console.log(`${avaliacoes.length} atendimentos passam no filtro (score>=${args.scoreMin}, resolvido in [${resolvidos}], sentimento != negativo)`);
+
+  const samples = [];
+  let descartadosCurtos = 0;
+  let descartadosLongos = 0;
+
+  for (const av of avaliacoes) {
+    const atendimento = await Atendimento.query()
+      .findById(av.AtendimentoId)
+      .withGraphFetched("mensagens")
+      .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
+    if (!atendimento) continue;
+
+    const messages = conversaParaMessages(atendimento, atendimento.mensagens ?? []);
+    if (!messages) {
+      descartadosCurtos += 1;
+      continue;
+    }
+    const chars = messages.reduce((n, m) => n + m.content.length, 0);
+    if (chars > args.maxChars) {
+      descartadosLongos += 1;
+      continue;
+    }
+    samples.push({ codigo: atendimento.Codigo, messages });
+  }
+
+  console.log(`${samples.length} amostras válidas (${descartadosCurtos} curtas demais, ${descartadosLongos} longas demais)`);
+  if (samples.length === 0) {
+    console.log("Nada a exportar — aguarde mais avaliações do estágio 1.");
+    process.exit(0);
+  }
+
+  const embaralhadas = shuffleDeterministico(samples);
+  const nValid = Math.max(1, Math.round(embaralhadas.length * args.validFrac));
+  const valid = embaralhadas.slice(0, nValid);
+  const train = embaralhadas.slice(nValid);
+
+  fs.mkdirSync(args.out, { recursive: true });
+  const toJsonl = (rows) => rows.map((s) => JSON.stringify({ messages: s.messages })).join("\n") + "\n";
+  fs.writeFileSync(path.join(args.out, "train.jsonl"), toJsonl(train));
+  fs.writeFileSync(path.join(args.out, "valid.jsonl"), toJsonl(valid));
+  fs.writeFileSync(
+    path.join(args.out, "manifest.json"),
+    JSON.stringify(
+      {
+        geradoEm: new Date().toISOString(),
+        filtro: { scoreMin: args.scoreMin, resolvidos, sentimentoExcluido: "negativo", maxChars: args.maxChars },
+        train: train.length,
+        valid: valid.length,
+        protocolos: embaralhadas.map((s) => s.codigo)
+      },
+      null,
+      2
+    )
+  );
+
+  console.log(`Exportado para ${args.out}: train.jsonl (${train.length}) + valid.jsonl (${valid.length})`);
+  process.exit(0);
+}
+
+main().catch((e) => {
+  console.error("ERRO:", e);
+  process.exit(1);
+});

+ 7 - 0
src/config/index.js

@@ -74,6 +74,13 @@ export const config = {
     token: process.env.IFBOT_TOKEN ?? "",
     authHeader: process.env.IFBOT_AUTH_HEADER ?? "Authorization",
     syncIntervalMinutes: Number(process.env.ATENDIMENTOS_SYNC_INTERVAL_MINUTES ?? 30)
+  },
+  avaliacao: {
+    // modelo usado como juiz; vazio usa o chatModel do Ollama
+    model: process.env.OLLAMA_JUDGE_MODEL ?? "",
+    intervalMinutes: Number(process.env.ATENDIMENTOS_AVALIACAO_INTERVAL_MINUTES ?? 60),
+    batchSize: Number(process.env.ATENDIMENTOS_AVALIACAO_BATCH ?? 10),
+    maxChars: Number(process.env.ATENDIMENTOS_AVALIACAO_MAX_CHARS ?? 12000)
   }
 };
 

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

@@ -7,6 +7,12 @@ import {
   listarAtendimentos,
   obterAtendimento
 } from "../services/atendimentosService.js";
+import {
+  avaliarAtendimento,
+  avaliarPendentes,
+  listarAvaliacoes,
+  estatisticasAvaliacoes
+} from "../services/atendimentoAvaliacaoService.js";
 
 export const AtendimentosController = {
   Ingerir: async function (req, res, next) {
@@ -75,6 +81,57 @@ export const AtendimentosController = {
     } catch (err) {
       next(err);
     }
+  },
+
+  AvaliarPendentes: async function (req, res, next) {
+    try {
+      const result = await avaliarPendentes(req.body);
+      res.json({ ok: true, ...result });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  AvaliarUm: async function (req, res, next) {
+    try {
+      const result = await avaliarAtendimento(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);
+      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 scoreMax = req.query.scoreMax !== undefined ? Number(req.query.scoreMax) : undefined;
+
+      const result = await listarAvaliacoes({
+        page,
+        pageSize,
+        setor,
+        resolvido,
+        sentimento,
+        scoreMax: Number.isFinite(scoreMax) ? scoreMax : undefined
+      });
+      res.json(result);
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  EstatisticasAvaliacoes: async function (req, res, next) {
+    try {
+      const setor = typeof req.query.setor === "string" && req.query.setor.trim() ? req.query.setor.trim() : undefined;
+      const result = await estatisticasAvaliacoes({ setor });
+      res.json(result);
+    } catch (err) {
+      next(err);
+    }
   }
 };
 

+ 2 - 0
src/factories/Server.factory.js

@@ -10,6 +10,7 @@ import { errorHandler } from "../middleware/ErrorHandler.js";
 import { Roteamento } from "../routes/index.js";
 import { scheduleCleanup } from "../jobs/cleanupTokens.js";
 import { scheduleSyncAtendimentos } from "../jobs/syncAtendimentos.js";
+import { scheduleAvaliarAtendimentos } from "../jobs/avaliarAtendimentos.js";
 
 export class ServerFactory {
   static Iniciar() {
@@ -55,6 +56,7 @@ export class ServerFactory {
       process.stdout.write(`API listening on http://localhost:${port}\n`);
       scheduleCleanup();
       scheduleSyncAtendimentos();
+      scheduleAvaliarAtendimentos();
     });
 
     this.app = app;

+ 27 - 0
src/jobs/avaliarAtendimentos.js

@@ -0,0 +1,27 @@
+import { config } from "../config/index.js";
+import { avaliarPendentes } from "../services/atendimentoAvaliacaoService.js";
+
+export async function runAvaliarAtendimentos() {
+  const r = await avaliarPendentes();
+  console.log(
+    `[avaliar-atendimentos] ${r.avaliados} avaliados, ${r.semDialogo} sem diálogo, ` +
+    `${r.erros.length} erros (${r.pendentes} pendentes no lote)`
+  );
+  return r;
+}
+
+export function scheduleAvaliarAtendimentos(intervalMs = config.avaliacao.intervalMinutes * 60_000) {
+  if (!intervalMs || intervalMs <= 0) {
+    console.warn("[avaliar-atendimentos] desabilitado — ATENDIMENTOS_AVALIACAO_INTERVAL_MINUTES=0");
+    return null;
+  }
+
+  console.log(`[avaliar-atendimentos] agendado a cada ${Math.round(intervalMs / 60_000)} min`);
+  // primeira execução só após um intervalo, para não disputar o Ollama a cada restart do --watch
+  return setInterval(() => {
+    runAvaliarAtendimentos().catch((e) => {
+      if (e?.message === "avaliacao_em_andamento") return;
+      console.error("[avaliar-atendimentos] falha:", e.message);
+    });
+  }, intervalMs).unref();
+}

+ 4 - 0
src/middleware/schemas/Atendimento.Schema.js

@@ -78,3 +78,7 @@ export const atendimentoSyncAllSchema = z.object({
   limite: z.number().int().positive().max(200).optional(),
   maxPaginas: z.number().int().positive().max(100).optional()
 });
+
+export const atendimentoAvaliarSchema = z.object({
+  limite: z.number().int().positive().max(200).optional()
+});

+ 6 - 0
src/models/Atendimento.model.js

@@ -2,6 +2,7 @@ import { Model } from "objection";
 import "../config/db.config.js";
 import { AtendimentoCliente } from "./AtendimentoCliente.model.js";
 import { AtendimentoMensagem } from "./AtendimentoMensagem.model.js";
+import { AtendimentoAvaliacao } from "./AtendimentoAvaliacao.model.js";
 
 export class Atendimento extends Model {
   static get tableName() {
@@ -23,6 +24,11 @@ export class Atendimento extends Model {
         relation: Model.HasManyRelation,
         modelClass: AtendimentoMensagem,
         join: { from: "atendimentos.Id", to: "atendimento_mensagens.AtendimentoId" }
+      },
+      avaliacao: {
+        relation: Model.HasOneRelation,
+        modelClass: AtendimentoAvaliacao,
+        join: { from: "atendimentos.Id", to: "atendimento_avaliacoes.AtendimentoId" }
       }
     };
   }

+ 18 - 0
src/models/AtendimentoAvaliacao.model.js

@@ -0,0 +1,18 @@
+import { Model } from "objection";
+import "../config/db.config.js";
+
+export class AtendimentoAvaliacao extends Model {
+  static get tableName() {
+    return "atendimento_avaliacoes";
+  }
+
+  static get idColumn() {
+    return "AtendimentoId";
+  }
+
+  static get jsonAttributes() {
+    return ["Sugestoes", "Atendentes"];
+  }
+}
+
+export default AtendimentoAvaliacao;

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

@@ -4,7 +4,7 @@ import { requireUser } from "../middleware/RequireUser.js";
 import { requireAdmin } from "../middleware/RequireAdmin.js";
 import { parseId } from "../middleware/ParseId.js";
 import { validate } from "../middleware/Validate.js";
-import { atendimentoBodySchema, atendimentoUrlSchema, atendimentoSyncSchema, atendimentoSyncAllSchema } from "../middleware/schemas/Atendimento.Schema.js";
+import { atendimentoBodySchema, atendimentoUrlSchema, atendimentoSyncSchema, atendimentoSyncAllSchema, atendimentoAvaliarSchema } from "../middleware/schemas/Atendimento.Schema.js";
 
 export const atendimentosRouter = Router();
 
@@ -12,5 +12,9 @@ atendimentosRouter.post("/", requireUser, requireAdmin, validate({ body: atendim
 atendimentosRouter.post("/url", requireUser, requireAdmin, validate({ body: atendimentoUrlSchema }), AtendimentosController.IngerirUrl);
 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", requireUser, requireAdmin, AtendimentosController.ListarAvaliacoes);
+atendimentosRouter.get("/avaliacoes/estatisticas", requireUser, requireAdmin, AtendimentosController.EstatisticasAvaliacoes);
 atendimentosRouter.get("/", requireUser, AtendimentosController.Listar);
 atendimentosRouter.get("/:id", requireUser, parseId, AtendimentosController.Obter);
+atendimentosRouter.post("/:id/avaliar", requireUser, requireAdmin, parseId, AtendimentosController.AvaliarUm);

+ 367 - 0
src/services/atendimentoAvaliacaoService.js

@@ -0,0 +1,367 @@
+import { config } from "../config/index.js";
+import { chatCompletion } from "./ollamaClient.js";
+import { formatMensagem, formatDateTime } from "../utils/atendimentoFormat.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";
+
+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).",
+  "Mensagens rotuladas como 'Sistema' são automáticas e não contam como atuação do atendente.",
+  "Baseie-se APENAS no que está na conversa; não invente fatos, nomes ou desfechos.",
+  "",
+  "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 (demora,",
+  "respostas confusas, cliente insistindo); 1-2 = muito ruim (cliente abandonado, tratado mal ou induzido a erro).",
+  "Um atendimento resolvido com cliente satisfeito NUNCA recebe score abaixo de 6.",
+  "Se nenhum atendente humano participou, use score_atendente = 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.',
+  '- sentimento_cliente_final: sentimento do cliente AO FINAL da conversa ("positivo", "neutro", "negativo"',
+  '  ou "indefinido" quando o cliente some/não se manifesta no final).',
+  "- resumo: 2 a 4 frases objetivas: quem procurou, qual o problema, o que foi feito, como terminou.",
+  "- sugestoes_melhoria: 0 a 5 itens curtos e acionáveis para o atendente/equipe melhorar; lista vazia se exemplar.",
+  "- atendentes: nomes dos atendentes humanos que participaram (como aparecem na conversa).",
+  "- visita_agendada: true se ficou combinada visita técnica presencial na conversa.",
+  '- 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).",
+  "",
+  "Responda somente com o JSON pedido, em português brasileiro."
+].join("\n");
+
+const AVALIACAO_JSON_SCHEMA = {
+  type: "object",
+  properties: {
+    // a ordem importa: a gramática do structured output segue a ordem das propriedades,
+    // então o modelo justifica antes de pontuar
+    justificativa_score: { type: "string" },
+    score_atendente: { type: ["integer", "null"], minimum: 1, maximum: 10 },
+    resolvido: { type: "string", enum: ["sim", "nao", "parcial", "indefinido"] },
+    sentimento_cliente_final: { type: "string", enum: ["positivo", "neutro", "negativo", "indefinido"] },
+    resumo: { type: "string" },
+    sugestoes_melhoria: { type: "array", items: { type: "string" }, maxItems: 5 },
+    atendentes: { type: "array", items: { type: "string" } },
+    visita_agendada: { type: "boolean" },
+    horario_visita_informado: { type: "string", enum: ["sim", "nao", "nao_se_aplica"] },
+    horario_visita: { type: ["string", "null"] }
+  },
+  required: [
+    "justificativa_score",
+    "score_atendente",
+    "resolvido",
+    "sentimento_cliente_final",
+    "resumo",
+    "sugestoes_melhoria",
+    "atendentes",
+    "visita_agendada",
+    "horario_visita_informado",
+    "horario_visita"
+  ]
+};
+
+// texto consolidado da conversa para o juiz (cabeçalho + mensagens rotuladas)
+function buildConversaTexto({ atendimento, cliente, mensagens }) {
+  const ordenadas = [...mensagens].sort((a, b) => {
+    const ta = a.Timestamp ? new Date(a.Timestamp).getTime() : 0;
+    const tb = b.Timestamp ? new Date(b.Timestamp).getTime() : 0;
+    return ta - tb || (Number(a.Id) || 0) - (Number(b.Id) || 0);
+  });
+
+  const cabecalho = [
+    `Atendimento ${atendimento.Codigo}` +
+      (atendimento.Setor ? ` — Setor: ${atendimento.Setor}` : "") +
+      (atendimento.Status !== null && atendimento.Status !== undefined ? ` — Status: ${atendimento.Status}` : ""),
+    `Cliente: ${cliente?.Nome ?? "desconhecido"}`,
+    atendimento.Abertura ? `Aberto em: ${formatDateTime(atendimento.Abertura)}` : null
+  ].filter(Boolean);
+
+  const linhas = ordenadas.map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
+  return [...cabecalho, "", ...linhas].join("\n").trim();
+}
+
+const TRUNCATION_MARKER = "\n[... trecho intermediário da conversa omitido ...]\n";
+
+// mantém início (cabeçalho + contexto do problema) e fim (desfecho/sentimento) quando a conversa é longa demais
+function truncateConversa(text, maxChars = config.avaliacao.maxChars) {
+  if (text.length <= maxChars) return text;
+  const head = Math.floor(maxChars * 0.45);
+  const tail = maxChars - head;
+  return text.slice(0, head) + TRUNCATION_MARKER + text.slice(-tail);
+}
+
+function normalizeEnum(value, allowed, fallback = "indefinido") {
+  const v = String(value ?? "").trim().toLowerCase();
+  return allowed.includes(v) ? v : fallback;
+}
+
+function normalizeScore(value) {
+  const n = Number(value);
+  if (!Number.isFinite(n)) return null;
+  return Math.min(10, Math.max(1, Math.round(n)));
+}
+
+function normalizeStringArray(value, { maxItems = 10, maxLength = 300 } = {}) {
+  if (!Array.isArray(value)) return [];
+  return value
+    .map((s) => String(s ?? "").trim())
+    .filter(Boolean)
+    .slice(0, maxItems)
+    .map((s) => s.slice(0, maxLength));
+}
+
+function parseAvaliacao(content) {
+  const cleaned = String(content ?? "")
+    .trim()
+    .replace(/^```json\s*/i, "")
+    .replace(/^```\s*/, "")
+    .replace(/```$/, "")
+    .trim();
+  const parsed = JSON.parse(cleaned);
+
+  const horarioInformado = normalizeEnum(parsed.horario_visita_informado, ["sim", "nao", "nao_se_aplica"], "nao_se_aplica");
+  const horarioVisita = typeof parsed.horario_visita === "string" && parsed.horario_visita.trim()
+    ? parsed.horario_visita.trim().slice(0, 150)
+    : null;
+
+  return {
+    ScoreAtendente: normalizeScore(parsed.score_atendente),
+    JustificativaScore: typeof parsed.justificativa_score === "string" && parsed.justificativa_score.trim()
+      ? parsed.justificativa_score.trim()
+      : null,
+    Resolvido: normalizeEnum(parsed.resolvido, ["sim", "nao", "parcial", "indefinido"]),
+    Sentimento: normalizeEnum(parsed.sentimento_cliente_final, ["positivo", "neutro", "negativo", "indefinido"]),
+    Resumo: typeof parsed.resumo === "string" && parsed.resumo.trim() ? parsed.resumo.trim() : null,
+    Sugestoes: normalizeStringArray(parsed.sugestoes_melhoria, { maxItems: 5 }),
+    Atendentes: normalizeStringArray(parsed.atendentes, { maxItems: 10, maxLength: 100 }),
+    VisitaAgendada: Boolean(parsed.visita_agendada),
+    HorarioVisitaInformado: horarioInformado,
+    HorarioVisita: horarioInformado === "sim" ? horarioVisita : null
+  };
+}
+
+function contarInteracoes(mensagens) {
+  let cliente = 0;
+  let atendente = 0;
+  for (const m of mensagens) {
+    if (m.Resposta === 0) cliente += 1;
+    else if (m.Resposta === 1) atendente += 1;
+  }
+  return { cliente, atendente };
+}
+
+async function upsertAvaliacao(atendimentoId, dados) {
+  const now = new Date();
+  const row = { AtendimentoId: atendimentoId, ...dados, UpdatedAt: now };
+  const merge = Object.keys(dados).concat("UpdatedAt");
+  await AtendimentoAvaliacao.query().insert(row).onConflict("AtendimentoId").merge(merge);
+  return AtendimentoAvaliacao.query().findById(atendimentoId);
+}
+
+export async function avaliarAtendimento(atendimentoId) {
+  const atendimento = await Atendimento.query()
+    .findById(atendimentoId)
+    .withGraphFetched("[cliente, mensagens]")
+    .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
+
+  if (!atendimento) throw new NotFoundError("atendimento_not_found");
+
+  const mensagens = atendimento.mensagens ?? [];
+  const ultimaMensagemId = mensagens.reduce((max, m) => (Number(m.Id) > max ? Number(m.Id) : max), 0) || null;
+  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,
+      ScoreAtendente: null,
+      JustificativaScore: null,
+      Resolvido: "indefinido",
+      Sentimento: "indefinido",
+      Resumo: null,
+      Sugestoes: [],
+      Atendentes: [],
+      VisitaAgendada: null,
+      HorarioVisitaInformado: null,
+      HorarioVisita: null,
+      Modelo: null,
+      UltimaMensagemId: ultimaMensagemId,
+      TotalMensagens: mensagens.length
+    });
+    return { avaliado: false, motivo: "sem_dialogo", avaliacao };
+  }
+
+  const conversaTexto = buildConversaTexto({
+    atendimento,
+    cliente: atendimento.cliente,
+    mensagens
+  });
+
+  const model = config.avaliacao.model || config.ollama.chatModel;
+  const { content } = await chatCompletion({
+    model,
+    messages: [
+      { role: "system", content: AVALIACAO_SYSTEM_PROMPT },
+      { role: "user", content: truncateConversa(conversaTexto) }
+    ],
+    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 avaliacao = await upsertAvaliacao(atendimento.Id, {
+    Avaliavel: true,
+    ...dados,
+    Modelo: model,
+    UltimaMensagemId: ultimaMensagemId,
+    TotalMensagens: mensagens.length
+  });
+
+  return { avaliado: true, avaliacao };
+}
+
+let avaliacaoEmAndamento = false;
+
+// pendente = atendimento com mensagens e sem avaliação, ou com mensagens mais novas que a última avaliada
+function pendentesQuery() {
+  const subMax = AtendimentoMensagem.query()
+    .select("AtendimentoId")
+    .max("Id as MaxMsgId")
+    .groupBy("AtendimentoId")
+    .as("m");
+
+  return Atendimento.query()
+    .select("atendimentos.Id")
+    .join(subMax, "m.AtendimentoId", "atendimentos.Id")
+    .leftJoin("atendimento_avaliacoes as av", "av.AtendimentoId", "atendimentos.Id")
+    .where((q) => {
+      q.whereNull("av.AtendimentoId").orWhereRaw("av.UltimaMensagemId < m.MaxMsgId");
+    });
+}
+
+function buscarPendentes(limite) {
+  return pendentesQuery().orderBy("atendimentos.IngestedAt", "desc").limit(limite);
+}
+
+export async function contarPendentes() {
+  return pendentesQuery().resultSize();
+}
+
+export async function avaliarPendentes({ limite = config.avaliacao.batchSize } = {}) {
+  if (avaliacaoEmAndamento) {
+    const err = new Error("avaliacao_em_andamento");
+    err.statusCode = 409;
+    throw err;
+  }
+  avaliacaoEmAndamento = true;
+
+  try {
+    const pendentes = await buscarPendentes(limite);
+    const resultado = { pendentes: pendentes.length, avaliados: 0, semDialogo: 0, erros: [] };
+
+    // sequencial de propósito: o Ollama local não se beneficia de concorrência aqui
+    for (const { Id } of pendentes) {
+      try {
+        const r = await avaliarAtendimento(Id);
+        if (r.avaliado) resultado.avaliados += 1;
+        else resultado.semDialogo += 1;
+      } catch (err) {
+        resultado.erros.push({ atendimentoId: Id, erro: err?.message ?? "erro_desconhecido" });
+      }
+    }
+
+    return resultado;
+  } finally {
+    avaliacaoEmAndamento = false;
+  }
+}
+
+export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, scoreMax } = {}) {
+  const query = AtendimentoAvaliacao.query()
+    .where("Avaliavel", true)
+    .page(page - 1, pageSize)
+    .orderBy("atendimento_avaliacoes.UpdatedAt", "desc");
+
+  if (resolvido) query.where("Resolvido", resolvido);
+  if (sentimento) query.where("Sentimento", sentimento);
+  if (scoreMax !== undefined) query.where("ScoreAtendente", "<=", scoreMax);
+
+  if (setor) {
+    query
+      .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
+      .where("a.Setor", setor)
+      .select("atendimento_avaliacoes.*");
+  }
+
+  const { results, total } = await query;
+
+  // junta código/cliente/setor do atendimento para exibição
+  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) => {
+    const a = byId.get(r.AtendimentoId);
+    return {
+      ...r,
+      Codigo: a?.Codigo ?? null,
+      Setor: a?.Setor ?? null,
+      Status: a?.Status ?? null,
+      Abertura: a?.Abertura ?? null,
+      ClienteNome: a?.cliente?.Nome ?? null
+    };
+  });
+
+  return { results: items, total, page, pageSize };
+}
+
+export async function estatisticasAvaliacoes({ setor } = {}) {
+  const base = () => {
+    const q = AtendimentoAvaliacao.query().where("Avaliavel", true);
+    if (setor) {
+      q.join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId").where("a.Setor", setor);
+    }
+    return q;
+  };
+
+  const [scoreRow, avaliados, porResolvido, porSentimento, visitas, pendentes] = await Promise.all([
+    base().avg("ScoreAtendente as media").whereNotNull("ScoreAtendente").first(),
+    base().resultSize(),
+    base().select("Resolvido").count("* as total").groupBy("Resolvido"),
+    base().select("Sentimento").count("* as total").groupBy("Sentimento"),
+    base()
+      .where("VisitaAgendada", true)
+      .select("HorarioVisitaInformado")
+      .count("* as total")
+      .groupBy("HorarioVisitaInformado"),
+    contarPendentes()
+  ]);
+
+  const toMap = (rows, key) =>
+    Object.fromEntries(rows.map((r) => [r[key] ?? "indefinido", Number(r.total)]));
+
+  return {
+    scoreMedio: scoreRow?.media !== null && scoreRow?.media !== undefined ? Number(Number(scoreRow.media).toFixed(2)) : null,
+    avaliados: Number(avaliados ?? 0),
+    resolvido: toMap(porResolvido, "Resolvido"),
+    sentimento: toMap(porSentimento, "Sentimento"),
+    visitasAgendadas: toMap(visitas, "HorarioVisitaInformado"),
+    pendentes
+  };
+}

+ 1 - 1
src/services/atendimentosService.js

@@ -285,6 +285,6 @@ export async function listarAtendimentos({ page = 1, pageSize = 20, setor, statu
 export async function obterAtendimento(id) {
   return Atendimento.query()
     .findById(id)
-    .withGraphFetched("[cliente, mensagens]")
+    .withGraphFetched("[cliente, mensagens, avaliacao]")
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
 }

+ 2 - 2
src/services/ollamaClient.js

@@ -60,9 +60,9 @@ export async function embedTexts(texts, { role } = {}) {
   return results;
 }
 
-export async function chatCompletion({ messages, options, format }) {
+export async function chatCompletion({ messages, options, format, model }) {
   const data = await ollamaFetch("/api/chat", {
-    model: config.ollama.chatModel,
+    model: model || config.ollama.chatModel,
     messages,
     stream: false,
     ...(format && { format }),