leonardo 1 месяц назад
Родитель
Сommit
72c5ae6e40

+ 32 - 0
db/migrations/20260812120000_create_atendimento_golden_labels_table.cjs

@@ -0,0 +1,32 @@
+exports.up = function (knex) {
+  return knex.schema.createTable("atendimento_golden_labels", (t) => {
+    t.integer("AtendimentoId").unsigned().primary();
+    t.string("Status", 12).notNullable().defaultTo("pendente"); // pendente | rotulado
+
+    // espelha atendimento_avaliacoes, porém tudo nullable: NULL = ainda não rotulado
+    t.tinyint("ScoreAtendente").unsigned().nullable(); // 1 a 10
+    t.text("JustificativaScore").nullable();
+    t.string("Resolvido", 12).nullable(); // sim | nao | parcial | indefinido
+    t.string("Sentimento", 12).nullable(); // positivo | neutro | negativo | indefinido
+    t.text("Resumo").nullable();
+    t.text("Sugestoes").nullable(); // JSON array de strings
+    t.text("Atendentes").nullable(); // JSON array de strings
+    t.boolean("VisitaAgendada").nullable();
+    t.string("HorarioVisitaInformado", 15).nullable(); // sim | nao | nao_se_aplica
+    t.string("HorarioVisita", 150).nullable();
+    t.string("StatusCancelamento", 12).nullable(); // nao | ameacou | cancelou | indefinido
+    t.text("MotivoCancelamento").nullable();
+
+    t.timestamp("SampledAt").notNullable().defaultTo(knex.fn.now()); // quando entrou na amostra do golden set
+    t.timestamp("LabeledAt").nullable(); // quando terminou de ser rotulado
+    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(["Status"]);
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.dropTable("atendimento_golden_labels");
+};

+ 38 - 0
db/migrations/20260812182305_add_ia_snapshot_to_atendimento_golden_labels.cjs

@@ -0,0 +1,38 @@
+// congela a avaliação da IA no momento em que o rótulo humano é salvo pela primeira vez,
+// pra comparação (compararGoldenSet.js) nunca ficar exposta a reavaliações futuras do
+// mesmo atendimento (ele pode receber mensagens novas e ser reavaliado depois de rotulado)
+exports.up = function (knex) {
+  return knex.schema.alterTable("atendimento_golden_labels", (t) => {
+    t.tinyint("IaScoreAtendente").unsigned().nullable();
+    t.text("IaJustificativaScore").nullable();
+    t.string("IaResolvido", 12).nullable();
+    t.string("IaSentimento", 12).nullable();
+    t.text("IaResumo").nullable();
+    t.text("IaSugestoes").nullable();
+    t.text("IaAtendentes").nullable();
+    t.boolean("IaVisitaAgendada").nullable();
+    t.string("IaHorarioVisitaInformado", 15).nullable();
+    t.string("IaHorarioVisita", 150).nullable();
+    t.string("IaStatusCancelamento", 12).nullable();
+    t.text("IaMotivoCancelamento").nullable();
+    t.timestamp("IaSnapshotEm").nullable(); // quando o snapshot foi tirado; null = ainda não congelado
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.alterTable("atendimento_golden_labels", (t) => {
+    t.dropColumn("IaScoreAtendente");
+    t.dropColumn("IaJustificativaScore");
+    t.dropColumn("IaResolvido");
+    t.dropColumn("IaSentimento");
+    t.dropColumn("IaResumo");
+    t.dropColumn("IaSugestoes");
+    t.dropColumn("IaAtendentes");
+    t.dropColumn("IaVisitaAgendada");
+    t.dropColumn("IaHorarioVisitaInformado");
+    t.dropColumn("IaHorarioVisita");
+    t.dropColumn("IaStatusCancelamento");
+    t.dropColumn("IaMotivoCancelamento");
+    t.dropColumn("IaSnapshotEm");
+  });
+};

+ 74 - 0
scripts/backfillSnapshotGoldenSet.js

@@ -0,0 +1,74 @@
+// backfill único: congela o snapshot da IA (Ia*) nos itens do golden set que já estavam
+// "rotulado" antes do congelamento existir (usa a avaliação atual como aproximação —
+// não há como recuperar o valor exato de quando cada um foi originalmente rotulado)
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { AtendimentoGoldenLabel } from "../src/models/AtendimentoGoldenLabel.model.js";
+import { AtendimentoAvaliacao } from "../src/models/AtendimentoAvaliacao.model.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const APLICAR = process.argv.includes("--apply");
+
+const CAMPOS_SNAPSHOT_IA = [
+  "ScoreAtendente",
+  "JustificativaScore",
+  "Resolvido",
+  "Sentimento",
+  "Resumo",
+  "Sugestoes",
+  "Atendentes",
+  "VisitaAgendada",
+  "HorarioVisitaInformado",
+  "HorarioVisita",
+  "StatusCancelamento",
+  "MotivoCancelamento"
+];
+
+async function main() {
+  const alvos = await AtendimentoGoldenLabel.query().where("Status", "rotulado").whereNull("IaSnapshotEm");
+  console.log(`[backfill-snapshot-golden-set] ${alvos.length} itens rotulados sem snapshot da IA.`);
+
+  if (alvos.length === 0) {
+    console.log("[backfill-snapshot-golden-set] nada para fazer.");
+    process.exit(0);
+  }
+
+  const ids = alvos.map((a) => a.AtendimentoId);
+  const avaliacoes = await AtendimentoAvaliacao.query().findByIds(ids);
+  const iaPorId = new Map(avaliacoes.map((a) => [a.AtendimentoId, a]));
+
+  const semAvaliacaoIa = ids.filter((id) => !iaPorId.has(id));
+  if (semAvaliacaoIa.length) {
+    console.warn(`[backfill-snapshot-golden-set] ${semAvaliacaoIa.length} atendimento(s) rotulado(s) sem avaliação da IA correspondente (pulados): ${semAvaliacaoIa.join(", ")}`);
+  }
+
+  const backupDir = path.resolve(__dirname, "logs");
+  fs.mkdirSync(backupDir, { recursive: true });
+  const backupPath = path.join(backupDir, `backfill-snapshot-golden-set-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
+  fs.writeFileSync(backupPath, JSON.stringify(alvos.map((a) => ({ AtendimentoId: a.AtendimentoId })), null, 2));
+  console.log(`[backfill-snapshot-golden-set] lista de alvos salva em ${backupPath}`);
+
+  if (!APLICAR) {
+    console.log("[backfill-snapshot-golden-set] dry-run (nenhuma alteração feita). Use --apply para gravar.");
+    process.exit(0);
+  }
+
+  let feitos = 0;
+  for (const id of ids) {
+    const ia = iaPorId.get(id);
+    if (!ia) continue;
+    const patch = { IaSnapshotEm: new Date() };
+    for (const campo of CAMPOS_SNAPSHOT_IA) patch[`Ia${campo}`] = ia[campo] ?? null;
+    await AtendimentoGoldenLabel.query().patchAndFetchById(id, patch);
+    feitos += 1;
+  }
+
+  console.log(`[backfill-snapshot-golden-set] ${feitos} itens com snapshot congelado agora.`);
+  process.exit(0);
+}
+
+main().catch((err) => {
+  console.error("[backfill-snapshot-golden-set] falha fatal:", err);
+  process.exit(1);
+});

+ 188 - 0
scripts/compararGoldenSet.js

@@ -0,0 +1,188 @@
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { AtendimentoGoldenLabel } from "../src/models/AtendimentoGoldenLabel.model.js";
+import { media, desvioPadrao, correlacaoPearson } from "./utils/estatistica.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 CAMPOS_CATEGORICOS = ["Resolvido", "Sentimento", "StatusCancelamento", "HorarioVisitaInformado", "VisitaAgendada"];
+
+// usa o snapshot da IA congelado em atendimento_golden_labels (Ia*) no momento em que
+// cada item foi rotulado — não junta com atendimento_avaliacoes ao vivo de propósito,
+// pra comparação nunca ser afetada por reavaliações posteriores do mesmo atendimento
+async function buscarPares() {
+  let query = AtendimentoGoldenLabel.query()
+    .where("atendimento_golden_labels.Status", "rotulado")
+    .whereNotNull("IaSnapshotEm")
+    .join("atendimentos as a", "a.Id", "atendimento_golden_labels.AtendimentoId")
+    .select("atendimento_golden_labels.*", "a.Codigo as Codigo", "a.Setor as Setor");
+
+  if (SETOR) query = query.where("a.Setor", SETOR);
+
+  return query;
+}
+
+// Jaccard entre dois conjuntos de strings (comparação textual simples, não crítica)
+function jaccard(a, b) {
+  const setA = new Set((a ?? []).map((s) => s.toLowerCase().trim()));
+  const setB = new Set((b ?? []).map((s) => s.toLowerCase().trim()));
+  if (setA.size === 0 && setB.size === 0) return 1;
+  const intersecao = [...setA].filter((x) => setB.has(x)).length;
+  const uniao = new Set([...setA, ...setB]).size;
+  return uniao === 0 ? 1 : intersecao / uniao;
+}
+
+function metricasCategoricas(pares) {
+  const resultado = {};
+  for (const campo of CAMPOS_CATEGORICOS) {
+    const campoIa = `Ia${campo}`;
+    const comparaveis = pares.filter((p) => p[campo] !== null && p[campo] !== undefined);
+    const matriz = {};
+    let acertos = 0;
+
+    for (const p of comparaveis) {
+      const humano = String(p[campo]);
+      const ia = String(p[campoIa]);
+      matriz[humano] = matriz[humano] ?? {};
+      matriz[humano][ia] = (matriz[humano][ia] ?? 0) + 1;
+      if (humano === ia) acertos += 1;
+    }
+
+    resultado[campo] = {
+      amostra: comparaveis.length,
+      exactMatchPercent: comparaveis.length ? Number(((acertos / comparaveis.length) * 100).toFixed(1)) : null,
+      matrizConfusao: matriz
+    };
+  }
+  return resultado;
+}
+
+function metricasScore(pares) {
+  const comparaveis = pares.filter((p) => p.ScoreAtendente !== null && p.IaScoreAtendente !== null);
+  if (comparaveis.length === 0) {
+    return { amostra: 0, mae: null, correlacao: null, distribuicaoDiferenca: null };
+  }
+
+  const diferencas = comparaveis.map((p) => Math.abs(p.ScoreAtendente - p.IaScoreAtendente));
+  const mae = Number(media(diferencas).toFixed(2));
+  const correlacao = correlacaoPearson(
+    comparaveis.map((p) => p.ScoreAtendente),
+    comparaveis.map((p) => p.IaScoreAtendente)
+  );
+
+  const distribuicaoDiferenca = { diferenca0: 0, diferenca1: 0, diferenca2: 0, diferenca3mais: 0 };
+  for (const d of diferencas) {
+    if (d === 0) distribuicaoDiferenca.diferenca0 += 1;
+    else if (d === 1) distribuicaoDiferenca.diferenca1 += 1;
+    else if (d === 2) distribuicaoDiferenca.diferenca2 += 1;
+    else distribuicaoDiferenca.diferenca3mais += 1;
+  }
+
+  return {
+    amostra: comparaveis.length,
+    mae,
+    desvioPadraoDiferenca: Number(desvioPadrao(diferencas).toFixed(2)),
+    correlacao: correlacao !== null ? Number(correlacao.toFixed(2)) : null,
+    distribuicaoDiferenca
+  };
+}
+
+function metricasListas(pares) {
+  const comAmbos = pares.filter((p) => (p.Atendentes?.length || p.IaAtendentes?.length));
+  const jaccardMedioAtendentes = comAmbos.length
+    ? Number(media(comAmbos.map((p) => jaccard(p.Atendentes, p.IaAtendentes))).toFixed(2))
+    : null;
+
+  const comAmbosSugestoes = pares.filter((p) => (p.Sugestoes?.length || p.IaSugestoes?.length));
+  const jaccardMedioSugestoes = comAmbosSugestoes.length
+    ? Number(media(comAmbosSugestoes.map((p) => jaccard(p.Sugestoes, p.IaSugestoes))).toFixed(2))
+    : null;
+
+  return {
+    atendentes: { amostra: comAmbos.length, jaccardMedio: jaccardMedioAtendentes },
+    sugestoes: { amostra: comAmbosSugestoes.length, jaccardMedio: jaccardMedioSugestoes }
+  };
+}
+
+async function main() {
+  const pares = await buscarPares();
+  console.log(`[comparar-golden-set] ${pares.length} atendimentos rotulados${SETOR ? ` no setor ${SETOR}` : ""} comparados contra a avaliação da IA.`);
+
+  if (pares.length === 0) {
+    console.log("[comparar-golden-set] nada para comparar ainda — rotule alguns atendimentos no golden set primeiro.");
+    process.exit(0);
+  }
+
+  const categoricas = metricasCategoricas(pares);
+  const score = metricasScore(pares);
+  const listas = metricasListas(pares);
+
+  console.log("\n=== Score do atendente ===");
+  console.log(`Amostra: ${score.amostra} | MAE: ${score.mae} | desvio da diferença: ${score.desvioPadraoDiferenca} | correlação: ${score.correlacao}`);
+  if (score.distribuicaoDiferenca) {
+    console.log(
+      `Distribuição da diferença |humano - IA|: 0 = ${score.distribuicaoDiferenca.diferenca0} | 1 = ${score.distribuicaoDiferenca.diferenca1} | 2 = ${score.distribuicaoDiferenca.diferenca2} | ≥3 = ${score.distribuicaoDiferenca.diferenca3mais}`
+    );
+  }
+
+  console.log("\n=== Campos categóricos (exact-match humano vs. IA) ===");
+  for (const [campo, m] of Object.entries(categoricas)) {
+    console.log(`  ${campo}: ${m.exactMatchPercent}% (amostra: ${m.amostra})`);
+  }
+
+  console.log("\n=== Listas (similaridade de Jaccard, informativo) ===");
+  console.log(`  Atendentes: ${listas.atendentes.jaccardMedio} (amostra: ${listas.atendentes.amostra})`);
+  console.log(`  Sugestões: ${listas.sugestoes.jaccardMedio} (amostra: ${listas.sugestoes.amostra})`);
+
+  const backupDir = path.resolve(__dirname, "logs");
+  fs.mkdirSync(backupDir, { recursive: true });
+  const backupPath = path.join(backupDir, `comparar-golden-set-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
+  fs.writeFileSync(
+    backupPath,
+    JSON.stringify(
+      {
+        config: { setor: SETOR },
+        resumo: { score, categoricas, listas },
+        detalhes: pares.map((p) => ({
+          atendimentoId: p.AtendimentoId,
+          codigo: p.Codigo,
+          setor: p.Setor,
+          humano: {
+            ScoreAtendente: p.ScoreAtendente,
+            Resolvido: p.Resolvido,
+            Sentimento: p.Sentimento,
+            StatusCancelamento: p.StatusCancelamento,
+            HorarioVisitaInformado: p.HorarioVisitaInformado,
+            VisitaAgendada: p.VisitaAgendada,
+            Atendentes: p.Atendentes,
+            Sugestoes: p.Sugestoes
+          },
+          ia: {
+            ScoreAtendente: p.IaScoreAtendente,
+            Resolvido: p.IaResolvido,
+            Sentimento: p.IaSentimento,
+            StatusCancelamento: p.IaStatusCancelamento,
+            HorarioVisitaInformado: p.IaHorarioVisitaInformado,
+            VisitaAgendada: p.IaVisitaAgendada,
+            Atendentes: p.IaAtendentes,
+            Sugestoes: p.IaSugestoes
+          }
+        }))
+      },
+      null,
+      2
+    )
+  );
+  console.log(`\n[comparar-golden-set] resultado completo salvo em ${backupPath}`);
+
+  process.exit(0);
+}
+
+main().catch((err) => {
+  console.error("[comparar-golden-set] falha fatal:", err);
+  process.exit(1);
+});

+ 165 - 0
scripts/gerarAmostraGoldenSet.js

@@ -0,0 +1,165 @@
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { AtendimentoAvaliacao } from "../src/models/AtendimentoAvaliacao.model.js";
+import { AtendimentoGoldenLabel } from "../src/models/AtendimentoGoldenLabel.model.js";
+import { SETOR_NOMES } from "../src/config/setores.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const APLICAR = process.argv.includes("--apply");
+
+const tamanhoIdx = process.argv.indexOf("--tamanho");
+const TAMANHO = tamanhoIdx > -1 ? Number(process.argv[tamanhoIdx + 1]) : 40;
+
+const minimoIdx = process.argv.indexOf("--minimo");
+const MINIMO = minimoIdx > -1 ? Number(process.argv[minimoIdx + 1]) : 3;
+
+const expectIdx = process.argv.indexOf("--expect-count");
+const EXPECT_COUNT = expectIdx > -1 ? Number(process.argv[expectIdx + 1]) : null;
+
+const SETORES = Object.keys(SETOR_NOMES);
+
+async function volumePorSetor() {
+  const linhas = await AtendimentoAvaliacao.query()
+    .where("Avaliavel", true)
+    .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
+    .whereIn("a.Setor", SETORES)
+    .groupBy("a.Setor")
+    .select("a.Setor")
+    .count("atendimento_avaliacoes.AtendimentoId as total");
+
+  const volume = Object.fromEntries(SETORES.map((s) => [s, 0]));
+  for (const l of linhas) volume[l.Setor] = Number(l.total);
+  return volume;
+}
+
+async function jaAmostradoPorSetor() {
+  const linhas = await AtendimentoGoldenLabel.query()
+    .join("atendimentos as a", "a.Id", "atendimento_golden_labels.AtendimentoId")
+    .whereIn("a.Setor", SETORES)
+    .groupBy("a.Setor")
+    .select("a.Setor")
+    .count("atendimento_golden_labels.AtendimentoId as total");
+
+  const jaAmostrado = Object.fromEntries(SETORES.map((s) => [s, 0]));
+  for (const l of linhas) jaAmostrado[l.Setor] = Number(l.total);
+  return jaAmostrado;
+}
+
+// aloca MINIMO fixo por setor, distribui o restante proporcionalmente ao volume
+// (método do maior resto), sem nunca alocar mais que o volume disponível no setor
+function calcularAlocacao(volume) {
+  const alocacao = Object.fromEntries(SETORES.map((s) => [s, Math.min(MINIMO, volume[s])]));
+  let restante = TAMANHO - Object.values(alocacao).reduce((a, b) => a + b, 0);
+  if (restante < 0) restante = 0;
+
+  const volumeTotal = SETORES.reduce((a, s) => a + volume[s], 0) || 1;
+  const fracoes = SETORES.map((s) => {
+    const proporcional = (volume[s] / volumeTotal) * restante;
+    const inteiro = Math.floor(proporcional);
+    return { setor: s, inteiro, resto: proporcional - inteiro };
+  });
+
+  for (const f of fracoes) alocacao[f.setor] += f.inteiro;
+  let distribuido = fracoes.reduce((a, f) => a + f.inteiro, 0);
+  let sobra = restante - distribuido;
+
+  fracoes.sort((a, b) => b.resto - a.resto);
+  for (const f of fracoes) {
+    if (sobra <= 0) break;
+    if (alocacao[f.setor] >= volume[f.setor]) continue;
+    alocacao[f.setor] += 1;
+    sobra -= 1;
+  }
+
+  for (const s of SETORES) alocacao[s] = Math.min(alocacao[s], volume[s]);
+
+  return alocacao;
+}
+
+async function sortearPorSetor(setor, quantidade) {
+  if (quantidade <= 0) return [];
+
+  const jaNaAmostra = AtendimentoGoldenLabel.query().select("AtendimentoId");
+
+  return AtendimentoAvaliacao.query()
+    .where("Avaliavel", true)
+    .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
+    .where("a.Setor", setor)
+    .whereNotIn("atendimento_avaliacoes.AtendimentoId", jaNaAmostra)
+    .orderByRaw("RAND()")
+    .limit(quantidade)
+    .select("atendimento_avaliacoes.AtendimentoId as AtendimentoId", "a.Codigo as Codigo", "a.Setor as Setor");
+}
+
+async function main() {
+  const volume = await volumePorSetor();
+  const jaAmostrado = await jaAmostradoPorSetor();
+  const alocacao = calcularAlocacao(volume);
+
+  console.log(`[gerar-amostra-golden-set] alvo total: ${TAMANHO} | mínimo por setor: ${MINIMO}`);
+  console.log("[gerar-amostra-golden-set] volume disponível / já amostrado / alocação-alvo por setor:");
+  for (const s of SETORES) {
+    console.log(`  ${s}: volume=${volume[s]} | já amostrado=${jaAmostrado[s]} | alocação-alvo=${alocacao[s]}`);
+  }
+
+  const amostraFinal = [];
+  const faltantePorSetor = {};
+  for (const s of SETORES) {
+    const faltam = Math.max(0, alocacao[s] - jaAmostrado[s]);
+    faltantePorSetor[s] = faltam;
+    if (faltam > 0) {
+      const sorteados = await sortearPorSetor(s, faltam);
+      amostraFinal.push(...sorteados);
+    }
+  }
+
+  console.log(`[gerar-amostra-golden-set] ${amostraFinal.length} atendimentos novos serão adicionados à amostra.`);
+  if (EXPECT_COUNT !== null && amostraFinal.length !== EXPECT_COUNT) {
+    console.error(`[gerar-amostra-golden-set] abortando: esperava ${EXPECT_COUNT}, achei ${amostraFinal.length}.`);
+    process.exit(1);
+  }
+
+  const backupDir = path.resolve(__dirname, "logs");
+  fs.mkdirSync(backupDir, { recursive: true });
+  const backupPath = path.join(backupDir, `gerar-amostra-golden-set-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
+  fs.writeFileSync(
+    backupPath,
+    JSON.stringify(
+      {
+        config: { tamanho: TAMANHO, minimo: MINIMO },
+        volumePorSetor: volume,
+        jaAmostradoPorSetor: jaAmostrado,
+        alocacaoAlvoPorSetor: alocacao,
+        faltantePorSetor,
+        amostraFinal
+      },
+      null,
+      2
+    )
+  );
+  console.log(`[gerar-amostra-golden-set] detalhe do sorteio salvo em ${backupPath}`);
+
+  if (!APLICAR) {
+    console.log("[gerar-amostra-golden-set] dry-run (nenhuma alteração feita). Use --apply para gravar.");
+    process.exit(0);
+  }
+
+  if (amostraFinal.length === 0) {
+    console.log("[gerar-amostra-golden-set] nada para inserir.");
+    process.exit(0);
+  }
+
+  for (const { AtendimentoId } of amostraFinal) {
+    await AtendimentoGoldenLabel.query().insert({ AtendimentoId, Status: "pendente" });
+  }
+  console.log(`[gerar-amostra-golden-set] ${amostraFinal.length} linhas inseridas com Status='pendente'.`);
+
+  process.exit(0);
+}
+
+main().catch((err) => {
+  console.error("[gerar-amostra-golden-set] falha fatal:", err);
+  process.exit(1);
+});

+ 1 - 11
scripts/medirVarianciaAvaliacao.js

@@ -5,6 +5,7 @@ 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";
+import { media, desvioPadrao } from "./utils/estatistica.js";
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 
@@ -23,17 +24,6 @@ const IDS = argValor("ids", 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 }));
 

+ 22 - 0
scripts/utils/estatistica.js

@@ -0,0 +1,22 @@
+export function media(valores) {
+  return valores.reduce((a, b) => a + b, 0) / valores.length;
+}
+
+export 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);
+}
+
+// coeficiente de correlação de Pearson entre dois vetores pareados
+export function correlacaoPearson(xs, ys) {
+  if (xs.length < 2 || xs.length !== ys.length) return null;
+  const mx = media(xs);
+  const my = media(ys);
+  const numerador = xs.reduce((soma, x, i) => soma + (x - mx) * (ys[i] - my), 0);
+  const denomX = Math.sqrt(xs.reduce((soma, x) => soma + (x - mx) ** 2, 0));
+  const denomY = Math.sqrt(ys.reduce((soma, y) => soma + (y - my) ** 2, 0));
+  if (denomX === 0 || denomY === 0) return null;
+  return numerador / (denomX * denomY);
+}

+ 25 - 4
src/controllers/Atendimentos.Controller.js

@@ -15,6 +15,7 @@ import {
   listarSetores
 } from "../services/atendimentoAvaliacaoService.js";
 import { gerarRelatorioNaoResolvidosPdf } from "../services/relatorioNaoResolvidosPdfService.js";
+import { listarGoldenSet, salvarGoldenLabel } from "../services/atendimentoGoldenLabelService.js";
 import { estruturarMensagem, montarUrlMidia } from "../utils/atendimentoFormat.js";
 import { parseFiltrosAvaliacoes } from "../utils/atendimentoAvaliacaoFiltros.js";
 import { config } from "../config/index.js";
@@ -22,8 +23,8 @@ import { config } from "../config/index.js";
 export const AtendimentosController = {
   Ingerir: async function (req, res, next) {
     try {
-      const { atendimento, setor } = req.body;
-      const result = await salvarAtendimento({ payload: atendimento, setor });
+      const { atendimento } = req.body;
+      const result = await salvarAtendimento({ payload: atendimento });
       res.json({ ok: true, ...result });
     } catch (err) {
       next(err);
@@ -32,9 +33,9 @@ export const AtendimentosController = {
 
   IngerirUrl: async function (req, res, next) {
     try {
-      const { url, setor } = req.body;
+      const { url } = req.body;
       const payload = await fetchAtendimentoJson(url);
-      const result = await salvarAtendimento({ payload, setor, sourceUrl: url });
+      const result = await salvarAtendimento({ payload, sourceUrl: url });
       res.json({ ok: true, ...result });
     } catch (err) {
       next(err);
@@ -172,6 +173,26 @@ export const AtendimentosController = {
     } catch (err) {
       next(err);
     }
+  },
+
+  ListarGoldenSet: async function (req, res, next) {
+    try {
+      const status = typeof req.query.status === "string" && req.query.status.trim() ? req.query.status.trim() : undefined;
+      const setor = typeof req.query.setor === "string" && req.query.setor.trim() ? req.query.setor.trim() : undefined;
+      const results = await listarGoldenSet({ status, setor });
+      res.json({ results });
+    } catch (err) {
+      next(err);
+    }
+  },
+
+  SalvarGoldenLabel: async function (req, res, next) {
+    try {
+      const result = await salvarGoldenLabel(req.params.id, req.body);
+      res.json(result);
+    } catch (err) {
+      next(err);
+    }
   }
 };
 

+ 16 - 3
src/middleware/schemas/Atendimento.Schema.js

@@ -36,13 +36,11 @@ export const atendimentoPayloadSchema = z.object({
 }).passthrough();
 
 export const atendimentoBodySchema = z.object({
-  setor: z.string().min(1).max(100).optional(),
   atendimento: atendimentoPayloadSchema
 });
 
 export const atendimentoUrlSchema = z.object({
-  url: z.string().url().startsWith("https://"),
-  setor: z.string().min(1).max(100).optional()
+  url: z.string().url().startsWith("https://")
 });
 
 export const protocolosAbertosSchema = z.object({
@@ -83,3 +81,18 @@ export const atendimentoSyncAllSchema = z.object({
 export const atendimentoAvaliarSchema = z.object({
   limite: z.number().int().positive().max(200).optional()
 });
+
+export const goldenLabelSchema = z.object({
+  ScoreAtendente: z.number().int().min(1).max(10).nullable().optional(),
+  JustificativaScore: z.string().max(2000).nullable().optional(),
+  Resolvido: z.enum(["sim", "nao", "parcial", "indefinido"]).nullable().optional(),
+  Sentimento: z.enum(["positivo", "neutro", "negativo", "indefinido"]).nullable().optional(),
+  Resumo: z.string().max(2000).nullable().optional(),
+  Sugestoes: z.array(z.string().max(500)).max(20).nullable().optional(),
+  Atendentes: z.array(z.string().max(150)).max(20).nullable().optional(),
+  VisitaAgendada: z.boolean().nullable().optional(),
+  HorarioVisitaInformado: z.enum(["sim", "nao", "nao_se_aplica"]).nullable().optional(),
+  HorarioVisita: z.string().max(150).nullable().optional(),
+  StatusCancelamento: z.enum(["nao", "ameacou", "cancelou", "indefinido"]).nullable().optional(),
+  MotivoCancelamento: z.string().max(2000).nullable().optional()
+}).strict();

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

@@ -3,6 +3,7 @@ import "../config/db.config.js";
 import { AtendimentoCliente } from "./AtendimentoCliente.model.js";
 import { AtendimentoMensagem } from "./AtendimentoMensagem.model.js";
 import { AtendimentoAvaliacao } from "./AtendimentoAvaliacao.model.js";
+import { AtendimentoGoldenLabel } from "./AtendimentoGoldenLabel.model.js";
 import { AtendimentoRagIndex } from "./AtendimentoRagIndex.model.js";
 
 export class Atendimento extends Model {
@@ -31,6 +32,11 @@ export class Atendimento extends Model {
         modelClass: AtendimentoAvaliacao,
         join: { from: "atendimentos.Id", to: "atendimento_avaliacoes.AtendimentoId" }
       },
+      goldenLabel: {
+        relation: Model.HasOneRelation,
+        modelClass: AtendimentoGoldenLabel,
+        join: { from: "atendimentos.Id", to: "atendimento_golden_labels.AtendimentoId" }
+      },
       ragIndex: {
         relation: Model.HasOneRelation,
         modelClass: AtendimentoRagIndex,

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

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

+ 3 - 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, atendimentoAvaliarSchema } from "../middleware/schemas/Atendimento.Schema.js";
+import { atendimentoBodySchema, atendimentoUrlSchema, atendimentoSyncSchema, atendimentoSyncAllSchema, atendimentoAvaliarSchema, goldenLabelSchema } from "../middleware/schemas/Atendimento.Schema.js";
 
 export const atendimentosRouter = Router();
 
@@ -17,6 +17,8 @@ atendimentosRouter.get("/avaliacoes/relatorio", requireUser, requireAdmin, Atend
 atendimentosRouter.get("/avaliacoes", requireUser, requireAdmin, AtendimentosController.ListarAvaliacoes);
 atendimentosRouter.get("/avaliacoes/estatisticas", requireUser, requireAdmin, AtendimentosController.EstatisticasAvaliacoes);
 atendimentosRouter.get("/avaliacoes/setores", requireUser, requireAdmin, AtendimentosController.ListarSetoresAvaliacoes);
+atendimentosRouter.get("/golden-set", requireUser, requireAdmin, AtendimentosController.ListarGoldenSet);
 atendimentosRouter.get("/", requireUser, AtendimentosController.Listar);
 atendimentosRouter.get("/:id", requireUser, parseId, AtendimentosController.Obter);
 atendimentosRouter.post("/:id/avaliar", requireUser, requireAdmin, parseId, AtendimentosController.AvaliarUm);
+atendimentosRouter.patch("/:id/golden-label", requireUser, requireAdmin, parseId, validate({ body: goldenLabelSchema }), AtendimentosController.SalvarGoldenLabel);

+ 82 - 0
src/services/atendimentoGoldenLabelService.js

@@ -0,0 +1,82 @@
+import { AtendimentoGoldenLabel } from "../models/AtendimentoGoldenLabel.model.js";
+import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
+import { NotFoundError } from "../shared/errors/index.js";
+
+// campos da avaliação da IA que são congelados (snapshot) na primeira vez que o rótulo
+// humano fica completo — a partir daí a comparação nunca mais é afetada por reavaliações
+// futuras do mesmo atendimento (ele pode receber mensagens novas depois de rotulado)
+const CAMPOS_SNAPSHOT_IA = [
+  "ScoreAtendente",
+  "JustificativaScore",
+  "Resolvido",
+  "Sentimento",
+  "Resumo",
+  "Sugestoes",
+  "Atendentes",
+  "VisitaAgendada",
+  "HorarioVisitaInformado",
+  "HorarioVisita",
+  "StatusCancelamento",
+  "MotivoCancelamento"
+];
+
+// campos que precisam estar preenchidos para considerar o rótulo "completo" —
+// os mesmos que a IA sempre produz mesmo em casos degenerados (Resumo/Resolvido/Sentimento
+// nunca ficam vazios na avaliação automática, então usamos o mesmo padrão aqui)
+const CAMPOS_OBRIGATORIOS_PARA_CONCLUIR = ["Resolvido", "Sentimento", "Resumo"];
+
+export async function listarGoldenSet({ status, setor } = {}) {
+  const query = AtendimentoGoldenLabel.query()
+    .join("atendimentos as a", "a.Id", "atendimento_golden_labels.AtendimentoId")
+    .leftJoin("atendimento_clientes as c", "c.Id", "a.ClienteId")
+    .select(
+      "atendimento_golden_labels.*",
+      "a.Codigo as Codigo",
+      "a.Setor as Setor",
+      "a.Abertura as Abertura",
+      "c.Nome as ClienteNome"
+    )
+    .orderBy("a.Setor", "asc")
+    .orderBy("atendimento_golden_labels.Status", "desc");
+
+  if (status) query.where("atendimento_golden_labels.Status", status);
+  if (setor) query.where("a.Setor", setor);
+
+  return query;
+}
+
+function estaCompleto(dados) {
+  return CAMPOS_OBRIGATORIOS_PARA_CONCLUIR.every((campo) => {
+    const valor = dados[campo];
+    return typeof valor === "string" && valor.trim().length > 0;
+  });
+}
+
+export async function salvarGoldenLabel(atendimentoId, dados) {
+  const atual = await AtendimentoGoldenLabel.query().findById(atendimentoId);
+  if (!atual) throw new NotFoundError("atendimento_fora_da_amostra_golden");
+
+  const mesclado = { ...atual, ...dados };
+  const novoStatus = estaCompleto(mesclado) ? "rotulado" : "pendente";
+
+  const patch = {
+    ...dados,
+    Status: novoStatus,
+    UpdatedAt: new Date()
+  };
+  if (novoStatus === "rotulado" && atual.Status !== "rotulado") {
+    patch.LabeledAt = new Date();
+  }
+
+  // congela a avaliação da IA na primeira vez que o rótulo fica completo — nunca
+  // re-congela depois (IaSnapshotEm já preenchido = já tem um par fixo pra comparar)
+  if (novoStatus === "rotulado" && !atual.IaSnapshotEm) {
+    const ia = await AtendimentoAvaliacao.query().findById(atendimentoId);
+    if (ia) {
+      for (const campo of CAMPOS_SNAPSHOT_IA) patch[`Ia${campo}`] = ia[campo] ?? null;
+      patch.IaSnapshotEm = new Date();
+    }
+  }
+
+  return AtendimentoGoldenLabel.query().patchAndFetchById(atendimentoId, patch);
+}

+ 38 - 8
src/services/atendimentosService.js

@@ -81,14 +81,25 @@ function toDate(value) {
   return Number.isNaN(d.getTime()) ? null : d;
 }
 
-// payload.Cliente.Dados é passthrough (não validado pelo schema) — o ifbot só preenche
-// ErpProtocolo/ErpVinculoConfirmadoEm quando confirma o vínculo do contato com um
-// su_ticket no IXC (hoje isso é quase exclusivo de atendimentos SUP). Nunca deve quebrar
-// a sincronização se vier ausente ou malformado.
+// payload.Cliente.Dados é passthrough (não validado pelo schema) — pode vir como objeto
+// ou como string JSON serializada, dependendo do endpoint do ifbot. Nunca deve quebrar a
+// sincronização se vier ausente, malformado ou com JSON inválido.
+function normalizarDados(dados) {
+  if (typeof dados !== "string") return dados && typeof dados === "object" ? dados : null;
+  try {
+    const parsed = JSON.parse(dados);
+    return parsed && typeof parsed === "object" ? parsed : null;
+  } catch {
+    return null;
+  }
+}
+
+// ErpProtocolo/ErpVinculoConfirmadoEm só são preenchidos pelo ifbot quando confirma o
+// vínculo do contato com um su_ticket no IXC (hoje isso é quase exclusivo de atendimentos
+// SUP). ErpProtocolo vem como string no payload do ifbot (ex.: "207195").
 function extrairVinculoErp(dados) {
-  if (!dados || typeof dados !== "object") return { erpProtocolo: null, erpVinculoConfirmadoEm: null };
+  if (!dados) return { erpProtocolo: null, erpVinculoConfirmadoEm: null };
 
-  // ErpProtocolo vem como string no payload do ifbot (ex.: "207195")
   const n = Number(dados.ErpProtocolo);
   const erpProtocolo = Number.isInteger(n) && n > 0 ? n : null;
   const erpVinculoConfirmadoEm = toDate(dados.ErpVinculoConfirmadoEm);
@@ -96,6 +107,21 @@ function extrairVinculoErp(dados) {
   return { erpProtocolo, erpVinculoConfirmadoEm };
 }
 
+// dados.dadosERP é uma string JSON com o cadastro do cliente no ERP. Extraímos só o Id
+// (id_cliente no IXC), usado pra vincular o atendimento ao cliente certo — o resto do
+// blob ("resto") tem dados pessoais (cpf, endereço, telefone, senha) que não persistimos.
+// Nunca deve quebrar a sincronização se vier ausente ou com JSON inválido.
+function extrairIdClienteIxc(dados) {
+  if (!dados || typeof dados.dadosERP !== "string") return null;
+  try {
+    const parsed = JSON.parse(dados.dadosERP);
+    const n = Number(parsed?.Id);
+    return Number.isInteger(n) && n > 0 ? n : null;
+  } catch {
+    return null;
+  }
+}
+
 export async function salvarAtendimento({ payload, sourceUrl, abertura, telefone }) {
   const now = new Date();
   const trx = await Model.startTransaction();
@@ -106,9 +132,12 @@ export async function salvarAtendimento({ payload, sourceUrl, abertura, telefone
     const clienteTelefone = telefone ?? c.Telefone ?? null;
     if (clienteTelefone) clienteMerge.push("Telefone");
 
-    const { erpProtocolo, erpVinculoConfirmadoEm } = extrairVinculoErp(c.Dados);
+    const dadosNormalizados = normalizarDados(c.Dados);
+    const { erpProtocolo, erpVinculoConfirmadoEm } = extrairVinculoErp(dadosNormalizados);
+    const idClienteIxc = extrairIdClienteIxc(dadosNormalizados);
     if (erpProtocolo) clienteMerge.push("ErpProtocolo");
     if (erpVinculoConfirmadoEm) clienteMerge.push("ErpVinculoConfirmadoEm");
+    if (idClienteIxc) clienteMerge.push("IdClienteIxc");
 
     await AtendimentoCliente.query(trx)
       .insert({
@@ -122,6 +151,7 @@ export async function salvarAtendimento({ payload, sourceUrl, abertura, telefone
         Deadmensage2: c.Deadmensage2 ?? null,
         ErpProtocolo: erpProtocolo,
         ErpVinculoConfirmadoEm: erpVinculoConfirmadoEm,
+        IdClienteIxc: idClienteIxc,
         UpdatedAt: now
       })
       .onConflict("Id")
@@ -302,6 +332,6 @@ export async function listarAtendimentos({ page = 1, pageSize = 20, setor, statu
 export async function obterAtendimento(id) {
   return Atendimento.query()
     .findById(id)
-    .withGraphFetched("[cliente, mensagens, avaliacao]")
+    .withGraphFetched("[cliente, mensagens, avaliacao, goldenLabel]")
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
 }