Преглед изворни кода

Merge remote-tracking branch 'origin/main': modo atendimentos via MySQL + avaliação IA/treino

Concilia o modo de chat sobre atendimentos via MySQL (fulltext) com o
pipeline de avaliação automática (LLM-as-judge) e treino do agente:
- migration ft_body: mantida a versão original do remoto
- atendimentoAvaliacaoService passa a montar o texto da conversa com
  os helpers de src/utils/atendimentoFormat.js (atendimentoRagService
  foi movido/renomeado no remoto)
- exportador de dataset atualizado para o novo módulo

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GabrielRamison пре 2 месеци
родитељ
комит
99dff3681b

+ 0 - 2
db/migrations/20260709090000_add_fulltext_atendimento_mensagens.cjs

@@ -1,5 +1,3 @@
-// recriado: o arquivo original foi perdido após já ter sido aplicado no banco
-// (índice ft_body existente em atendimento_mensagens)
 exports.up = function (knex) {
   return knex.schema.raw("ALTER TABLE atendimento_mensagens ADD FULLTEXT INDEX ft_body (Body)");
 };

+ 1 - 1
scripts/exportarDatasetTreinamento.js

@@ -13,7 +13,7 @@ import path from "node:path";
 import { fileURLToPath } from "node:url";
 import { AtendimentoAvaliacao } from "../src/models/AtendimentoAvaliacao.model.js";
 import { Atendimento } from "../src/models/Atendimento.model.js";
-import { stripHtml, parseAtendenteBody } from "../src/services/atendimentoRagService.js";
+import { stripHtml, parseAtendenteBody } from "../src/utils/atendimentoFormat.js";
 
 const __dirname = path.dirname(fileURLToPath(import.meta.url));
 

+ 48 - 70
src/chat/chatChain.js

@@ -2,6 +2,7 @@ import { config } from "../config/index.js";
 import { chatCompletion, chatCompletionStream } from "../services/ollamaClient.js";
 import { getRecentMessages } from "../services/conversationsService.js";
 import { searchDocs } from "../services/searchService.js";
