Bladeren bron

ajustes avaliacoes

leonardo 2 maanden geleden
bovenliggende
commit
974ffa16f0

+ 0 - 12
loaders/txtLoader.js

@@ -1,12 +0,0 @@
-export async function loadTxtFromString(text, { source, metadata } = {}) {
-  const normalized = String(text ?? "").trim();
-  if (!normalized) return [];
-
-  return [
-    {
-      text: normalized,
-      source: source ?? "txt",
-      metadata: metadata ?? null
-    }
-  ];
-}

+ 64 - 0
scripts/evalAtendimentosGate.mjs

@@ -0,0 +1,64 @@
+import { classificarPergunta } from "#services/atendimentosQueryService.js";
+
+
+const CASES = [
+  
+  { query: "cliente pedindo segunda via de boleto", esperado: true, tipo: "positivo" },
+  { query: "problema de internet lenta", esperado: true, tipo: "positivo" },
+  { query: "cancelamento de contrato", esperado: true, tipo: "positivo" },
+  { query: "cliente reclamando que a internet caiu de novo", esperado: true, tipo: "positivo" },
+  { query: "solicitação de mudança de endereço de instalação", esperado: true, tipo: "positivo" },
+  { query: "cliente questionando cobrança duplicada na fatura", esperado: true, tipo: "positivo" },
+  { query: "pedido de upgrade de plano de internet", esperado: true, tipo: "positivo" },
+
+  
+  { query: "me conta algo engraçado que um cliente falou", esperado: true, tipo: "quase-armadilha" },
+  { query: "qual foi a reclamação mais estranha que já recebemos", esperado: true, tipo: "quase-armadilha" },
+  { query: "quantos atendimentos tivemos essa semana", esperado: true, tipo: "quase-armadilha" },
+  { query: "o que aconteceu no último atendimento", esperado: true, tipo: "quase-armadilha" },
+  { query: "vocês têm histórico de conversas com clientes?", esperado: true, tipo: "quase-armadilha" },
+
+  
+  { query: "previsão do tempo para amanhã em Paris", esperado: false, tipo: "negativo" },
+  { query: "receita de bolo de chocolate", esperado: false, tipo: "negativo" },
+  { query: "quem ganhou o jogo de futebol ontem", esperado: false, tipo: "negativo" },
+  { query: "qual a capital da Austrália", esperado: false, tipo: "negativo" },
+  { query: "como treinar para uma maratona", esperado: false, tipo: "negativo" },
+  { query: "melhores filmes de ficção científica dos anos 80", esperado: false, tipo: "negativo" }
+];
+
+async function main() {
+  const rows = [];
+  for (const c of CASES) {
+    const { relacionado } = await classificarPergunta(c.query, []);
+    rows.push({
+      query: c.query,
+      tipo: c.tipo,
+      esperado: c.esperado,
+      obtido: relacionado,
+      resultado: relacionado === c.esperado ? "OK" : (c.esperado ? "FALSO-NEGATIVO (grave)" : "falso-positivo")
+    });
+  }
+
+  console.log("\n=== evalAtendimentosGate ===\n");
+  console.table(rows);
+
+  const acertos = rows.filter((r) => r.resultado === "OK").length;
+  const falsosNegativos = rows.filter((r) => r.resultado.startsWith("FALSO-NEGATIVO"));
+  const falsosPositivos = rows.filter((r) => r.resultado === "falso-positivo");
+
+  console.log("\n--- resumo ---");
+  console.log(`acurácia: ${acertos}/${rows.length} (${((acertos / rows.length) * 100).toFixed(0)}%)`);
+  console.log(`falsos-negativos (bloqueou pergunta legítima, ERRO GRAVE): ${falsosNegativos.length}`);
+  if (falsosNegativos.length) console.log(falsosNegativos.map((r) => `  - "${r.query}"`).join("\n"));
+  console.log(`falsos-positivos (deixou passar pergunta fora do tema): ${falsosPositivos.length}`);
+  if (falsosPositivos.length) console.log(falsosPositivos.map((r) => `  - "${r.query}"`).join("\n"));
+  console.log("");
+}
+
+main()
+  .then(() => process.exit(0))
+  .catch((err) => {
+    console.error("[evalAtendimentosGate] erro:", err);
+    process.exit(1);
+  });

+ 93 - 0
scripts/evalAtendimentosRetrieval.mjs

@@ -0,0 +1,93 @@
+import { config } from "#config/index.js";
+import { buscarAtendimentosSemelhantes } from "#services/atendimentoRagService.js";
+
+function parseArgs(argv) {
+  const flags = { hyde: null, minScore: 0, topK: 5, label: "" };
+  for (const arg of argv) {
+    if (arg === "--hyde") flags.hyde = true;
+    else if (arg === "--no-hyde") flags.hyde = false;
+    else if (arg.startsWith("--min-score=")) flags.minScore = Number(arg.split("=")[1]);
+    else if (arg.startsWith("--top-k=")) flags.topK = Number(arg.split("=")[1]);
+    else if (arg.startsWith("--label=")) flags.label = arg.split("=").slice(1).join("=");
+  }
+  return flags;
+}
+
+const CASES = [
+  { query: "cliente pedindo segunda via de boleto", type: "positive" },
+  { query: "problema de internet lenta", type: "positive" },
+  { query: "cancelamento de contrato", type: "positive" },
+  { query: "cliente reclamando que a internet caiu de novo", type: "positive" },
+  { query: "solicitação de mudança de endereço de instalação", type: "positive" },
+  { query: "cliente questionando cobrança duplicada na fatura", type: "positive" },
+  { query: "pedido de upgrade de plano de internet", type: "positive" },
+  { query: "previsão do tempo para amanhã em Paris", type: "negative" },
+  { query: "receita de bolo de chocolate", type: "negative" },
+  { query: "quem ganhou o jogo de futebol ontem", type: "negative" },
+  { query: "qual a capital da Austrália", type: "negative" },
+  { query: "como treinar para uma maratona", type: "negative" },
+  { query: "melhores filmes de ficção científica dos anos 80", type: "negative" }
+];
+
+async function runCase(testCase, flags) {
+  const hits = await buscarAtendimentosSemelhantes({
+    query: testCase.query,
+    topK: flags.topK,
+    minScore: flags.minScore,
+    ...(flags.hyde !== null ? { hyde: flags.hyde } : {})
+  });
+
+  const top1 = hits[0] ?? null;
+
+  return {
+    query: testCase.query,
+    type: testCase.type,
+    top1_score: top1 ? top1.score.toFixed(4) : "-",
+    top1_source: top1?.source ?? "-"
+  };
+}
+
+async function main() {
+  const flags = parseArgs(process.argv.slice(2));
+  const hydeEfetivo = flags.hyde !== null ? flags.hyde : config.atendimentosRag.hyde;
+
+  console.log(`\n=== evalAtendimentosRetrieval ${flags.label ? `[${flags.label}] ` : ""}===`);
+  console.log(
+    `modelo=${config.ollama.embeddingsModel} minScore=${flags.minScore} topK=${flags.topK} ` +
+    `hyde=${hydeEfetivo} colecao=${config.atendimentosRag.collection}\n`
+  );
+
+  const rows = [];
+  for (const testCase of CASES) {
+    rows.push(await runCase(testCase, flags));
+  }
+
+  console.table(rows);
+
+  const positives = rows.filter((r) => r.type === "positive");
+  const negatives = rows.filter((r) => r.type === "negative");
+
+  const positiveScores = positives.map((r) => Number(r.top1_score)).filter((n) => !Number.isNaN(n));
+  const negativeScores = negatives.map((r) => Number(r.top1_score)).filter((n) => !Number.isNaN(n));
+
+  const avg = (arr) => (arr.length ? arr.reduce((a, b) => a + b, 0) / arr.length : NaN);
+
+  const positivoMin = positiveScores.length ? Math.min(...positiveScores) : NaN;
+  const negativoMax = negativeScores.length ? Math.max(...negativeScores) : NaN;
+  const gap = positivoMin - negativoMax;
+
+  console.log("\n--- resumo ---");
+  console.log(`positivos: score médio top-1 = ${avg(positiveScores).toFixed(4)}`);
+  console.log(`positivos: score mínimo top-1 (piso de segurança) = ${positiveScores.length ? positivoMin.toFixed(4) : "-"}`);
+  console.log(`negativos: score médio top-1 = ${avg(negativeScores).toFixed(4)}`);
+  console.log(`negativos: score máximo top-1 (teto de ruído) = ${negativeScores.length ? negativoMax.toFixed(4) : "-"}`);
+  console.log(`gap (piso positivos - teto negativos) = ${Number.isNaN(gap) ? "-" : gap.toFixed(4)} ${!Number.isNaN(gap) ? (gap > 0 ? "-> HÁ separação viável" : "-> SEM separação") : ""}`);
+  console.log("");
+}
+
+main()
+  .then(() => process.exit(0))
+  .catch((err) => {
+    console.error("[evalAtendimentosRetrieval] erro:", err);
+    process.exit(1);
+  });

