Explorar el Código

feat: avaliações não resolvidas

leonardo hace 1 mes
padre
commit
72c694c2d7

+ 68 - 0
scripts/backfillTranscricaoSentinel.js

@@ -0,0 +1,68 @@
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { AtendimentoAvaliacao } from "../src/models/AtendimentoAvaliacao.model.js";
+import { AtendimentoMensagem } from "../src/models/AtendimentoMensagem.model.js";
+import { contarPendentes } from "../src/services/atendimentoAvaliacaoService.js";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const APLICAR = process.argv.includes("--apply");
+
+const limiteIdx = process.argv.indexOf("--limite");
+const LIMITE = limiteIdx > -1 ? Number(process.argv[limiteIdx + 1]) : null;
+
+const SENTINELS = ["__processando__", "__erro__"];
+
+async function buscarCandidatos() {
+  const rows = await AtendimentoAvaliacao.query()
+    .where("atendimento_avaliacoes.Avaliavel", true)
+    .join(AtendimentoMensagem.tableName + " as m", "m.AtendimentoId", "atendimento_avaliacoes.AtendimentoId")
+    .whereIn("m.Transcricao", SENTINELS)
+    .whereRaw("atendimento_avaliacoes.UltimaMensagemId >= m.Id")
+    .select(
+      "atendimento_avaliacoes.AtendimentoId",
+      "atendimento_avaliacoes.ScoreAtendente",
+      "atendimento_avaliacoes.UltimaMensagemId"
+    )
+    .distinct("atendimento_avaliacoes.AtendimentoId")
+    .orderBy("atendimento_avaliacoes.AtendimentoId");
+
+  return rows;
+}
+
+async function main() {
+  const candidatos = await buscarCandidatos();
+  const alvos = LIMITE ? candidatos.slice(0, LIMITE) : candidatos;
+
+  console.log(`[backfill-transcricao-sentinel] ${candidatos.length} atendimentos avaliados com sentinel no texto.`);
+  console.log(`[backfill-transcricao-sentinel] ${alvos.length} avaliações serão resetadas para pendente${LIMITE ? ` (--limite ${LIMITE})` : ""}.`);
+
+  if (!APLICAR) {
+    console.log("[backfill-transcricao-sentinel] dry-run (nenhuma alteração feita). Use --apply para executar.");
+    process.exit(0);
+  }
+  if (alvos.length === 0) {
+    console.log("[backfill-transcricao-sentinel] nada para reavaliar.");
+    process.exit(0);
+  }
+
+  const backupDir = path.resolve(__dirname, "logs");
+  fs.mkdirSync(backupDir, { recursive: true });
+  const backupPath = path.join(backupDir, `backfill-transcricao-sentinel-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
+  fs.writeFileSync(backupPath, JSON.stringify(alvos, null, 2));
+  console.log(`[backfill-transcricao-sentinel] backup dos valores antigos salvo em ${backupPath}`);
+
+  const ids = alvos.map((a) => a.AtendimentoId);
+  const pendentesAntes = await contarPendentes();
+  await AtendimentoAvaliacao.query().whereIn("AtendimentoId", ids).patch({ UltimaMensagemId: null });
+  const pendentesDepois = await contarPendentes();
+  console.log(`[backfill-transcricao-sentinel] resetados ${ids.length} — fila de pendentes ${pendentesAntes} -> ${pendentesDepois}.`);
+  console.log("[backfill-transcricao-sentinel] pronto. Agora rode scripts/avaliarTudo.js para drenar a fila.");
+
+  process.exit(0);
+}
+
+main().catch((e) => {
+  console.error("[backfill-transcricao-sentinel] falha:", e.message);
+  process.exit(1);
+});

+ 99 - 0
scripts/reavaliarAudiosSemTranscricao.js

@@ -0,0 +1,99 @@
+import "dotenv/config";
+import { Atendimento } from "../src/models/Atendimento.model.js";
+import { AtendimentoMensagem } from "../src/models/AtendimentoMensagem.model.js";
+import { avaliarAtendimento } from "../src/services/atendimentoAvaliacaoService.js";
+import { AUDIO_MEDIA_TYPES } from "../src/utils/atendimentoFormat.js";
+
+// backfill pós-correção do viés de julgamento em áudios de atendente sem transcrição
+// (ver plano "corrigir viés de julgamento em áudios de atendente sem transcrição",
+// 2026-08-10): reavalia atendimentos que já foram avaliados ANTES da mudança de prompt/
+// placeholder e que têm ao menos uma mensagem de áudio sem transcrição real, pra que a
+// nova regra do AVALIACAO_SYSTEM_PROMPT seja aplicada ao histórico.
+
+const limiteIdx = process.argv.indexOf("--limite");
+const LIMITE = limiteIdx > -1 ? Number(process.argv[limiteIdx + 1]) : null;
+
+// --janela 19-8: só processa dentro do intervalo de horas (cruzando meia-noite, se preciso);
+// fora dele, pausa e fica checando a cada 5min até reabrir — pensado pra rodar madrugadas
+// seguidas sem precisar reiniciar o processo manualmente.
+const janelaIdx = process.argv.indexOf("--janela");
+const JANELA = janelaIdx > -1 ? process.argv[janelaIdx + 1] : null;
+const [JANELA_INICIO, JANELA_FIM] = JANELA ? JANELA.split("-").map(Number) : [null, null];
+
+const SENTINELS = ["__processando__", "__erro__"];
+
+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; // janela cruza a meia-noite
+}
+
+async function aguardarJanela(inicioHora, fimHora) {
+  let avisado = false;
+  while (!dentroDaJanela(inicioHora, fimHora)) {
+    if (!avisado) {
+      console.log(`[reavaliar-audios] fora da janela noturna (${inicioHora}h-${fimHora}h) — pausado até reabrir`);
+      avisado = true;
+    }
+    await new Promise((r) => setTimeout(r, 5 * 60 * 1000));
+  }
+  if (avisado) console.log("[reavaliar-audios] janela reaberta, retomando");
+}
+
+async function main() {
+  // subquery (não JOIN) pra evitar duplicar uma linha de atendimento por cada mensagem de
+  // áudio que ele tiver — JOIN + DISTINCT ... ORDER BY Abertura quebra no MySQL porque
+  // Abertura não entra no SELECT do DISTINCT.
+  const atendimentosComAudioPendente = AtendimentoMensagem.query()
+    .select("AtendimentoId")
+    .whereIn("Tipodemidia", [...AUDIO_MEDIA_TYPES])
+    .where((qb) => {
+      qb.whereNull("Transcricao").orWhere("Transcricao", "").orWhereIn("Transcricao", SENTINELS);
+    });
+
+  let query = Atendimento.query()
+    .join("atendimento_avaliacoes as av", "av.AtendimentoId", "atendimentos.Id")
+    .where("av.Avaliavel", true)
+    .whereIn("atendimentos.Id", atendimentosComAudioPendente)
+    .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-audios] 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-audios] 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-audios] progresso: ${processados}/${total} | erros: ${erros} | ${Math.round(s / 60)}min decorridos | ~${restamMin}min restantes`
+        );
+      }
+    }
+  }
+
+  console.log(`[reavaliar-audios] concluído: ${processados}/${total} | erros: ${erros} | ${Math.round((Date.now() - t0) / 60000)}min total`);
+  process.exit(0);
+}
+
+main().catch((err) => {
+  console.error("[reavaliar-audios] falha fatal:", err);
+  process.exit(1);
+});