+import { resolverContextoAtendimentos } from "../services/atendimentosQueryService.js";
 
 const SYSTEM_PROMPT = [
   "Você é o Oráculo, assistente interno da Star.",
@@ -15,10 +16,12 @@ const SYSTEM_PROMPT = [
 const ATENDIMENTOS_SYSTEM_PROMPT = [
   "Você é o Oráculo, assistente interno da Star, no modo de consulta a atendimentos de suporte ao cliente.",
   "Responda em português brasileiro de forma natural, clara e objetiva.",
-  "O CONTEXTO abaixo contém trechos de conversas reais entre atendentes e clientes, extraídos do sistema de atendimento (ifbot); cada trecho traz o código do protocolo, setor, cliente e data.",
-  "Use o CONTEXTO para responder o que foi dito, reclamado, resolvido ou combinado em atendimentos específicos, citando o código do protocolo entre parênteses.",
-  "Nunca invente falas, nomes de clientes ou desfechos que não estejam no CONTEXTO.",
-  "Se a pergunta pedir estatística agregada (contagens, totais, rankings) que não dá para responder apenas lendo trechos de conversas, avise que esse tipo de análise não é suportado neste modo."
+  "O CONTEXTO abaixo vem diretamente do banco de dados interno de atendimentos (sem busca por similaridade) e pode ser de dois 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 e data — use-os para responder o que foi dito, reclamado, resolvido ou combinado, citando o código do protocolo entre parênteses.",
+  "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 a pergunta mencionar um código de protocolo específico e o CONTEXTO não contiver exatamente esse código (ou contiver um código diferente), diga claramente que não encontrou esse atendimento — nunca responda como se fosse sobre o código pedido.",
+  "Nunca invente números, falas, nomes de clientes, códigos de protocolo ou desfechos que não estejam literalmente no CONTEXTO."
 ].join("\n");
 
 function buildContextBlock(hits) {
@@ -184,20 +187,11 @@ const HYDE_SYSTEM_PROMPT = [
   "Responda em português brasileiro."
 ].join("\n");
 
-const ATENDIMENTOS_HYDE_SYSTEM_PROMPT = [
-  "Você simula um trecho plausível de conversa real entre um atendente de suporte e um cliente,",
-  "extraído de um sistema de atendimento via WhatsApp. Dada a pergunta abaixo, escreva um parágrafo",
-  "curto (3 a 6 frases) no estilo de uma troca de mensagens entre cliente e atendente, mencionando",
-  "o tipo de problema, sintomas ou solução relacionados à pergunta. Não inclua a pergunta,",
-  "saudações genéricas ou ressalvas de incerteza — o texto só será usado para busca por",
-  "similaridade, nunca mostrado ao usuário. Responda em português brasileiro."
-].join("\n");
-
-export async function generateHydeDocument(query, hydeSystemPrompt = HYDE_SYSTEM_PROMPT) {
+export async function generateHydeDocument(query) {
   try {
     const { content } = await chatCompletion({
       messages: [
-        { role: "system", content: hydeSystemPrompt },
+        { role: "system", content: HYDE_SYSTEM_PROMPT },
         { role: "user", content: query }
       ],
       options: { temperature: 0.2 }
@@ -240,12 +234,12 @@ function shouldSkipHyde(originalMessage, searchQuery) {
   return false;
 }
 
-async function resolveEmbeddingQuery(searchQuery, originalMessage, hydeSystemPrompt = HYDE_SYSTEM_PROMPT) {
+async function resolveEmbeddingQuery(searchQuery, originalMessage) {
   if (!config.rag.hyde) return { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: false };
   if (shouldSkipHyde(originalMessage, searchQuery)) {
     return { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: true };
   }
-  const hydeText = await generateHydeDocument(searchQuery, hydeSystemPrompt);
+  const hydeText = await generateHydeDocument(searchQuery);
   return hydeText
     ? { embeddingQuery: hydeText, embedRole: "passage", hydeSkipped: false }
     : { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: false };
@@ -263,18 +257,6 @@ const REWRITE_HYDE_SYSTEM_PROMPT = [
   "Não inclua explicações, texto extra ou markdown fora do JSON."
 ].join(" ");
 
-const ATENDIMENTOS_REWRITE_HYDE_SYSTEM_PROMPT = [
-  'Você é um motor de busca interno. Responda APENAS com um objeto JSON no formato',
-  '{"rewrittenQuery": "...", "hydeDocument": "..."}.',
-  '"rewrittenQuery": reescreva a ÚLTIMA pergunta do usuário como uma query autocontida e específica',
-  "para busca em conversas de atendimento ao cliente, em linguagem natural, resolvendo",
-  "pronomes/referências via HISTÓRICO DA CONVERSA quando houver.",
-  '"hydeDocument": um parágrafo curto (3 a 6 frases) simulando um trecho plausível de conversa real',
-  "entre atendente e cliente de suporte (estilo WhatsApp) relacionado 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. Não inclua explicações, texto extra ou markdown fora do JSON."
-].join(" ");
-
 function parseRewriteHydeResult(content, originalQuery) {
   try {
     const cleaned = String(content ?? "")
@@ -296,11 +278,11 @@ function parseRewriteHydeResult(content, originalQuery) {
   }
 }
 
-async function rewriteAndHyde(query, history, rewriteHydeSystemPrompt = REWRITE_HYDE_SYSTEM_PROMPT) {
+async function rewriteAndHyde(query, history) {
   try {
     const hasHistory = history.length > 0;
     const messages = [
-      { role: "system", content: rewriteHydeSystemPrompt },
+      { role: "system", content: REWRITE_HYDE_SYSTEM_PROMPT },
       ...(hasHistory
         ? [{ role: "system", content: `HISTÓRICO DA CONVERSA:\n${history.map((m) => `${m.Role}: ${m.Content}`).join("\n")}` }]
         : []),
@@ -316,11 +298,11 @@ async function rewriteAndHyde(query, history, rewriteHydeSystemPrompt = REWRITE_
   }
 }
 
-async function resolveSearchAndEmbedding(message, history, timer, modeConfig = MODES.documentos) {
+async function resolveSearchAndEmbedding(message, history, timer) {
   const bothEnabled = config.rag.queryRewrite && config.rag.hyde;
 
   if (bothEnabled) {
-    const { rewrittenQuery, hydeDocument } = await rewriteAndHyde(message, history, modeConfig.rewriteHydeSystemPrompt);
+    const { rewrittenQuery, hydeDocument } = await rewriteAndHyde(message, history);
     timer?.mark("rewrite_hyde");
     const skip = shouldSkipHyde(message, rewrittenQuery) || !hydeDocument;
     if (skip) {
@@ -332,33 +314,14 @@ async function resolveSearchAndEmbedding(message, history, timer, modeConfig = M
 
   const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message, history) : message;
   timer?.mark("rewrite");
-  const { embeddingQuery, embedRole, hydeSkipped } = await resolveEmbeddingQuery(
-    searchQuery,
-    message,
-    modeConfig.hydeSystemPrompt
-  );
+  const { embeddingQuery, embedRole, hydeSkipped } = await resolveEmbeddingQuery(searchQuery, message);
   timer?.mark("hyde", hydeSkipped ? "skipped" : undefined);
   return { searchQuery, embeddingQuery, embedRole, hydeSkipped };
 }
 
-const ATENDIMENTOS_FILTER = { must: [{ key: "metadata.type", match: { value: "atendimento" } }] };
-const DOCUMENTOS_FILTER = { must_not: [{ key: "metadata.type", match: { value: "atendimento" } }] };
-
 const MODES = {
-  documentos: {
-    systemPrompt: SYSTEM_PROMPT,
-    emptyContextGuard: EMPTY_CONTEXT_GUARD,
-    hydeSystemPrompt: HYDE_SYSTEM_PROMPT,
-    rewriteHydeSystemPrompt: REWRITE_HYDE_SYSTEM_PROMPT,
-    filter: DOCUMENTOS_FILTER
-  },
-  atendimentos: {
-    systemPrompt: ATENDIMENTOS_SYSTEM_PROMPT,
-    emptyContextGuard: ATENDIMENTOS_EMPTY_CONTEXT_GUARD,
-    hydeSystemPrompt: ATENDIMENTOS_HYDE_SYSTEM_PROMPT,
-    rewriteHydeSystemPrompt: ATENDIMENTOS_REWRITE_HYDE_SYSTEM_PROMPT,
-    filter: ATENDIMENTOS_FILTER
-  }
+  documentos: { systemPrompt: SYSTEM_PROMPT, emptyContextGuard: EMPTY_CONTEXT_GUARD },
+  atendimentos: { systemPrompt: ATENDIMENTOS_SYSTEM_PROMPT, emptyContextGuard: ATENDIMENTOS_EMPTY_CONTEXT_GUARD }
 };
 
 function resolveMode(mode) {
@@ -387,13 +350,21 @@ export async function answerWithContext({ message, conversationId, userId, optio
   const timer = createTimer();
   const history = await loadHistory(conversationId, userId);
   timer.mark("history");
-  const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer, modeConfig);
-  let hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole, filter: modeConfig.filter });
-  timer.mark("search");
-  if (config.rag.rerank && hits.length > 1) {
-    hits = await rerankHits(searchQuery, hits);
-    timer.mark("rerank");
+
+  let hits;
+  if (mode === "atendimentos") {
+    hits = await resolverContextoAtendimentos(message, history);
+    timer.mark("atendimentos_query");
+  } else {
+    const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer);
+    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);
+      timer.mark("rerank");
+    }
   }
+
   const context = buildContextBlock(hits);
   const messages = buildMessages(message, context, history, modeConfig);
   const completion = await chatCompletion({ messages, options: buildDefaultOptions(options) });
@@ -422,15 +393,22 @@ export async function answerWithContextStream({
   onStatus?.("buscando");
   const history = await loadHistory(conversationId, userId);
   timer.mark("history");
-  const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer, modeConfig);
-  let hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole, filter: modeConfig.filter });
-  timer.mark("search");
-  onStatus?.("encontrou", hits.length);
-
-  if (config.rag.rerank && hits.length > 1) {
-    onStatus?.("reordenando");
-    hits = await rerankHits(searchQuery, hits);
-    timer.mark("rerank");
+
+  let hits;
+  if (mode === "atendimentos") {
+    hits = await resolverContextoAtendimentos(message, history);
+    timer.mark("atendimentos_query");
+    onStatus?.("encontrou", hits.length);
+  } else {
+    const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer);
+    hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
+    timer.mark("search");
+    onStatus?.("encontrou", hits.length);
+    if (config.rag.rerank && hits.length > 1) {
+      onStatus?.("reordenando");
+      hits = await rerankHits(searchQuery, hits);
+      timer.mark("rerank");
+    }
   }
 
   const sources = hitsToSources(hits);

+ 23 - 3
src/services/atendimentoAvaliacaoService.js

@@ -1,6 +1,6 @@
 import { config } from "../config/index.js";
 import { chatCompletion } from "./ollamaClient.js";
-import { buildAtendimentoDocument } from "./atendimentoRagService.js";
+import { formatMensagem, formatDateTime } from "../utils/atendimentoFormat.js";
 import { Atendimento } from "../models/Atendimento.model.js";
 import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
@@ -65,6 +65,26 @@ const AVALIACAO_JSON_SCHEMA = {
   ]
 };
 
+// texto consolidado da conversa para o juiz (cabeçalho + mensagens rotuladas)
+function buildConversaTexto({ atendimento, cliente, mensagens }) {
+  const ordenadas = [...mensagens].sort((a, b) => {
+    const ta = a.Timestamp ? new Date(a.Timestamp).getTime() : 0;
+    const tb = b.Timestamp ? new Date(b.Timestamp).getTime() : 0;
+    return ta - tb || (Number(a.Id) || 0) - (Number(b.Id) || 0);
+  });
+
+  const cabecalho = [
+    `Atendimento ${atendimento.Codigo}` +
+      (atendimento.Setor ? ` — Setor: ${atendimento.Setor}` : "") +
+      (atendimento.Status !== null && atendimento.Status !== undefined ? ` — Status: ${atendimento.Status}` : ""),
+    `Cliente: ${cliente?.Nome ?? "desconhecido"}`,
+    atendimento.Abertura ? `Aberto em: ${formatDateTime(atendimento.Abertura)}` : null
+  ].filter(Boolean);
+
+  const linhas = ordenadas.map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
+  return [...cabecalho, "", ...linhas].join("\n").trim();
+}
+
 const TRUNCATION_MARKER = "\n[... trecho intermediário da conversa omitido ...]\n";
 
 // mantém início (cabeçalho + contexto do problema) e fim (desfecho/sentimento) quando a conversa é longa demais
@@ -177,7 +197,7 @@ export async function avaliarAtendimento(atendimentoId) {
     return { avaliado: false, motivo: "sem_dialogo", avaliacao };
   }
 
-  const doc = buildAtendimentoDocument({
+  const conversaTexto = buildConversaTexto({
     atendimento,
     cliente: atendimento.cliente,
     mensagens
@@ -188,7 +208,7 @@ export async function avaliarAtendimento(atendimentoId) {
     model,
     messages: [
       { role: "system", content: AVALIACAO_SYSTEM_PROMPT },
-      { role: "user", content: truncateConversa(doc.text) }
+      { role: "user", content: truncateConversa(conversaTexto) }
     ],
     format: AVALIACAO_JSON_SCHEMA,
     options: { temperature: 0.1, num_ctx: 8192, num_predict: 1024 }

+ 197 - 0
src/services/atendimentosQueryService.js

@@ -0,0 +1,197 @@
+import { chatCompletion } from "./ollamaClient.js";
+import { Atendimento } from "../models/Atendimento.model.js";
+import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
+import { formatMensagem } from "../utils/atendimentoFormat.js";
+
+const BUSCA_LIMIT_ATENDIMENTOS = 5;
+
+// códigos de protocolo (ex.: SUP0000014741/2026) não aparecem no texto das mensagens —
+// são um campo próprio (Codigo) — por isso precisam de lookup direto, não FULLTEXT.
+const CODIGO_PROTOCOLO_RE = /\b[A-Z]{2,4}\d{4,}\/\d{4}\b/gi;
+
+function extrairCodigosProtocolo(texto) {
+  const matches = String(texto ?? "").match(CODIGO_PROTOCOLO_RE) ?? [];
+  return [...new Set(matches.map((c) => c.toUpperCase()))];
+}
+
+async function buscarPorCodigos(codigos) {
+  if (!codigos.length) return [];
+  return Atendimento.query()
+    .whereIn("Codigo", codigos)
+    .withGraphFetched("[cliente, mensagens]")
+    .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
+}
+
+function classificacaoSystemPrompt() {
+  const hoje = new Date().toISOString().slice(0, 10);
+  return [
+    "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, "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", "qual foi a solução para...").',
+    '"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.',
+    '"termos": para tipo="busca", palavras-chave relevantes para buscar no texto das mensagens (nome do problema, código de protocolo, etc). Para tipo="agregacao" pode ser null.',
+    "Não inclua explicações, texto extra ou markdown fora do JSON."
+  ].join("\n");
+}
+
+async function classificarPergunta(message) {
+  const fallback = { tipo: "busca", setor: null, status: null, dataInicio: null, dataFim: null, termos: message };
+  try {
+    const { content } = await chatCompletion({
+      messages: [
+        { role: "system", content: classificacaoSystemPrompt() },
+        { role: "user", content: message }
+      ],
+      format: "json",
+      options: { temperature: 0.1 }
+    });
+    const parsed = JSON.parse(String(content ?? "").trim());
+    if (parsed?.tipo !== "agregacao" && parsed?.tipo !== "busca") return fallback;
+    return {
+      tipo: parsed.tipo,
+      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,
+      dataFim: typeof parsed.dataFim === "string" && parsed.dataFim.trim() ? parsed.dataFim.trim() : null,
+      termos: typeof parsed.termos === "string" && parsed.termos.trim() ? parsed.termos.trim() : message
+    };
+  } catch (err) {
+    console.warn("[atendimentosQueryService] classificarPergunta falhou, usando busca textual:", err.message);
+    return fallback;
+  }
+}
+
+async function agregarAtendimentos({ setor, status, dataInicio, dataFim }) {
+  let query = Atendimento.query().select("Setor").count("* as total").groupBy("Setor");
+
+  if (setor) query = query.where("Setor", setor);
+  if (status !== null && status !== undefined) query = query.where("Status", status);
+  if (dataInicio) query = query.where("Abertura", ">=", dataInicio);
+  if (dataFim) query = query.where("Abertura", "<=", `${dataFim} 23:59:59`);
+
+  const linhas = await query;
+  const totalGeral = linhas.reduce((acc, l) => acc + Number(l.total), 0);
+  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}`);
+  if (filtros.dataInicio || filtros.dataFim) {
+    partesFiltro.push(`período ${filtros.dataInicio ?? "início"} a ${filtros.dataFim ?? "hoje"}`);
+  }
+  const cabecalho = partesFiltro.length
+    ? `Contagem de atendimentos (banco interno) filtrando por ${partesFiltro.join(", ")}:`
+    : "Contagem de atendimentos (banco interno) por setor:";
+
+  const linhasTexto = linhas
+    .filter((l) => l.Setor)
+    .map((l) => `Setor ${l.Setor}: ${l.total} atendimento${Number(l.total) === 1 ? "" : "s"}`);
+  const semSetor = linhas.find((l) => !l.Setor);
+  if (semSetor) {
+    linhasTexto.push(`Sem setor identificado: ${semSetor.total} atendimento${Number(semSetor.total) === 1 ? "" : "s"}`);
+  }
+
+  return [cabecalho, ...linhasTexto, `Total: ${totalGeral} atendimento${totalGeral === 1 ? "" : "s"}`].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.
+const RELEVANCIA_MIN_RATIO = 0.3;
+
+async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
+  const termo = String(termos ?? "").trim();
+  if (!termo) return [];
+
+  let ranqueados = await AtendimentoMensagem.query()
+    .select("AtendimentoId")
+    .select(AtendimentoMensagem.knex().raw("MAX(MATCH(Body) AGAINST(? IN NATURAL LANGUAGE MODE)) as relevancia", [termo]))
+    .whereRaw("MATCH(Body) AGAINST(? IN NATURAL LANGUAGE MODE)", [termo])
+    .groupBy("AtendimentoId")
+    .orderBy("relevancia", "desc")
+    .limit(limit);
+
+  if (ranqueados.length) {
+    const topScore = Number(ranqueados[0].relevancia);
+    ranqueados = ranqueados.filter((r) => Number(r.relevancia) >= topScore * RELEVANCIA_MIN_RATIO);
+  } else {
+    // fallback LIKE: termos curtos ou compostos só de stopwords não casam em FULLTEXT natural language mode
+    ranqueados = (
+      await AtendimentoMensagem.query()
+        .select("AtendimentoId")
+        .whereRaw("Body LIKE ?", [`%${termo}%`])
+        .groupBy("AtendimentoId")
+        .limit(limit)
+    ).map((r) => ({ AtendimentoId: r.AtendimentoId }));
+  }
+
+  const ids = ranqueados.map((r) => r.AtendimentoId);
+  if (!ids.length) return [];
+
+  let query = Atendimento.query()
+    .findByIds(ids)
+    .withGraphFetched("[cliente, mensagens]")
+    .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
+
+  if (setor) query = query.where("Setor", setor);
+  if (status !== null && status !== undefined) query = query.where("Status", status);
+
+  const atendimentos = await query;
+  const ordem = new Map(ids.map((id, idx) => [id, idx]));
+  atendimentos.sort((a, b) => (ordem.get(a.Id) ?? 0) - (ordem.get(b.Id) ?? 0));
+  return atendimentos;
+}
+
+function formatarTrechoAtendimento(atendimento) {
+  const cliente = atendimento.cliente;
+  const cabecalho = [
+    `Atendimento ${atendimento.Codigo}` +
+      (atendimento.Setor ? ` — Setor: ${atendimento.Setor}` : "") +
+      (atendimento.Status !== null && atendimento.Status !== undefined ? ` — Status: ${atendimento.Status}` : ""),
+    `Cliente: ${cliente?.Nome ?? "desconhecido"}`
+  ];
+  const linhasMensagens = (atendimento.mensagens ?? []).map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
+  return [...cabecalho, "", ...linhasMensagens].join("\n").trim();
+}
+
+export async function resolverContextoAtendimentos(message, history = []) {
+  const filtros = await classificarPergunta(message);
+
+  if (filtros.tipo === "agregacao") {
+    const resultado = await agregarAtendimentos(filtros);
+    return [
+      {
+        id: "agregacao",
+        source: null,
+        text: formatarResumoAgregado(resultado),
+        chunkIndex: 0,
+        metadata: { type: "atendimento_agregacao", ...filtros },
+        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.
+  let codigos = extrairCodigosProtocolo(message);
+  if (!codigos.length && history.length) {
+    codigos = extrairCodigosProtocolo(history.map((m) => m.Content).join("\n"));
+  }
+  const atendimentos = codigos.length ? await buscarPorCodigos(codigos) : await buscarAtendimentosPorTexto(filtros);
+  return atendimentos.map((a) => ({
+    id: `atendimento:${a.Codigo}`,
+    source: `atendimento:${a.Codigo}`,
+    text: formatarTrechoAtendimento(a),
+    chunkIndex: 0,
+    metadata: { type: "atendimento", codigo: a.Codigo, setor: a.Setor, status: a.Status, clienteNome: a.cliente?.Nome ?? null },
+    score: null
+  }));
+}

+ 0 - 11
src/services/atendimentosService.js

@@ -9,7 +9,6 @@ import {
   protocoloMensagensSchema
 } from "../middleware/schemas/Atendimento.Schema.js";
 import { assertPublicHttpsUrl } from "./ingestService.js";
-import { indexAtendimento } from "./atendimentoRagService.js";
 import { BadRequestError } from "../shared/errors/index.js";
 
 const MAX_REDIRECTS = 5;
@@ -154,16 +153,6 @@ export async function salvarAtendimento({ payload, setor, sourceUrl, abertura, t
 
     await trx.commit();
 
-    try {
-      await indexAtendimento({
-        atendimento: { Id: p.Id, Codigo: p.Codigo, Status: p.Status, Setor: setor ?? null, Abertura: toDate(abertura) },
-        cliente: { ...c, Telefone: clienteTelefone },
-        mensagens
-      });
-    } catch (err) {
-      console.error(`[atendimentos] falha ao indexar RAG (${p.Codigo}):`, err.message);
-    }
-
     return {
       protocoloId: p.Id,
       codigo: p.Codigo,

+ 2 - 3
src/services/searchService.js

@@ -2,7 +2,7 @@ import { config } from "../config/index.js";
 import { embedTexts } from "./ollamaClient.js";
 import { qdrant } from "./qdrantClient.js";
 
-export async function searchDocs({ query, topK, embedRole = "query", minScore, filter }) {
+export async function searchDocs({ query, topK, embedRole = "query", minScore }) {
   const collectionName = config.qdrant.collection;
   const [vector] = await embedTexts([query], { role: embedRole });
 
@@ -13,8 +13,7 @@ export async function searchDocs({ query, topK, embedRole = "query", minScore, f
       limit: topK ?? config.rag.topK,
       score_threshold: minScore ?? config.rag.minScore,
       with_payload: true,
-      with_vector: false,
-      ...(filter ? { filter } : {})
+      with_vector: false
     });
   } catch (err) {
     const status = err?.status ?? err?.statusCode ?? err?.response?.status ?? null;

+ 2 - 49
src/services/atendimentoRagService.js → src/utils/atendimentoFormat.js

@@ -1,5 +1,3 @@
-import { ingestDocuments } from "./ingestService.js";
-
 // mensagens do ifbot: Resposta 0 = cliente, 1 = atendente, 9 = sistema.
 // o campo Autor normalmente vem null; o nome do atendente é embutido no próprio
 // Body como HTML: "<b>Nome:</b><br/>texto da mensagem".
@@ -35,7 +33,7 @@ export function parseAtendenteBody(bodyHtml) {
   return { nome: match[1].trim(), texto: stripHtml(raw.slice(match[0].length)) };
 }
 
-function formatDateTime(value) {
+export function formatDateTime(value) {
   if (!value) return null;
   const d = value instanceof Date ? value : new Date(value);
   if (Number.isNaN(d.getTime())) return null;
@@ -49,7 +47,7 @@ function formatDateTime(value) {
   }).format(d);
 }
 
-function formatMensagem(m, clienteNome) {
+export function formatMensagem(m, clienteNome) {
   let label;
   let texto;
 
@@ -76,48 +74,3 @@ function formatMensagem(m, clienteNome) {
   const timestamp = formatDateTime(m.Timestamp);
   return timestamp ? `[${timestamp}] ${label}: ${texto}` : `${label}: ${texto}`;
 }
-
-export function buildAtendimentoDocument({ atendimento, cliente, mensagens }) {
-  const ordenadas = [...mensagens].sort((a, b) => {
-    const ta = a.Timestamp ? new Date(a.Timestamp).getTime() : 0;
-    const tb = b.Timestamp ? new Date(b.Timestamp).getTime() : 0;
-    return ta - tb || (a.Id ?? 0) - (b.Id ?? 0);
-  });
-
-  const cabecalho = [
-    `Atendimento ${atendimento.Codigo}` +
-      (atendimento.Setor ? ` — Setor: ${atendimento.Setor}` : "") +
-      (atendimento.Status !== null && atendimento.Status !== undefined ? ` — Status: ${atendimento.Status}` : ""),
-    `Cliente: ${cliente?.Nome ?? "desconhecido"}${cliente?.Telefone ? ` (Tel: ${cliente.Telefone})` : ""}`,
-    atendimento.Abertura ? `Aberto em: ${formatDateTime(atendimento.Abertura)}` : null
-  ].filter(Boolean);
-
-  const linhasMensagens = ordenadas.map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
-
-  const text = [...cabecalho, "", ...linhasMensagens].join("\n").trim();
-
-  return {
-    id: `atendimento:${atendimento.Codigo}`,
-    text,
-    source: `atendimento:${atendimento.Codigo}`,
-    title: `Atendimento ${atendimento.Codigo} — ${cliente?.Nome ?? "cliente desconhecido"}`,
-    metadata: {
-      type: "atendimento",
-      codigo: atendimento.Codigo,
-      protocoloId: atendimento.Id,
-      setor: atendimento.Setor ?? null,
-      status: atendimento.Status ?? null,
-      clienteId: cliente?.Id ?? null,
-      clienteNome: cliente?.Nome ?? null,
-      abertura: atendimento.Abertura ? new Date(atendimento.Abertura).toISOString() : null,
-      totalMensagens: linhasMensagens.length
-    }
-  };
-}
-
-export async function indexAtendimento({ atendimento, cliente, mensagens }) {
-  if (!mensagens?.length) return { upserted: 0 };
-  const doc = buildAtendimentoDocument({ atendimento, cliente, mensagens });
-  if (!doc.text.trim()) return { upserted: 0 };
-  return ingestDocuments([doc]);
-}