+ 2 - 2
scripts/evalRetrieval.mjs

@@ -2,7 +2,7 @@
 
 import { config } from "#config/index.js";
 import { searchDocs } from "#services/searchService.js";
-import { rewriteQuery, generateHydeDocument } from "#chat/chatChain.js";
+import { rewriteQuery, generateHydeDocument, HYDE_SYSTEM_PROMPT } from "#chat/chatChain.js";
 
 function parseArgs(argv) {
   const flags = { hyde: false, rewrite: false, minScore: 0, topK: 5, label: "" };
@@ -40,7 +40,7 @@ function sourceMatches(hit, expectedIncludes) {
 async function resolveEmbeddingQuery(originalQuery, flags) {
   const searchQuery = flags.rewrite ? await rewriteQuery(originalQuery, []) : originalQuery;
   if (!flags.hyde) return { searchQuery, embeddingQuery: searchQuery, embedRole: "query" };
-  const hydeText = await generateHydeDocument(searchQuery);
+  const hydeText = await generateHydeDocument(searchQuery, HYDE_SYSTEM_PROMPT);
   return hydeText
     ? { searchQuery, embeddingQuery: hydeText, embedRole: "passage" }
     : { searchQuery, embeddingQuery: searchQuery, embedRole: "query" };

+ 45 - 83
src/chat/chatChain.js

@@ -4,6 +4,8 @@ import { getRecentMessages } from "../services/conversationsService.js";
 import { searchDocs } from "../services/searchService.js";
 import { resolverContextoAtendimentos } from "../services/atendimentosQueryService.js";
 import { generateHydeDocument } from "../services/hydeService.js";
+import { rerankHits } from "../services/rerankService.js";
+import { createTtlCache } from "../services/simpleCache.js";
 
 const SYSTEM_PROMPT = [
   "Você é o Oráculo, assistente interno da Star.",
@@ -18,9 +20,12 @@ const ATENDIMENTOS_SYSTEM_PROMPT = [
   "Você é o Oráculo, assistente interno da Star, no modo de consulta a atendimentos de suporte ao cliente.",
   "Você tem acesso legítimo e autorizado a esses registros — é uma ferramenta interna da empresa para analisar o próprio histórico de atendimento, não uma conversa privada de terceiros externos. NUNCA recuse responder por motivos de privacidade/confidencialidade, mesmo que as mensagens tenham linguagem informal, gírias ou desabafos do cliente — relate o conteúdo normalmente, como faria com qualquer outro dado interno da empresa.",
   "Responda em português brasileiro de forma natural e o mais completa e detalhada possível, usando TUDO que estiver literalmente disponível no CONTEXTO (cabeçalho, sequência de atendentes, cada mensagem relevante) — não resuma demais nem omita detalhes reais só para ser breve.",
-  "O CONTEXTO abaixo vem diretamente do banco de dados interno de atendimentos (sem busca por similaridade) e pode ser de dois tipos:",
+  "O CONTEXTO abaixo vem diretamente do banco de dados interno de atendimentos (sem busca por similaridade) e pode ser de vários tipos:",
   "(1) uma contagem/estatística já calculada (ex: quantidade de atendimentos por setor) — nesse caso, apenas apresente os números exatamente como estão, sem recalcular ou arredondar;",
   "(2) trechos de conversas reais entre atendentes e clientes, cada um com código do protocolo, setor, cliente, data ('Aberto em') e uma linha 'Atendentes envolvidos' já calculada — use-os para responder o que foi dito, reclamado, resolvido ou combinado, citando o código do protocolo entre parênteses.",
+  "(3) uma avaliação automática por IA de um atendimento específico (nota, resolvido, sentimento do cliente, resumo e sugestões de melhoria) — trate como uma análise automatizada feita por outra IA, não como um fato relatado pelo cliente ou uma pesquisa de satisfação; se o bloco disser 'DESATUALIZADA', avise o usuário disso antes de responder sobre a qualidade desse atendimento;",
+  "(4) uma agregação de qualidade (nota média, distribuição de resolução, contagem de atendimentos mal avaliados) calculada a partir de avaliações automáticas por IA de vários atendimentos — apresente os números exatamente como estão, sem recalcular, deixando claro que a nota vem de avaliação automatizada por IA, não de pesquisa respondida pelo cliente;",
+  "(5) uma amostra de avaliações de IA recentes com sugestões de melhoria, usada para perguntas sobre padrões ou lições aprendidas — quando isso aparecer, identifique e agrupe os temas/sugestões que se repetem entre os itens em vez de apenas listar cada um separadamente, e deixe claro que é uma amostra recente (não necessariamente todas as avaliações do período).",
   "Perguntas sobre quantos atendentes participaram, se houve transferência de atendente, ou quem atendeu primeiro/depois: responda usando diretamente a linha 'Atendentes envolvidos, em ordem de participação' do cabeçalho — ela já é calculada corretamente, não precisa contar você mesmo nas mensagens.",
   "Quando o CONTEXTO trouxer vários atendimentos do mesmo cliente, eles vêm ordenados do mais recente para o mais antigo (compare o campo 'Aberto em') — use isso para responder perguntas sobre a última/mais recente conversa com um cliente.",
   "Se o CONTEXTO trouxer mais de um atendimento, NÃO misture fatos de atendimentos diferentes numa mesma resposta — trate cada código de protocolo separadamente.",
@@ -107,7 +112,10 @@ export async function rewriteQuery(query, history = []) {
           { role: "system", content: REWRITE_SYSTEM_PROMPT },
           { role: "user", content: query }
         ];
-    const { content } = await chatCompletion({ messages, options: { temperature: 0.1 } });
+    const { content } = await chatCompletion({
+      messages,
+      options: { temperature: 0.1, num_ctx: config.llm.numCtx, num_predict: config.llm.auxNumPredict }
+    });
     return content?.trim() || query;
   } catch (err) {
     console.warn("[chatChain] rewriteQuery falhou, usando query original:", err.message);
@@ -115,71 +123,6 @@ export async function rewriteQuery(query, history = []) {
   }
 }
 
-const RERANK_SYSTEM_PROMPT = [
-  "Você reordena trechos de documentos internos por relevância à pergunta do usuário.",
-  'Responda APENAS com um objeto JSON no formato {"order": [...]}, contendo TODOS os índices',
-  '(0-based) de cada trecho, em ordem decrescente de relevância. Exemplo: {"order": [2,0,3,1]}.',
-  "Não inclua explicações, texto extra ou markdown."
-].join("\n");
-
-const RERANK_SNIPPET_LENGTH = 300;
-
-function buildRerankPrompt(query, hits) {
-  const listing = hits
-    .map((h, i) => `[${i}] ${h.text.slice(0, RERANK_SNIPPET_LENGTH)}`)
-    .join("\n\n");
-  return `Pergunta: ${query}\n\nTrechos:\n${listing}`;
-}
-
-function parseRerankOrder(content, len) {
-  try {
-    const cleaned = String(content ?? "")
-      .trim()
-      .replace(/^```json\s*/i, "")
-      .replace(/^```\s*/, "")
-      .replace(/```$/, "")
-      .trim();
-    const parsed = JSON.parse(cleaned);
-    const arr = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.order) ? parsed.order : null;
-    if (!arr) return null;
-
-    const seen = new Set();
-    const valid = [];
-    for (const v of arr) {
-      const idx = Number(v);
-      if (Number.isInteger(idx) && idx >= 0 && idx < len && !seen.has(idx)) {
-        seen.add(idx);
-        valid.push(idx);
-      }
-    }
-    if (valid.length === 0) return null;
-    for (let i = 0; i < len; i++) if (!seen.has(i)) valid.push(i);
-    return valid;
-  } catch {
-    return null;
-  }
-}
-
-async function rerankHits(query, hits) {
-  if (hits.length <= 1) return hits;
-  try {
-    const { content } = await chatCompletion({
-      messages: [
-        { role: "system", content: RERANK_SYSTEM_PROMPT },
-        { role: "user", content: buildRerankPrompt(query, hits) }
-      ],
-      format: "json",
-      options: { temperature: 0.1 }
-    });
-    const order = parseRerankOrder(content, hits.length);
-    if (!order) console.warn("[chatChain] rerankHits: resposta não parseável, mantendo ordem original");
-    const reordered = order ? order.map((i) => hits[i]) : hits;
-    return reordered.slice(0, config.rag.rerankTopN);
-  } catch (err) {
-    console.warn("[chatChain] rerankHits falhou, mantendo ordem original:", err.message);
-    return hits.slice(0, config.rag.rerankTopN);
-  }
-}
 
 const HYDE_SYSTEM_PROMPT = [
   "Você é um redator técnico que escreve trechos de manuais internos de redes e",
@@ -193,7 +136,7 @@ const HYDE_SYSTEM_PROMPT = [
   "Responda em português brasileiro."
 ].join("\n");
 
-export { generateHydeDocument };
+export { generateHydeDocument, HYDE_SYSTEM_PROMPT };
 
 async function loadHistory(conversationId, userId) {
   if (!conversationId) return [];
@@ -205,8 +148,7 @@ async function loadHistory(conversationId, userId) {
   }
 }
 
-// modo atendimentos prioriza fidelidade ao CONTEXTO (fatos de banco de dados) sobre
-// fluência criativa — temperatura bem mais baixa reduz variação entre chamadas idênticas.
+
 const ATENDIMENTOS_TEMPERATURE = 0.1;
 
 function buildDefaultOptions(override, modeDefaults = {}) {
@@ -249,9 +191,11 @@ const REWRITE_HYDE_SYSTEM_PROMPT = [
   '"rewrittenQuery": reescreva a ÚLTIMA pergunta do usuário como uma query autocontida e específica',
   "para busca em documentos internos da empresa, em linguagem natural (nunca em código, SQL,",
   "fórmulas ou pseudocódigo), resolvendo pronomes/referências via HISTÓRICO DA CONVERSA quando houver.",
-  '"hydeDocument": um parágrafo curto (3 a 6 frases) no estilo de um trecho real de documentação técnica',
-  "interna (comandos, telas, campos, passos) que responderia a essa pergunta, plausível mesmo sem certeza,",
-  "pois será usado apenas para busca por similaridade, nunca mostrado ao usuário. Responda em português brasileiro.",
+  '"hydeDocument": um parágrafo curto (3 a 6 frases) no estilo de um trecho real de manual interno de',
+  "redes e equipamentos (comandos Huawei, configuração de roteadores TP-Link, processos e políticas",
+  "internas da empresa, etc.) que responderia a essa pergunta — nomes de comandos, telas, campos, passos,",
+  "quando fizer sentido — plausível mesmo sem certeza, pois será usado apenas para busca por similaridade,",
+  "nunca mostrado ao usuário. Responda em português brasileiro.",
   "Não inclua explicações, texto extra ou markdown fora do JSON."
 ].join(" ");
 
@@ -276,9 +220,19 @@ function parseRewriteHydeResult(content, originalQuery) {
   }
 }
 
+
+const REWRITE_HYDE_CACHE_TTL_MS = 15 * 60 * 1000;
+const rewriteHydeCache = createTtlCache(REWRITE_HYDE_CACHE_TTL_MS);
+
 async function rewriteAndHyde(query, history) {
+  const hasHistory = history.length > 0;
+  const cacheKey = hasHistory ? null : query;
+  if (cacheKey) {
+    const cached = rewriteHydeCache.get(cacheKey);
+    if (cached) return cached;
+  }
+
   try {
-    const hasHistory = history.length > 0;
     const messages = [
       { role: "system", content: REWRITE_HYDE_SYSTEM_PROMPT },
       ...(hasHistory
@@ -286,9 +240,14 @@ async function rewriteAndHyde(query, history) {
         : []),
       { role: "user", content: query }
     ];
-    const { content } = await chatCompletion({ messages, format: "json", options: { temperature: 0.1 } });
+    const { content } = await chatCompletion({
+      messages,
+      format: "json",
+      options: { temperature: 0.1, num_ctx: config.llm.numCtx, num_predict: config.llm.auxNumPredict }
+    });
     const result = parseRewriteHydeResult(content, query);
     if (!result.hydeDocument) console.warn("[chatChain] rewriteAndHyde: hydeDocument ausente/inválido, seguindo sem HyDE");
+    if (cacheKey) rewriteHydeCache.set(cacheKey, result);
     return result;
   } catch (err) {
     console.warn("[chatChain] rewriteAndHyde falhou, usando query original sem HyDE:", err.message);
@@ -326,10 +285,7 @@ function resolveMode(mode) {
   return MODES[mode] ?? MODES.documentos;
 }
 
-// llama3.1 às vezes recusa relatar o conteúdo de atendimentos com linguagem informal/crua
-// do cliente, tratando como "conversa privada de terceiros" mesmo com instrução no prompt.
-// Reaproveita o padrão de retry-em-recusa já usado em ingestService.js (isRefusal.js) —
-// aqui com detecção mais ampla, já que essas recusas não seguem o padrão "desculpe...".
+
 const ATENDIMENTOS_REFUSAL_PATTERNS = [
   "não posso fornecer",
   "nao posso fornecer",
@@ -399,14 +355,17 @@ export async function answerWithContext({ message, conversationId, userId, optio
     hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
     timer.mark("search");
     if (config.rag.rerank && hits.length > 1) {
-      hits = await rerankHits(searchQuery, hits);
+      hits = await rerankHits(searchQuery, hits, { topN: config.rag.rerankTopN, numCtx: config.llm.numCtx });
       timer.mark("rerank");
     }
   }
 
   const context = buildContextBlock(hits);
   const messages = buildMessages(message, context, history, modeConfig);
-  const modeDefaults = mode === "atendimentos" ? { temperature: ATENDIMENTOS_TEMPERATURE } : {};
+  const modeDefaults =
+    mode === "atendimentos"
+      ? { temperature: ATENDIMENTOS_TEMPERATURE, num_ctx: config.llm.atendimentosNumCtx }
+      : {};
   const finalOptions = buildDefaultOptions(options, modeDefaults);
   const completion =
     mode === "atendimentos"
@@ -450,7 +409,7 @@ export async function answerWithContextStream({
     onStatus?.("encontrou", hits.length);
     if (config.rag.rerank && hits.length > 1) {
       onStatus?.("reordenando");
-      hits = await rerankHits(searchQuery, hits);
+      hits = await rerankHits(searchQuery, hits, { topN: config.rag.rerankTopN, numCtx: config.llm.numCtx });
       timer.mark("rerank");
     }
   }
@@ -460,7 +419,10 @@ export async function answerWithContextStream({
   const context = buildContextBlock(hits);
   const messages = buildMessages(message, context, history, modeConfig);
   onStatus?.("gerando");
-  const modeDefaults = mode === "atendimentos" ? { temperature: ATENDIMENTOS_TEMPERATURE } : {};
+  const modeDefaults =
+    mode === "atendimentos"
+      ? { temperature: ATENDIMENTOS_TEMPERATURE, num_ctx: config.llm.atendimentosNumCtx }
+      : {};
   const finalOptions = buildDefaultOptions(options, modeDefaults);
   // modo atendimentos precisa checar recusa antes de mostrar a resposta ao usuário,
   // então gera a resposta inteira primeiro (sem streaming real) e emite como um chunk só.

+ 9 - 4
src/config/index.js

@@ -66,21 +66,23 @@ export const config = {
     topP: Number(process.env.LLM_TOP_P ?? 0.9),
     numPredict: Number(process.env.LLM_NUM_PREDICT ?? 4096),
     repeatPenalty: Number(process.env.LLM_REPEAT_PENALTY ?? 1.1),
-    numCtx: Number(process.env.LLM_NUM_CTX ?? 32768)
+    numCtx: Number(process.env.LLM_NUM_CTX ?? 8192),
+    atendimentosNumCtx: Number(process.env.LLM_ATENDIMENTOS_NUM_CTX ?? 32768),
+    auxNumPredict: Number(process.env.LLM_AUX_NUM_PREDICT ?? 400)
   },
   rateLimit: {
     perMinute: Number(process.env.RATE_LIMIT_PER_MINUTE ?? 0)
   },
   ifbot: {
     baseUrl: process.env.IFBOT_BASE_URL ?? "https://star.ifbot.com.br/api",
-    // arquivos de mídia (/data/img, /data/aud) são servidos na raiz do domínio, fora do /api
+
     mediaBaseUrl: (process.env.IFBOT_MEDIA_BASE_URL ?? (process.env.IFBOT_BASE_URL ?? "https://star.ifbot.com.br/api").replace(/\/api\/?$/, "")).replace(/\/+$/, ""),
     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),
@@ -95,7 +97,10 @@ export const config = {
     topK: Number(process.env.ATENDIMENTOS_RAG_TOP_K ?? 20),
     minScore: Number(process.env.ATENDIMENTOS_RAG_MIN_SCORE ?? 0.5),
     searchEnabled: process.env.ATENDIMENTOS_RAG_SEARCH_ENABLED === "true",
-    hyde: process.env.ATENDIMENTOS_RAG_HYDE === "true"
+    hyde: process.env.ATENDIMENTOS_RAG_HYDE === "true",
+    
+    rerank: process.env.ATENDIMENTOS_RAG_RERANK !== "false",
+    rerankTopN: Number(process.env.ATENDIMENTOS_RAG_RERANK_TOP_N ?? 10)
   }
 };
 

+ 66 - 6
src/services/atendimentoAvaliacaoService.js

@@ -17,10 +17,18 @@ const AVALIACAO_SYSTEM_PROMPT = [
   "Continue avaliando normalmente a qualidade e o conteúdo da resposta dada.",
   "Baseie-se APENAS no que está na conversa; não invente fatos, nomes ou desfechos.",
   "",
+  "Tempo de resposta NÃO é o critério principal da nota. Intervalos de minutos até cerca de uma hora",
+  "entre mensagens são comuns na operação e, sozinhos, não devem derrubar o score — priorize sempre se",
+  "o problema foi entendido, se a solução foi correta e clara, e se o cliente saiu satisfeito. Só deixe",
+  "a demora pesar de forma decisiva na nota quando for extrema (várias horas sem resposta, atendimento",
+  "abandonado) ou quando tiver causado um desfecho ruim real (cliente foi embora sem solução por falta",
+  "de retorno). Fora isso, mencione a demora só como detalhe na justificativa/sugestões, sem rebaixar o score.",
+  "",
   "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).",
+  "9-10 = exemplar; 7-8 = bom, resolveu com pequenas falhas; 5-6 = mediano; 3-4 = fraco (respostas",
+  "confusas, informação incorreta, cliente insistindo sem retorno adequado); 1-2 = muito ruim (cliente",
+  "abandonado, tratado mal ou induzido a erro).",
   "Um atendimento resolvido com cliente satisfeito NUNCA recebe score abaixo de 6.",
   "Se nenhum atendente humano participou, use score_atendente = null.",
   "",
@@ -33,10 +41,10 @@ const AVALIACAO_SYSTEM_PROMPT = [
   'Cliente: "alguém aí? já fazem 60 minutos"',
   'Atendente Marcos: "Desculpe a demora! Vou reiniciar sua conexão remotamente."',
   'Cliente: "Voltou, obrigado!"',
-  '→ justificativa_score: "Problema resolvido e cliente satisfeito ao final, mas o atendente demorou mais',
-  '  de 60 minutos para responder, prejudicando a experiência."',
-  "→ score_atendente: 6 — mesmo com falha grave de processo, resolvido=sim e sentimento=positivo nunca",
-  "  ficam abaixo de 6; a falha já está registrada na justificativa e nas sugestões de melhoria.",
+  '→ justificativa_score: "Problema resolvido e cliente satisfeito ao final; atendente demorou mais de',
+  '  60 minutos para responder, o que vale registrar, mas não compromete o resultado."',
+  "→ score_atendente: 6 — mesmo com o atraso extremo registrado, resolvido=sim e sentimento=positivo",
+  "  nunca ficam abaixo de 6; o atraso já está anotado na justificativa e nas sugestões de melhoria.",
   "",
   "Exemplo 2 — atendimento exemplar (score 9-10):",
   'Cliente: "Minha internet está lenta há dois dias."',
@@ -447,3 +455,55 @@ export async function estatisticasAvaliacoes({ setor } = {}) {
     totalAtendimentos
   };
 }
+
+// usado pelo chat de atendimentos (atendimentosQueryService.js) para responder perguntas
+// agregadas de qualidade — dedicado, em vez de reaproveitar estatisticasAvaliacoes (que
+// não filtra por status/período e mistura métricas do dashboard admin como visitas/pendentes).
+export const SCORE_RUIM_MAX = 5; // consistente com SCORE_MINIMO_RESOLVIDO_POSITIVO = 6 já existente
+
+export async function agregarQualidade({ setor, status, dataInicio, dataFim } = {}) {
+  const base = () => {
+    let q = AtendimentoAvaliacao.query()
+      .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
+      .where("atendimento_avaliacoes.Avaliavel", true);
+    if (setor) q = q.where("a.Setor", setor);
+    if (status !== null && status !== undefined) q = q.where("a.Status", status);
+    if (dataInicio) q = q.where("a.Abertura", ">=", dataInicio);
+    if (dataFim) q = q.where("a.Abertura", "<=", `${dataFim} 23:59:59`);
+    return q;
+  };
+
+  const [porSetorMedia, porSetorCount, porResolvido, malAvaliados, totalComNota] = await Promise.all([
+    base().whereNotNull("atendimento_avaliacoes.ScoreAtendente")
+      .select("a.Setor").avg("atendimento_avaliacoes.ScoreAtendente as media").groupBy("a.Setor"),
+    base().whereNotNull("atendimento_avaliacoes.ScoreAtendente")
+      .select("a.Setor").count("* as total").groupBy("a.Setor"),
+    base().select("atendimento_avaliacoes.Resolvido").count("* as total").groupBy("atendimento_avaliacoes.Resolvido"),
+    base().whereNotNull("atendimento_avaliacoes.ScoreAtendente")
+      .where("atendimento_avaliacoes.ScoreAtendente", "<=", SCORE_RUIM_MAX).resultSize(),
+    base().whereNotNull("atendimento_avaliacoes.ScoreAtendente").resultSize()
+  ]);
+
+  return { porSetorMedia, porSetorCount, porResolvido, malAvaliados, totalComNota, filtros: { setor, status, dataInicio, dataFim } };
+}
+
+// amostra recente de avaliações com sugestões de melhoria registradas, usada pelo chat de
+// atendimentos para perguntas sobre padrões/lições aprendidas (sem chamada LLM auxiliar —
+// o LLM final do modo atendimentos já sintetiza o CONTEXTO bruto).
+const LICOES_LIMIT = 30;
+
+export async function buscarAvaliacoesParaLicoes({ setor, status, dataInicio, dataFim, limit = LICOES_LIMIT } = {}) {
+  let q = AtendimentoAvaliacao.query()
+    .join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId")
+    .where("atendimento_avaliacoes.Avaliavel", true)
+    .whereNotNull("atendimento_avaliacoes.ScoreAtendente")
+    .whereRaw("JSON_LENGTH(atendimento_avaliacoes.Sugestoes) > 0")
+    .select("atendimento_avaliacoes.*", "a.Codigo")
+    .orderBy("atendimento_avaliacoes.UpdatedAt", "desc")
+    .limit(limit);
+  if (setor) q = q.where("a.Setor", setor);
+  if (status !== null && status !== undefined) q = q.where("a.Status", status);
+  if (dataInicio) q = q.where("a.Abertura", ">=", dataInicio);
+  if (dataFim) q = q.where("a.Abertura", "<=", `${dataFim} 23:59:59`);
+  return q;
+}

+ 22 - 12
src/services/atendimentoRagService.js

@@ -6,6 +6,7 @@ import { buildConversaTexto, calcularUltimaMensagemId } from "../utils/atendimen
 import { ingestDocuments } from "./ingestService.js";
 import { searchDocs } from "./searchService.js";
 import { generateHydeDocument } from "./hydeService.js";
+import { rerankHits } from "./rerankService.js";
 import { NotFoundError } from "../shared/errors/index.js";
 
 const ATENDIMENTO_HYDE_SYSTEM_PROMPT = [
@@ -150,23 +151,32 @@ export async function buscarAtendimentosSemelhantes({
   minScore = config.atendimentosRag.minScore,
   hyde = config.atendimentosRag.hyde
 }) {
-  if (!hyde || shouldSkipAtendimentoHyde(query)) {
-    return searchDocs({
-      query,
-      topK,
-      minScore,
-      collectionName: config.atendimentosRag.collection,
-      embedRole: "query"
+  let embeddingQuery = query;
+  let embedRole = "query";
+
+  if (hyde && !shouldSkipAtendimentoHyde(query)) {
+    const hydeText = await generateHydeDocument(query, ATENDIMENTO_HYDE_SYSTEM_PROMPT, {
+      numCtx: config.llm.atendimentosNumCtx
     });
+    if (hydeText) {
+      embeddingQuery = hydeText;
+      embedRole = "passage";
+    }
   }
 
-  const hydeText = await generateHydeDocument(query, ATENDIMENTO_HYDE_SYSTEM_PROMPT);
-
-  return searchDocs({
-    query: hydeText || query,
+  const hits = await searchDocs({
+    query: embeddingQuery,
     topK,
     minScore,
     collectionName: config.atendimentosRag.collection,
-    embedRole: hydeText ? "passage" : "query"
+    embedRole
+  });
+
+  // segundo filtro por LLM (query original, não o texto HyDE) — tentativa de resolver a
+  // falta de separação de score encontrada na calibração (ver memória rag-atendimentos)
+  if (!config.atendimentosRag.rerank || hits.length <= 1) return hits;
+  return rerankHits(query, hits, {
+    topN: config.atendimentosRag.rerankTopN,
+    numCtx: config.llm.atendimentosNumCtx
   });
 }

+ 152 - 20
src/services/atendimentosQueryService.js

@@ -3,8 +3,9 @@ import { chatCompletion } from "./ollamaClient.js";
 import { Atendimento } from "../models/Atendimento.model.js";
 import { AtendimentoCliente } from "../models/AtendimentoCliente.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
-import { formatMensagem, formatDateTime, parseAtendenteBody } from "../utils/atendimentoFormat.js";
+import { formatMensagem, formatDateTime, parseAtendenteBody, calcularUltimaMensagemId } from "../utils/atendimentoFormat.js";
 import { buscarAtendimentosSemelhantes } from "./atendimentoRagService.js";
+import { agregarQualidade, buscarAvaliacoesParaLicoes, SCORE_RUIM_MAX } from "./atendimentoAvaliacaoService.js";
 
 const BUSCA_LIMIT_ATENDIMENTOS = 5;
 
@@ -21,7 +22,7 @@ async function buscarPorCodigos(codigos) {
   if (!codigos.length) return [];
   const atendimentos = await Atendimento.query()
     .whereIn("Codigo", codigos)
-    .withGraphFetched("[cliente, mensagens]")
+    .withGraphFetched("[cliente, mensagens, avaliacao]")
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
   return atendimentos.map((atendimento) => ({ atendimento, score: null }));
 }
@@ -40,7 +41,7 @@ async function buscarPorNomeCliente(nome, limit = BUSCA_LIMIT_ATENDIMENTOS) {
 
   const atendimentos = await Atendimento.query()
     .whereIn("ClienteId", clienteIds)
-    .withGraphFetched("[cliente, mensagens]")
+    .withGraphFetched("[cliente, mensagens, avaliacao]")
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"))
     .orderByRaw("COALESCE(Abertura, IngestedAt) DESC")
     .limit(limit);
@@ -53,9 +54,18 @@ function classificacaoSystemPrompt() {
     "Você classifica perguntas sobre atendimentos de suporte ao cliente de uma empresa.",
     `Hoje é ${hoje}.`,
     "Responda APENAS com um objeto JSON no formato:",
-    '{"tipo": "agregacao" | "busca", "setor": "..." | null, "status": 0 | null, "dataInicio": "YYYY-MM-DD" | null, "dataFim": "YYYY-MM-DD" | null, "clienteNome": "..." | null, "termos": "..." | null}',
-    '"tipo"="agregacao": a pergunta pede contagem, total, quantidade ou estatística de atendimentos (ex: "quantos atendimentos...", "total de...", "quantos no setor X").',
-    '"tipo"="busca": a pergunta pede conteúdo de conversas específicas (ex: "o que o cliente reclamou", "o que aconteceu no atendimento X", "última conversa com o cliente Y").',
+    '{"relacionado": true | false, "tipo": "agregacao" | "busca" | "licoes", "metrica": "quantidade" | "nota_media" | null, "setor": "..." | null, "status": 0 | null, "dataInicio": "YYYY-MM-DD" | null, "dataFim": "YYYY-MM-DD" | null, "clienteNome": "..." | null, "termos": "..." | null}',
+    '"relacionado": true se a pergunta tiver QUALQUER relação com atendimento ao cliente/suporte',
+    "dessa empresa, mesmo que indireta ou informal (ex: sobre um cliente, um problema relatado, um",
+    "setor, uma reclamação, uma conversa de suporte, uma anedota sobre atendimento). false SÓ se a",
+    "pergunta for sobre um assunto completamente alheio, sem nenhuma relação com atendimento/suporte",
+    "(ex: previsão do tempo, receita de culinária, esporte, cultura geral, geografia). Na dúvida,",
+    "prefira true — só marque false quando tiver certeza de que não há relação nenhuma.",
+    '"tipo"="agregacao" com "metrica"="quantidade" (default): a pergunta pede contagem, total ou quantidade de atendimentos (ex: "quantos atendimentos...", "total de...", "quantos no setor X").',
+    '"tipo"="agregacao" com "metrica"="nota_media": pergunta pede nota média, avaliação, qualidade agregada, quantos foram mal/bem avaliados (ex: "nota média do setor X", "quantos atendimentos mal avaliados esse mês").',
+    '"tipo"="busca": a pergunta pede conteúdo de conversas específicas (ex: "o que o cliente reclamou", "o que aconteceu no atendimento X", "última conversa com o cliente Y", "esse atendimento foi bem resolvido?").',
+    '"tipo"="licoes": pergunta pede padrões, problemas recorrentes ou sugestões de melhoria mais comuns (ex: "quais os principais problemas do setor X", "que sugestões de melhoria mais aparecem").',
+    '"metrica": só preencha quando "tipo"="agregacao" ("quantidade" ou "nota_media"); null nos outros tipos.',
     '"setor": sigla do setor mencionado na pergunta (ex: SUP, POS), ou null se não mencionado.',
     '"status": SÓ preencha se a pergunta mencionar um número de status explícito (ex: "status 1"). Não tente adivinhar a partir de palavras como "aberto"/"fechado/"pendente" — não sabemos o mapeamento desses valores, então nesses casos deixe null.',
     '"dataInicio"/"dataFim": intervalo de datas mencionado, resolvendo referências relativas ("essa semana", "esse mês", "hoje") com base na data de hoje informada acima. Null se não houver período.',
@@ -65,8 +75,10 @@ function classificacaoSystemPrompt() {
   ].join("\n");
 }
 
-async function classificarPergunta(message, history = []) {
-  const fallback = { tipo: "busca", setor: null, status: null, dataInicio: null, dataFim: null, clienteNome: null, termos: message };
+export async function classificarPergunta(message, history = []) {
+  // fail-open: se a classificação falhar/não parsear, nunca bloquear uma pergunta
+  // legítima por causa disso — relacionado=true segue pro caminho de busca normal.
+  const fallback = { relacionado: true, tipo: "busca", metrica: null, setor: null, status: null, dataInicio: null, dataFim: null, clienteNome: null, termos: message };
   try {
     const hasHistory = history.length > 0;
     const messages = [
@@ -76,11 +88,25 @@ async function classificarPergunta(message, history = []) {
         : []),
       { role: "user", content: message }
     ];
-    const { content } = await chatCompletion({ messages, format: "json", options: { temperature: 0.1 } });
+    const { content } = await chatCompletion({
+      messages,
+      format: "json",
+      options: { temperature: 0.1, num_ctx: config.llm.atendimentosNumCtx, num_predict: config.llm.auxNumPredict }
+    });
     const parsed = JSON.parse(String(content ?? "").trim());
-    if (parsed?.tipo !== "agregacao" && parsed?.tipo !== "busca") return fallback;
+    // comparação por string: alguns modelos devolvem "false" (string) em vez de
+    // false (booleano) no JSON — !== false sozinho deixaria passar esse caso.
+    const relacionado = String(parsed?.relacionado).toLowerCase() !== "false";
+    // pergunta fora do tema: "tipo" costuma vir null (não há o que classificar) — não é
+    // falha de parsing, então não deve cair no fallback (que força relacionado=true).
+    if (!relacionado) {
+      return { ...fallback, relacionado: false };
+    }
+    if (!["agregacao", "busca", "licoes"].includes(parsed?.tipo)) return fallback;
     return {
+      relacionado,
       tipo: parsed.tipo,
+      metrica: parsed.tipo === "agregacao" && parsed.metrica === "nota_media" ? "nota_media" : parsed.tipo === "agregacao" ? "quantidade" : null,
       setor: typeof parsed.setor === "string" && parsed.setor.trim() ? parsed.setor.trim().toUpperCase() : null,
       status: Number.isInteger(parsed.status) ? parsed.status : null,
       dataInicio: typeof parsed.dataInicio === "string" && parsed.dataInicio.trim() ? parsed.dataInicio.trim() : null,
@@ -107,15 +133,22 @@ async function agregarAtendimentos({ setor, status, dataInicio, dataFim }) {
   return { linhas, totalGeral, filtros: { setor, status, dataInicio, dataFim } };
 }
 
-function formatarResumoAgregado({ linhas, totalGeral, filtros }) {
-  const partesFiltro = [];
-  if (filtros.setor) partesFiltro.push(`setor ${filtros.setor}`);
-  if (filtros.status !== null && filtros.status !== undefined) partesFiltro.push(`status ${filtros.status}`);
+// texto compartilhado "setor X, status Y, período Z..." usado pelos três tipos de
+// agregação/lições — evita duplicar a mesma lógica de descrição de filtro três vezes.
+function descreverFiltros(filtros) {
+  const partes = [];
+  if (filtros.setor) partes.push(`setor ${filtros.setor}`);
+  if (filtros.status !== null && filtros.status !== undefined) partes.push(`status ${filtros.status}`);
   if (filtros.dataInicio || filtros.dataFim) {
-    partesFiltro.push(`período ${filtros.dataInicio ?? "início"} a ${filtros.dataFim ?? "hoje"}`);
+    partes.push(`período ${filtros.dataInicio ?? "início"} a ${filtros.dataFim ?? "hoje"}`);
   }
-  const cabecalho = partesFiltro.length
-    ? `Contagem de atendimentos (banco interno) filtrando por ${partesFiltro.join(", ")}:`
+  return partes.length ? `filtrando por ${partes.join(", ")}` : null;
+}
+
+function formatarResumoAgregado({ linhas, totalGeral, filtros }) {
+  const descricao = descreverFiltros(filtros);
+  const cabecalho = descricao
+    ? `Contagem de atendimentos (banco interno) ${descricao}:`
     : "Contagem de atendimentos (banco interno) por setor:";
 
   const linhasTexto = linhas
@@ -129,6 +162,51 @@ function formatarResumoAgregado({ linhas, totalGeral, filtros }) {
   return [cabecalho, ...linhasTexto, `Total: ${totalGeral} atendimento${totalGeral === 1 ? "" : "s"}`].join("\n");
 }
 
+function formatarResumoQualidade({ porSetorMedia, porSetorCount, porResolvido, malAvaliados, totalComNota, filtros }) {
+  const descricao = descreverFiltros(filtros);
+  if (!totalComNota) {
+    return descricao
+      ? `Nenhuma avaliação de IA encontrada para esse filtro (${descricao}).`
+      : "Nenhuma avaliação de IA encontrada.";
+  }
+
+  const cabecalho = descricao
+    ? `Qualidade dos atendimentos, segundo avaliação automática por IA (banco interno), ${descricao}:`
+    : "Qualidade dos atendimentos, segundo avaliação automática por IA (banco interno), por setor:";
+
+  const totalPorSetor = new Map(porSetorCount.map((l) => [l.Setor, Number(l.total)]));
+  const linhasSetor = porSetorMedia
+    .filter((l) => l.Setor)
+    .map((l) => `Setor ${l.Setor}: nota média ${Number(l.media).toFixed(1)}/10 (${totalPorSetor.get(l.Setor) ?? 0} avaliações)`);
+
+  const resolvidoTexto = porResolvido
+    .map((l) => `${l.Resolvido ?? "indefinido"}: ${l.total}`)
+    .join(", ");
+
+  return [
+    cabecalho,
+    ...linhasSetor,
+    `Resolvido (campo independente da nota, avaliado separadamente pela IA) — ${resolvidoTexto}`,
+    `Atendimentos mal avaliados (nota <= ${SCORE_RUIM_MAX}): ${malAvaliados} de ${totalComNota} com nota.`
+  ].join("\n");
+}
+
+function formatarLicoesAprendidas(avaliacoes, filtros) {
+  const descricao = descreverFiltros(filtros);
+  if (!avaliacoes.length) {
+    return descricao
+      ? `Nenhuma avaliação de IA com sugestões de melhoria encontrada para esse filtro (${descricao}).`
+      : "Nenhuma avaliação de IA com sugestões de melhoria encontrada.";
+  }
+  const cabecalho = `Amostra das ${avaliacoes.length} avaliações de IA mais recentes com sugestões de melhoria${
+    descricao ? ` ${descricao}` : ""
+  } (amostra recente, não é o total do período):`;
+  const linhas = avaliacoes.map(
+    (av) => `- ${av.Codigo} (nota ${av.ScoreAtendente}/10, resolvido: ${av.Resolvido}, sentimento: ${av.Sentimento}): ${av.Sugestoes.join("; ")}`
+  );
+  return [cabecalho, ...linhas].join("\n");
+}
+
 // relevância do MySQL FULLTEXT não é normalizada (0-1) — em vez de um valor fixo,
 // descarta matches fracos relativos ao melhor resultado, pra não misturar atendimentos
 // só incidentalmente relacionados quando existe um resultado claramente mais forte.
@@ -165,7 +243,7 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA
 
   let query = Atendimento.query()
     .findByIds(ids)
-    .withGraphFetched("[cliente, mensagens]")
+    .withGraphFetched("[cliente, mensagens, avaliacao]")
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
 
   if (setor) query = query.where("Setor", setor);
@@ -200,7 +278,7 @@ async function buscarAtendimentosPorVetor({ termos, setor, status, limit = BUSCA
 
   let query = Atendimento.query()
     .findByIds(ids)
-    .withGraphFetched("[cliente, mensagens]")
+    .withGraphFetched("[cliente, mensagens, avaliacao]")
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
 
   if (setor) query = query.where("Setor", setor);
@@ -254,6 +332,23 @@ function formatarSequenciaAtendentes(sequenciaAtendentes, distintos) {
   );
 }
 
+// avaliação automática por IA (se existir) do próprio atendimento — permite o chat responder
+// perguntas de qualidade ("foi bem resolvido?") sem precisar reclassificar a intenção da
+// pergunta. ScoreAtendente pode ser null legitimamente (nenhum atendente humano participou);
+// nesse caso resumo/resolvido/sentimento continuam válidos.
+function formatarBlocoAvaliacao(atendimento) {
+  const av = atendimento.avaliacao;
+  if (!av?.Avaliavel) return null;
+  const desatualizada = calcularUltimaMensagemId(atendimento.mensagens ?? []) > (av.UltimaMensagemId ?? 0);
+  const nota = av.ScoreAtendente != null ? `${av.ScoreAtendente}/10` : "não aplicável (sem atendente humano)";
+  return [
+    `Avaliação automática por IA deste atendimento${desatualizada ? " (DESATUALIZADA — feita antes das últimas mensagens abaixo)" : ""}:`,
+    `Nota: ${nota} — Resolvido: ${av.Resolvido} — Sentimento do cliente: ${av.Sentimento}`,
+    av.Resumo ? `Resumo da IA: ${av.Resumo}` : null,
+    av.Sugestoes?.length ? `Sugestões de melhoria apontadas pela IA: ${av.Sugestoes.join("; ")}` : null
+  ].filter(Boolean).join("\n");
+}
+
 function formatarTrechoAtendimento(atendimento) {
   const cliente = atendimento.cliente;
   const abertura = formatDateTime(atendimento.Abertura);
@@ -270,13 +365,28 @@ function formatarTrechoAtendimento(atendimento) {
     formatarSequenciaAtendentes(sequenciaAtendentes, distintos)
   ].filter(Boolean);
 
+  const blocoAvaliacao = formatarBlocoAvaliacao(atendimento);
   const linhasMensagens = mensagens.map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
-  return [...cabecalho, "", ...linhasMensagens].join("\n").trim();
+  return [...cabecalho, "", ...(blocoAvaliacao ? [blocoAvaliacao, ""] : []), ...linhasMensagens].join("\n").trim();
 }
 
 export async function resolverContextoAtendimentos(message, history = []) {
   const filtros = await classificarPergunta(message, history);
 
+  if (filtros.tipo === "agregacao" && filtros.metrica === "nota_media") {
+    const resultado = await agregarQualidade(filtros);
+    return [
+      {
+        id: "agregacao_qualidade",
+        source: null,
+        text: formatarResumoQualidade(resultado),
+        chunkIndex: 0,
+        metadata: { type: "atendimento_agregacao_qualidade", ...filtros },
+        score: null
+      }
+    ];
+  }
+
   if (filtros.tipo === "agregacao") {
     const resultado = await agregarAtendimentos(filtros);
     return [
@@ -291,6 +401,20 @@ export async function resolverContextoAtendimentos(message, history = []) {
     ];
   }
 
+  if (filtros.tipo === "licoes") {
+    const avaliacoes = await buscarAvaliacoesParaLicoes(filtros);
+    return [
+      {
+        id: "licoes",
+        source: null,
+        text: formatarLicoesAprendidas(avaliacoes, filtros),
+        chunkIndex: 0,
+        metadata: { type: "atendimento_licoes", ...filtros, amostra: avaliacoes.length },
+        score: null
+      }
+    ];
+  }
+
   // se a pergunta atual não repete o código (ex.: "e isso foi resolvido?" numa
   // pergunta de acompanhamento), continua referenciando o atendimento já
   // discutido na conversa, em vez de cair numa busca textual desconexa.
@@ -299,6 +423,14 @@ export async function resolverContextoAtendimentos(message, history = []) {
     codigos = extrairCodigosProtocolo(history.map((m) => m.Content).join("\n"));
   }
 
+  // gate de tema: código de protocolo e nome de cliente são lookups exatos/objetivos,
+  // sempre honrados. Sem eles, pergunta sem relação nenhuma com atendimento/suporte não
+  // deve gastar uma busca (FULLTEXT ou vetor) só pra trazer contexto irrelevante — cai no
+  // guard de "nenhum atendimento encontrado" já existente em chatChain.js.
+  if (!codigos.length && !filtros.clienteNome && !filtros.relacionado) {
+    return [];
+  }
+
   let resultados;
   if (codigos.length) {
     resultados = await buscarPorCodigos(codigos);

+ 3 - 2
src/services/hydeService.js

@@ -1,13 +1,14 @@
+import { config } from "../config/index.js";
 import { chatCompletion } from "./ollamaClient.js";
 
-export async function generateHydeDocument(query, systemPrompt) {
+export async function generateHydeDocument(query, systemPrompt, { numCtx = config.llm.numCtx } = {}) {
   try {
     const { content } = await chatCompletion({
       messages: [
         { role: "system", content: systemPrompt },
         { role: "user", content: query }
       ],
-      options: { temperature: 0.2 }
+      options: { temperature: 0.2, num_ctx: numCtx, num_predict: config.llm.auxNumPredict }
     });
     return content?.trim() || null;
   } catch (err) {

+ 11 - 0
src/services/ollamaClient.js

@@ -1,4 +1,5 @@
 import { config } from "../config/index.js";
+import { createTtlCache } from "./simpleCache.js";
 
 // combina um AbortSignal externo (opcional, ex.: desconexão do cliente) com um timeout
 // interno — sem isso, uma chamada ao Ollama que trava nunca resolve nem rejeita, o que
@@ -67,12 +68,22 @@ function embedPrefixFor(role) {
   return "";
 }
 
+// embeddings de um texto+role são determinísticos — cachear evita reprocessar perguntas
+// repetidas/idênticas (comum num chatbot interno) do zero a cada vez.
+const EMBED_CACHE_TTL_MS = 15 * 60 * 1000;
+const embedCache = createTtlCache(EMBED_CACHE_TTL_MS);
+
 async function embedSingle(text, { role } = {}) {
+  const cacheKey = `${role ?? ""}:${text}`;
+  const cached = embedCache.get(cacheKey);
+  if (cached) return cached;
+
   const prefix = embedPrefixFor(role);
   const data = await ollamaFetch("/api/embeddings", {
     model: config.ollama.embeddingsModel,
     prompt: `${prefix}${text}`
   });
+  embedCache.set(cacheKey, data.embedding);
   return data.embedding;
 }
 

+ 72 - 0
src/services/rerankService.js

@@ -0,0 +1,72 @@
+import { config } from "../config/index.js";
+import { chatCompletion } from "./ollamaClient.js";
+
+// módulo-folha (sem importar de chatChain.js/atendimentosQueryService.js), pra poder ser
+// reusado tanto no rerank de documentos quanto no de atendimentos sem dependência circular
+// (mesmo motivo de hydeService.js).
+const RERANK_SYSTEM_PROMPT = [
+  "Você reordena trechos de documentos internos por relevância à pergunta do usuário.",
+  'Responda APENAS com um objeto JSON no formato {"order": [...]}, contendo TODOS os índices',
+  '(0-based) de cada trecho, em ordem decrescente de relevância. Exemplo: {"order": [2,0,3,1]}.',
+  "Não inclua explicações, texto extra ou markdown."
+].join("\n");
+
+const RERANK_SNIPPET_LENGTH = 300;
+
+function buildRerankPrompt(query, hits) {
+  const listing = hits
+    .map((h, i) => `[${i}] ${h.text.slice(0, RERANK_SNIPPET_LENGTH)}`)
+    .join("\n\n");
+  return `Pergunta: ${query}\n\nTrechos:\n${listing}`;
+}
+
+function parseRerankOrder(content, len) {
+  try {
+    const cleaned = String(content ?? "")
+      .trim()
+      .replace(/^```json\s*/i, "")
+      .replace(/^```\s*/, "")
+      .replace(/```$/, "")
+      .trim();
+    const parsed = JSON.parse(cleaned);
+    const arr = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.order) ? parsed.order : null;
+    if (!arr) return null;
+
+    const seen = new Set();
+    const valid = [];
+    for (const v of arr) {
+      const idx = Number(v);
+      if (Number.isInteger(idx) && idx >= 0 && idx < len && !seen.has(idx)) {
+        seen.add(idx);
+        valid.push(idx);
+      }
+    }
+    if (valid.length === 0) return null;
+    for (let i = 0; i < len; i++) if (!seen.has(i)) valid.push(i);
+    return valid;
+  } catch {
+    return null;
+  }
+}
+
+export async function rerankHits(query, hits, { topN, numCtx = config.llm.numCtx } = {}) {
+  if (hits.length <= 1) return hits;
+  const limit = topN ?? hits.length;
+  try {
+    const { content } = await chatCompletion({
+      messages: [
+        { role: "system", content: RERANK_SYSTEM_PROMPT },
+        { role: "user", content: buildRerankPrompt(query, hits) }
+      ],
+      format: "json",
+      options: { temperature: 0.1, num_ctx: numCtx, num_predict: config.llm.auxNumPredict }
+    });
+    const order = parseRerankOrder(content, hits.length);
+    if (!order) console.warn("[rerankService] rerankHits: resposta não parseável, mantendo ordem original");
+    const reordered = order ? order.map((i) => hits[i]) : hits;
+    return reordered.slice(0, limit);
+  } catch (err) {
+    console.warn("[rerankService] rerankHits falhou, mantendo ordem original:", err.message);
+    return hits.slice(0, limit);
+  }
+}

+ 22 - 0
src/services/simpleCache.js

@@ -0,0 +1,22 @@
+// cache em memória com TTL, sem dependência externa — escopo suficiente pra um único
+// processo Node com tráfego modesto (chatbot interno). Cada chamada a createTtlCache
+// cria um Map isolado, pra caches diferentes (embeddings, rewrite+hyde, ...) não colidirem.
+export function createTtlCache(ttlMs, { maxEntries = 500 } = {}) {
+  const store = new Map();
+
+  return {
+    get(key) {
+      const entry = store.get(key);
+      if (!entry) return undefined;
+      if (Date.now() > entry.expiresAt) {
+        store.delete(key);
+        return undefined;
+      }
+      return entry.value;
+    },
+    set(key, value) {
+      if (store.size >= maxEntries) store.delete(store.keys().next().value);
+      store.set(key, { value, expiresAt: Date.now() + ttlMs });
+    }
+  };
+}