瀏覽代碼

ajustes avaliacoes

leonardo 2 月之前
父節點
當前提交
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 { config } from "#config/index.js";
 import { searchDocs } from "#services/searchService.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) {
 function parseArgs(argv) {
   const flags = { hyde: false, rewrite: false, minScore: 0, topK: 5, label: "" };
   const flags = { hyde: false, rewrite: false, minScore: 0, topK: 5, label: "" };
@@ -40,7 +40,7 @@ function sourceMatches(hit, expectedIncludes) {
 async function resolveEmbeddingQuery(originalQuery, flags) {
 async function resolveEmbeddingQuery(originalQuery, flags) {
   const searchQuery = flags.rewrite ? await rewriteQuery(originalQuery, []) : originalQuery;
   const searchQuery = flags.rewrite ? await rewriteQuery(originalQuery, []) : originalQuery;
   if (!flags.hyde) return { searchQuery, embeddingQuery: searchQuery, embedRole: "query" };
   if (!flags.hyde) return { searchQuery, embeddingQuery: searchQuery, embedRole: "query" };
-  const hydeText = await generateHydeDocument(searchQuery);
+  const hydeText = await generateHydeDocument(searchQuery, HYDE_SYSTEM_PROMPT);
   return hydeText
   return hydeText
     ? { searchQuery, embeddingQuery: hydeText, embedRole: "passage" }
     ? { searchQuery, embeddingQuery: hydeText, embedRole: "passage" }
     : { searchQuery, embeddingQuery: searchQuery, embedRole: "query" };
     : { 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 { searchDocs } from "../services/searchService.js";
 import { resolverContextoAtendimentos } from "../services/atendimentosQueryService.js";
 import { resolverContextoAtendimentos } from "../services/atendimentosQueryService.js";
 import { generateHydeDocument } from "../services/hydeService.js";
 import { generateHydeDocument } from "../services/hydeService.js";
+import { rerankHits } from "../services/rerankService.js";
+import { createTtlCache } from "../services/simpleCache.js";
 
 
 const SYSTEM_PROMPT = [
 const SYSTEM_PROMPT = [
   "Você é o Oráculo, assistente interno da Star.",
   "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ê é 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.",
   "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.",
   "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;",
   "(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.",
   "(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.",
   "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.",
   "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.",
   "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: "system", content: REWRITE_SYSTEM_PROMPT },
           { role: "user", content: query }
           { 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;
     return content?.trim() || query;
   } catch (err) {
   } catch (err) {
     console.warn("[chatChain] rewriteQuery falhou, usando query original:", err.message);
     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 = [
 const HYDE_SYSTEM_PROMPT = [
   "Você é um redator técnico que escreve trechos de manuais internos de redes e",
   "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."
   "Responda em português brasileiro."
 ].join("\n");
 ].join("\n");
 
 
-export { generateHydeDocument };
+export { generateHydeDocument, HYDE_SYSTEM_PROMPT };
 
 
 async function loadHistory(conversationId, userId) {
 async function loadHistory(conversationId, userId) {
   if (!conversationId) return [];
   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;
 const ATENDIMENTOS_TEMPERATURE = 0.1;
 
 
 function buildDefaultOptions(override, modeDefaults = {}) {
 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',
   '"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,",
   "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.",
   "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."
   "Não inclua explicações, texto extra ou markdown fora do JSON."
 ].join(" ");
 ].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) {
 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 {
   try {
-    const hasHistory = history.length > 0;
     const messages = [
     const messages = [
       { role: "system", content: REWRITE_HYDE_SYSTEM_PROMPT },
       { role: "system", content: REWRITE_HYDE_SYSTEM_PROMPT },
       ...(hasHistory
       ...(hasHistory
@@ -286,9 +240,14 @@ async function rewriteAndHyde(query, history) {
         : []),
         : []),
       { role: "user", content: query }
       { 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);
     const result = parseRewriteHydeResult(content, query);
     if (!result.hydeDocument) console.warn("[chatChain] rewriteAndHyde: hydeDocument ausente/inválido, seguindo sem HyDE");
     if (!result.hydeDocument) console.warn("[chatChain] rewriteAndHyde: hydeDocument ausente/inválido, seguindo sem HyDE");
+    if (cacheKey) rewriteHydeCache.set(cacheKey, result);
     return result;
     return result;
   } catch (err) {
   } catch (err) {
     console.warn("[chatChain] rewriteAndHyde falhou, usando query original sem HyDE:", err.message);
     console.warn("[chatChain] rewriteAndHyde falhou, usando query original sem HyDE:", err.message);
@@ -326,10 +285,7 @@ function resolveMode(mode) {
   return MODES[mode] ?? MODES.documentos;
   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 = [
 const ATENDIMENTOS_REFUSAL_PATTERNS = [
   "não posso fornecer",
   "não posso fornecer",
   "nao 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 });
     hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
     timer.mark("search");
     timer.mark("search");
     if (config.rag.rerank && hits.length > 1) {
     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");
       timer.mark("rerank");
     }
     }
   }
   }
 
 
   const context = buildContextBlock(hits);
   const context = buildContextBlock(hits);
   const messages = buildMessages(message, context, history, modeConfig);
   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 finalOptions = buildDefaultOptions(options, modeDefaults);
   const completion =
   const completion =
     mode === "atendimentos"
     mode === "atendimentos"
@@ -450,7 +409,7 @@ export async function answerWithContextStream({
     onStatus?.("encontrou", hits.length);
     onStatus?.("encontrou", hits.length);
     if (config.rag.rerank && hits.length > 1) {
     if (config.rag.rerank && hits.length > 1) {
       onStatus?.("reordenando");
       onStatus?.("reordenando");
-      hits = await rerankHits(searchQuery, hits);
+      hits = await rerankHits(searchQuery, hits, { topN: config.rag.rerankTopN, numCtx: config.llm.numCtx });
       timer.mark("rerank");
       timer.mark("rerank");
     }
     }
   }
   }
@@ -460,7 +419,10 @@ export async function answerWithContextStream({
   const context = buildContextBlock(hits);
   const context = buildContextBlock(hits);
   const messages = buildMessages(message, context, history, modeConfig);
   const messages = buildMessages(message, context, history, modeConfig);
   onStatus?.("gerando");
   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);
   const finalOptions = buildDefaultOptions(options, modeDefaults);
   // modo atendimentos precisa checar recusa antes de mostrar a resposta ao usuário,
   // 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ó.
   // 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),
     topP: Number(process.env.LLM_TOP_P ?? 0.9),
     numPredict: Number(process.env.LLM_NUM_PREDICT ?? 4096),
     numPredict: Number(process.env.LLM_NUM_PREDICT ?? 4096),
     repeatPenalty: Number(process.env.LLM_REPEAT_PENALTY ?? 1.1),
     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: {
   rateLimit: {
     perMinute: Number(process.env.RATE_LIMIT_PER_MINUTE ?? 0)
     perMinute: Number(process.env.RATE_LIMIT_PER_MINUTE ?? 0)
   },
   },
   ifbot: {
   ifbot: {
     baseUrl: process.env.IFBOT_BASE_URL ?? "https://star.ifbot.com.br/api",
     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(/\/+$/, ""),
     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 ?? "",
     token: process.env.IFBOT_TOKEN ?? "",
     authHeader: process.env.IFBOT_AUTH_HEADER ?? "Authorization",
     authHeader: process.env.IFBOT_AUTH_HEADER ?? "Authorization",
     syncIntervalMinutes: Number(process.env.ATENDIMENTOS_SYNC_INTERVAL_MINUTES ?? 30)
     syncIntervalMinutes: Number(process.env.ATENDIMENTOS_SYNC_INTERVAL_MINUTES ?? 30)
   },
   },
   avaliacao: {
   avaliacao: {
-    // modelo usado como juiz; vazio usa o chatModel do Ollama
+    
     model: process.env.OLLAMA_JUDGE_MODEL ?? "",
     model: process.env.OLLAMA_JUDGE_MODEL ?? "",
     intervalMinutes: Number(process.env.ATENDIMENTOS_AVALIACAO_INTERVAL_MINUTES ?? 60),
     intervalMinutes: Number(process.env.ATENDIMENTOS_AVALIACAO_INTERVAL_MINUTES ?? 60),
     batchSize: Number(process.env.ATENDIMENTOS_AVALIACAO_BATCH ?? 10),
     batchSize: Number(process.env.ATENDIMENTOS_AVALIACAO_BATCH ?? 10),
@@ -95,7 +97,10 @@ export const config = {
     topK: Number(process.env.ATENDIMENTOS_RAG_TOP_K ?? 20),
     topK: Number(process.env.ATENDIMENTOS_RAG_TOP_K ?? 20),
     minScore: Number(process.env.ATENDIMENTOS_RAG_MIN_SCORE ?? 0.5),
     minScore: Number(process.env.ATENDIMENTOS_RAG_MIN_SCORE ?? 0.5),
     searchEnabled: process.env.ATENDIMENTOS_RAG_SEARCH_ENABLED === "true",
     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.",
   "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.",
   "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",
   "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:",
   "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.",
   "Um atendimento resolvido com cliente satisfeito NUNCA recebe score abaixo de 6.",
   "Se nenhum atendente humano participou, use score_atendente = null.",
   "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"',
   'Cliente: "alguém aí? já fazem 60 minutos"',
   'Atendente Marcos: "Desculpe a demora! Vou reiniciar sua conexão remotamente."',
   'Atendente Marcos: "Desculpe a demora! Vou reiniciar sua conexão remotamente."',
   'Cliente: "Voltou, obrigado!"',
   '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):",
   "Exemplo 2 — atendimento exemplar (score 9-10):",
   'Cliente: "Minha internet está lenta há dois dias."',
   'Cliente: "Minha internet está lenta há dois dias."',
@@ -447,3 +455,55 @@ export async function estatisticasAvaliacoes({ setor } = {}) {
     totalAtendimentos
     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 { ingestDocuments } from "./ingestService.js";
 import { searchDocs } from "./searchService.js";
 import { searchDocs } from "./searchService.js";
 import { generateHydeDocument } from "./hydeService.js";
 import { generateHydeDocument } from "./hydeService.js";
+import { rerankHits } from "./rerankService.js";
 import { NotFoundError } from "../shared/errors/index.js";
 import { NotFoundError } from "../shared/errors/index.js";
 
 
 const ATENDIMENTO_HYDE_SYSTEM_PROMPT = [
 const ATENDIMENTO_HYDE_SYSTEM_PROMPT = [
@@ -150,23 +151,32 @@ export async function buscarAtendimentosSemelhantes({
   minScore = config.atendimentosRag.minScore,
   minScore = config.atendimentosRag.minScore,
   hyde = config.atendimentosRag.hyde
   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,
     topK,
     minScore,
     minScore,
     collectionName: config.atendimentosRag.collection,
     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 { Atendimento } from "../models/Atendimento.model.js";
 import { AtendimentoCliente } from "../models/AtendimentoCliente.model.js";
 import { AtendimentoCliente } from "../models/AtendimentoCliente.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.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 { buscarAtendimentosSemelhantes } from "./atendimentoRagService.js";
+import { agregarQualidade, buscarAvaliacoesParaLicoes, SCORE_RUIM_MAX } from "./atendimentoAvaliacaoService.js";
 
 
 const BUSCA_LIMIT_ATENDIMENTOS = 5;
 const BUSCA_LIMIT_ATENDIMENTOS = 5;
 
 
@@ -21,7 +22,7 @@ async function buscarPorCodigos(codigos) {
   if (!codigos.length) return [];
   if (!codigos.length) return [];
   const atendimentos = await Atendimento.query()
   const atendimentos = await Atendimento.query()
     .whereIn("Codigo", codigos)
     .whereIn("Codigo", codigos)
-    .withGraphFetched("[cliente, mensagens]")
+    .withGraphFetched("[cliente, mensagens, avaliacao]")
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
   return atendimentos.map((atendimento) => ({ atendimento, score: null }));
   return atendimentos.map((atendimento) => ({ atendimento, score: null }));
 }
 }
@@ -40,7 +41,7 @@ async function buscarPorNomeCliente(nome, limit = BUSCA_LIMIT_ATENDIMENTOS) {
 
 
   const atendimentos = await Atendimento.query()
   const atendimentos = await Atendimento.query()
     .whereIn("ClienteId", clienteIds)
     .whereIn("ClienteId", clienteIds)
-    .withGraphFetched("[cliente, mensagens]")
+    .withGraphFetched("[cliente, mensagens, avaliacao]")
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"))
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"))
     .orderByRaw("COALESCE(Abertura, IngestedAt) DESC")
     .orderByRaw("COALESCE(Abertura, IngestedAt) DESC")
     .limit(limit);
     .limit(limit);
@@ -53,9 +54,18 @@ function classificacaoSystemPrompt() {
     "Você classifica perguntas sobre atendimentos de suporte ao cliente de uma empresa.",
     "Você classifica perguntas sobre atendimentos de suporte ao cliente de uma empresa.",
     `Hoje é ${hoje}.`,
     `Hoje é ${hoje}.`,
     "Responda APENAS com um objeto JSON no formato:",
     "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.',
     '"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.',
     '"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.',
     '"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");
   ].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 {
   try {
     const hasHistory = history.length > 0;
     const hasHistory = history.length > 0;
     const messages = [
     const messages = [
@@ -76,11 +88,25 @@ async function classificarPergunta(message, history = []) {
         : []),
         : []),
       { role: "user", content: message }
       { 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());
     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 {
     return {
+      relacionado,
       tipo: parsed.tipo,
       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,
       setor: typeof parsed.setor === "string" && parsed.setor.trim() ? parsed.setor.trim().toUpperCase() : null,
       status: Number.isInteger(parsed.status) ? parsed.status : null,
       status: Number.isInteger(parsed.status) ? parsed.status : null,
       dataInicio: typeof parsed.dataInicio === "string" && parsed.dataInicio.trim() ? parsed.dataInicio.trim() : 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 } };
   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) {
   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:";
     : "Contagem de atendimentos (banco interno) por setor:";
 
 
   const linhasTexto = linhas
   const linhasTexto = linhas
@@ -129,6 +162,51 @@ function formatarResumoAgregado({ linhas, totalGeral, filtros }) {
   return [cabecalho, ...linhasTexto, `Total: ${totalGeral} atendimento${totalGeral === 1 ? "" : "s"}`].join("\n");
   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,
 // 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
 // descarta matches fracos relativos ao melhor resultado, pra não misturar atendimentos
 // só incidentalmente relacionados quando existe um resultado claramente mais forte.
 // 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()
   let query = Atendimento.query()
     .findByIds(ids)
     .findByIds(ids)
-    .withGraphFetched("[cliente, mensagens]")
+    .withGraphFetched("[cliente, mensagens, avaliacao]")
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
 
 
   if (setor) query = query.where("Setor", setor);
   if (setor) query = query.where("Setor", setor);
@@ -200,7 +278,7 @@ async function buscarAtendimentosPorVetor({ termos, setor, status, limit = BUSCA
 
 
   let query = Atendimento.query()
   let query = Atendimento.query()
     .findByIds(ids)
     .findByIds(ids)
-    .withGraphFetched("[cliente, mensagens]")
+    .withGraphFetched("[cliente, mensagens, avaliacao]")
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
 
 
   if (setor) query = query.where("Setor", setor);
   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) {
 function formatarTrechoAtendimento(atendimento) {
   const cliente = atendimento.cliente;
   const cliente = atendimento.cliente;
   const abertura = formatDateTime(atendimento.Abertura);
   const abertura = formatDateTime(atendimento.Abertura);
@@ -270,13 +365,28 @@ function formatarTrechoAtendimento(atendimento) {
     formatarSequenciaAtendentes(sequenciaAtendentes, distintos)
     formatarSequenciaAtendentes(sequenciaAtendentes, distintos)
   ].filter(Boolean);
   ].filter(Boolean);
 
 
+  const blocoAvaliacao = formatarBlocoAvaliacao(atendimento);
   const linhasMensagens = mensagens.map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
   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 = []) {
 export async function resolverContextoAtendimentos(message, history = []) {
   const filtros = await classificarPergunta(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") {
   if (filtros.tipo === "agregacao") {
     const resultado = await agregarAtendimentos(filtros);
     const resultado = await agregarAtendimentos(filtros);
     return [
     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
   // se a pergunta atual não repete o código (ex.: "e isso foi resolvido?" numa
   // pergunta de acompanhamento), continua referenciando o atendimento já
   // pergunta de acompanhamento), continua referenciando o atendimento já
   // discutido na conversa, em vez de cair numa busca textual desconexa.
   // 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"));
     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;
   let resultados;
   if (codigos.length) {
   if (codigos.length) {
     resultados = await buscarPorCodigos(codigos);
     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";
 import { chatCompletion } from "./ollamaClient.js";
 
 
-export async function generateHydeDocument(query, systemPrompt) {
+export async function generateHydeDocument(query, systemPrompt, { numCtx = config.llm.numCtx } = {}) {
   try {
   try {
     const { content } = await chatCompletion({
     const { content } = await chatCompletion({
       messages: [
       messages: [
         { role: "system", content: systemPrompt },
         { role: "system", content: systemPrompt },
         { role: "user", content: query }
         { role: "user", content: query }
       ],
       ],
-      options: { temperature: 0.2 }
+      options: { temperature: 0.2, num_ctx: numCtx, num_predict: config.llm.auxNumPredict }
     });
     });
     return content?.trim() || null;
     return content?.trim() || null;
   } catch (err) {
   } catch (err) {

+ 11 - 0
src/services/ollamaClient.js

@@ -1,4 +1,5 @@
 import { config } from "../config/index.js";
 import { config } from "../config/index.js";
+import { createTtlCache } from "./simpleCache.js";
 
 
 // combina um AbortSignal externo (opcional, ex.: desconexão do cliente) com um timeout
 // 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
 // interno — sem isso, uma chamada ao Ollama que trava nunca resolve nem rejeita, o que
@@ -67,12 +68,22 @@ function embedPrefixFor(role) {
   return "";
   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 } = {}) {
 async function embedSingle(text, { role } = {}) {
+  const cacheKey = `${role ?? ""}:${text}`;
+  const cached = embedCache.get(cacheKey);
+  if (cached) return cached;
+
   const prefix = embedPrefixFor(role);
   const prefix = embedPrefixFor(role);
   const data = await ollamaFetch("/api/embeddings", {
   const data = await ollamaFetch("/api/embeddings", {
     model: config.ollama.embeddingsModel,
     model: config.ollama.embeddingsModel,
     prompt: `${prefix}${text}`
     prompt: `${prefix}${text}`
   });
   });
+  embedCache.set(cacheKey, data.embedding);
   return 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 });
+    }
+  };
+}