+ 20 - 2
src/controllers/Atendimentos.Controller.js

@@ -137,6 +137,14 @@ export const AtendimentosController = {
       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,
@@ -147,7 +155,9 @@ export const AtendimentosController = {
         statusCancelamento,
         scoreMax: Number.isFinite(scoreMax) ? scoreMax : undefined,
         busca,
-        ordenacao
+        ordenacao,
+        dataInicio,
+        dataFim
       });
       res.json(result);
     } catch (err) {
@@ -162,7 +172,15 @@ export const AtendimentosController = {
       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 result = await estatisticasAvaliacoes({ setor, resolvido, sentimento, statusCancelamento, busca });
+      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 });
       res.json(result);
     } catch (err) {
       next(err);

+ 11 - 4
src/services/atendimentoAvaliacaoService.js

@@ -447,7 +447,7 @@ function parseDataBusca(termo) {
   return null;
 }
 
-export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvido, sentimento, statusCancelamento, scoreMax, busca, ordenacao } = {}) {
+export async function listarAvaliacoes({ page = 1, pageSize = 20, 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
@@ -463,6 +463,8 @@ export async function listarAvaliacoes({ page = 1, pageSize = 20, setor, resolvi
   if (statusCancelamento) query.where("StatusCancelamento", statusCancelamento);
   if (scoreMax !== undefined) query.where("ScoreAtendente", "<=", scoreMax);
   if (setor) query.where("a.Setor", setor);
+  if (dataInicio) query.where("a.Abertura", ">=", dataInicio);
+  if (dataFim) query.where("a.Abertura", "<=", `${dataFim} 23:59:59`);
 
   const termo = typeof busca === "string" ? busca.trim() : "";
 
@@ -517,8 +519,9 @@ export async function listarSetores() {
   return rows.map((r) => ({ sigla: r.Setor, nome: nomeSetor(r.Setor), total: Number(r.total) }));
 }
 
-export async function estatisticasAvaliacoes({ setor, resolvido, sentimento, statusCancelamento, busca } = {}) {
+export async function estatisticasAvaliacoes({ setor, resolvido, sentimento, statusCancelamento, busca, dataInicio, dataFim } = {}) {
   const termo = typeof busca === "string" ? busca.trim() : "";
+  const precisaJoin = Boolean(setor || termo || dataInicio || dataFim);
 
   // mesmos filtros de listarAvaliacoes, para os cards do topo baterem com a lista abaixo
   // deles na tela (pendentes/totalAtendimentos ficam de fora de propósito: representam o
@@ -526,13 +529,15 @@ export async function estatisticasAvaliacoes({ setor, resolvido, sentimento, sta
   // que só existe em quem já foi avaliado)
   const base = () => {
     const q = AtendimentoAvaliacao.query().where("Avaliavel", true);
-    if (setor || termo) {
+    if (precisaJoin) {
       q.join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId");
     }
     if (setor) q.where("a.Setor", setor);
     if (resolvido) q.where("Resolvido", resolvido);
     if (sentimento) q.where("Sentimento", sentimento);
     if (statusCancelamento) q.where("StatusCancelamento", statusCancelamento);
+    if (dataInicio) q.where("a.Abertura", ">=", dataInicio);
+    if (dataFim) q.where("a.Abertura", "<=", `${dataFim} 23:59:59`);
     if (termo) {
       const like = `%${termo.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
       const data = parseDataBusca(termo);
@@ -546,7 +551,7 @@ export async function estatisticasAvaliacoes({ setor, resolvido, sentimento, sta
     return q;
   };
 
-  const [scoreRow, avaliados, porResolvido, porSentimento, porCancelamento, visitas, pendentes, totalAtendimentos] = await Promise.all([
+  const [scoreRow, avaliados, porResolvido, porSentimento, porCancelamento, visitas, porSetorRows, pendentes, totalAtendimentos] = await Promise.all([
     base().avg("ScoreAtendente as media").whereNotNull("ScoreAtendente").first(),
     base().resultSize(),
     base().select("Resolvido").count("* as total").groupBy("Resolvido"),
@@ -557,6 +562,7 @@ export async function estatisticasAvaliacoes({ setor, resolvido, sentimento, sta
       .select("HorarioVisitaInformado")
       .count("* as total")
       .groupBy("HorarioVisitaInformado"),
+    precisaJoin ? base().select("a.Setor").count("* as total").groupBy("a.Setor") : Promise.resolve([]),
     contarPendentes({ setor }),
     setor ? Atendimento.query().where("Setor", setor).resultSize() : Atendimento.query().resultSize()
   ]);
@@ -571,6 +577,7 @@ export async function estatisticasAvaliacoes({ setor, resolvido, sentimento, sta
     sentimento: toMap(porSentimento, "Sentimento"),
     cancelamento: toMap(porCancelamento, "StatusCancelamento"),
     visitasAgendadas: toMap(visitas, "HorarioVisitaInformado"),
+    porSetor: toMap(porSetorRows, "Setor"),
     pendentes,
     totalAtendimentos
   };