فهرست منبع

ajustes chat atendimentos

leonardo 2 ماه پیش
والد
کامیت
fc8024f858

+ 118 - 20
scripts/evalAtendimentosRetrieval.mjs

@@ -2,31 +2,106 @@ import { config } from "#config/index.js";
 import { buscarAtendimentosSemelhantes } from "#services/atendimentoRagService.js";
 
 function parseArgs(argv) {
-  const flags = { hyde: null, minScore: 0, topK: 5, label: "" };
+  const flags = { hyde: null, minScore: 0, topK: 5, label: "", collection: null, model: null, casos: null, slice: null };
   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("=");
+    else if (arg.startsWith("--collection=")) flags.collection = arg.split("=").slice(1).join("=");
+    else if (arg.startsWith("--model=")) flags.model = arg.split("=").slice(1).join("=");
+    else if (arg.startsWith("--casos=")) flags.casos = Number(arg.split("=")[1]);
+    else if (arg.startsWith("--slice=")) {
+      const [ini, fim] = arg.split("=")[1].split("-").map(Number);
+      flags.slice = { ini, fim };
+    }
   }
   return flags;
 }
 
+
+function amostrar(cases, n) {
+  if (!n || n >= cases.length) return cases;
+  const positivos = cases.filter((c) => c.type === "positive");
+  const negativos = cases.filter((c) => c.type === "negative");
+  const metade = Math.ceil(n / 2);
+  return [...positivos.slice(0, metade), ...negativos.slice(0, n - metade)];
+}
+
+
+const POSITIVE_CASES = [
+  
+  { query: "cliente reclamando que a internet caiu de novo", setor: "SUP" },
+  { query: "problema de internet lenta", setor: "SUP" },
+  { query: "roteador não conecta depois da queda de energia", setor: "SUP" },
+  { query: "sinal de wifi caindo toda hora", setor: "SUP" },
+  { query: "velocidade de internet abaixo do contratado", setor: "SUP" },
+  { query: "modem piscando luz vermelha sem conexão", setor: "SUP" },
+  
+  { query: "cliente pedindo segunda via de boleto", setor: "FIN" },
+  { query: "cliente questionando cobrança duplicada na fatura", setor: "FIN" },
+  { query: "fatura veio com valor diferente do combinado", setor: "FIN" },
+  { query: "negociação de dívida em atraso", setor: "FIN" },
+  { query: "desconto prometido não apareceu na fatura", setor: "FIN" },
+ 
+  { query: "solicitação de mudança de endereço de instalação", setor: "ATE" },
+  { query: "atualização de cadastro do cliente", setor: "ATE" },
+  { query: "dúvida sobre os detalhes do plano contratado", setor: "ATE" },
+  { query: "reclamação sobre atendimento anterior mal resolvido", setor: "ATE" },
+  { query: "cliente elogiando o atendente que resolveu o problema", setor: "ATE" },
+  
+  { query: "pedido de upgrade de plano de internet", setor: "VEN" },
+  { query: "cliente interessado em portabilidade de número", setor: "VEN" },
+  { query: "contratação de novo serviço combo com TV", setor: "VEN" },
+  { query: "cliente perguntando sobre planos disponíveis na região", setor: "VEN" },
+ 
+  { query: "instalação atrasada, técnico não compareceu", setor: "POS" },
+  { query: "acompanhamento do status da instalação agendada", setor: "POS" },
+  { query: "solicitação de troca de titularidade do contrato", setor: "POS" },
+  { query: "cliente reclamando de equipamento entregue com defeito", setor: "POS" },
+
+  { query: "cliente sem internet à noite, caso urgente", setor: "PLA" },
+  { query: "queda de energia afetando o serviço fora do horário comercial", setor: "PLA" },
+  { query: "chamado de emergência fora do expediente normal", setor: "PLA" },
+ 
+  { query: "cancelamento de contrato", setor: null },
+  { query: "cliente pedindo desbloqueio do serviço suspenso", setor: null },
+  { query: "renovação de contrato prestes a vencer", setor: null }
+];
+
+
+const NEGATIVE_UNRELATED = [
+  "previsão do tempo para amanhã em Paris",
+  "receita de bolo de chocolate",
+  "quem ganhou o jogo de futebol ontem",
+  "qual a capital da Austrália",
+  "como treinar para uma maratona",
+  "melhores filmes de ficção científica dos anos 80",
+  "resumo da história do Brasil colonial",
+  "dicas de jardinagem para iniciantes",
+  "como aprender inglês sozinho",
+  "cotação do dólar hoje",
+  "letra da música mais tocada este ano",
+  "curiosidades sobre o sistema solar"
+];
+
+
+const NEGATIVE_BOILERPLATE = [
+  "digite 1 para confirmar ou 2 para cancelar",
+  "obrigado por aguardar, em instantes um atendente irá te atender",
+  "encaminhando para o setor responsável",
+  "aguarde enquanto conectamos você a um atendente disponível",
+  "sua senha de atendimento é a 25",
+  "deseja avaliar nosso atendimento de 1 a 5",
+  "este atendimento foi encerrado por inatividade",
+  "digite o número da opção desejada no menu"
+];
+
 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" }
+  ...POSITIVE_CASES.map((c) => ({ query: c.query, type: "positive" })),
+  ...NEGATIVE_UNRELATED.map((query) => ({ query, type: "negative" })),
+  ...NEGATIVE_BOILERPLATE.map((query) => ({ query, type: "negative" }))
 ];
 
 async function runCase(testCase, flags) {
@@ -34,7 +109,9 @@ async function runCase(testCase, flags) {
     query: testCase.query,
     topK: flags.topK,
     minScore: flags.minScore,
-    ...(flags.hyde !== null ? { hyde: flags.hyde } : {})
+    ...(flags.hyde !== null ? { hyde: flags.hyde } : {}),
+    ...(flags.collection ? { collectionName: flags.collection } : {}),
+    ...(flags.model ? { embeddingsModel: flags.model } : {})
   });
 
   const top1 = hits[0] ?? null;
@@ -47,18 +124,37 @@ async function runCase(testCase, flags) {
   };
 }
 
+
+function separabilidade(positiveScores, negativeScores) {
+  if (!positiveScores.length || !negativeScores.length) return NaN;
+  let wins = 0;
+  let total = 0;
+  for (const p of positiveScores) {
+    for (const n of negativeScores) {
+      total += 1;
+      if (p > n) wins += 1;
+      else if (p === n) wins += 0.5;
+    }
+  }
+  return (wins / total) * 100;
+}
+
 async function main() {
   const flags = parseArgs(process.argv.slice(2));
   const hydeEfetivo = flags.hyde !== null ? flags.hyde : config.atendimentosRag.hyde;
+  const collectionEfetiva = flags.collection ?? config.atendimentosRag.collection;
+  const modelEfetivo = flags.model ?? config.ollama.embeddingsModel;
+
+  const casos = flags.slice ? CASES.slice(flags.slice.ini, flags.slice.fim) : amostrar(CASES, flags.casos);
 
   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`
+    `modelo=${modelEfetivo} minScore=${flags.minScore} topK=${flags.topK} ` +
+    `hyde=${hydeEfetivo} colecao=${collectionEfetiva} casos=${casos.length}/${CASES.length}\n`
   );
 
   const rows = [];
-  for (const testCase of CASES) {
+  for (const testCase of casos) {
     rows.push(await runCase(testCase, flags));
   }
 
@@ -75,13 +171,15 @@ async function main() {
   const positivoMin = positiveScores.length ? Math.min(...positiveScores) : NaN;
   const negativoMax = negativeScores.length ? Math.max(...negativeScores) : NaN;
   const gap = positivoMin - negativoMax;
+  const auc = separabilidade(positiveScores, negativeScores);
 
   console.log("\n--- resumo ---");
-  console.log(`positivos: score médio top-1 = ${avg(positiveScores).toFixed(4)}`);
+  console.log(`positivos: ${positiveScores.length} casos, 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: ${negativeScores.length} casos, 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(`separabilidade (% pares positivo>negativo, tipo AUC) = ${Number.isNaN(auc) ? "-" : auc.toFixed(1) + "%"}`);
   console.log("");
 }
 

+ 9 - 7
src/services/atendimentoRagService.js

@@ -86,7 +86,7 @@ export async function ingestarAtendimento(atendimentoId) {
 
 let ragIngestaoEmAndamento = false;
 
-// pendente = atendimento com mensagens e sem índice RAG, ou com mensagens mais novas que a última indexada
+
 function pendentesQuery({ setor } = {}) {
   const subMax = AtendimentoMensagem.query()
     .select("AtendimentoId")
@@ -128,7 +128,7 @@ export async function ingestarPendentes({ limite = config.atendimentosRag.batchS
     const pendentes = await buscarPendentes(limite, { setor });
     const resultado = { pendentes: pendentes.length, ingeridos: 0, semConteudo: 0, erros: [] };
 
-    // sequencial de propósito: o Ollama local não se beneficia de concorrência aqui
+    
     for (const { Id } of pendentes) {
       try {
         const r = await ingestarAtendimento(Id);
@@ -149,7 +149,9 @@ export async function buscarAtendimentosSemelhantes({
   query,
   topK = config.atendimentosRag.topK,
   minScore = config.atendimentosRag.minScore,
-  hyde = config.atendimentosRag.hyde
+  hyde = config.atendimentosRag.hyde,
+  collectionName = config.atendimentosRag.collection,
+  embeddingsModel = config.ollama.embeddingsModel
 }) {
   let embeddingQuery = query;
   let embedRole = "query";
@@ -168,12 +170,12 @@ export async function buscarAtendimentosSemelhantes({
     query: embeddingQuery,
     topK,
     minScore,
-    collectionName: config.atendimentosRag.collection,
-    embedRole
+    collectionName,
+    embedRole,
+    embeddingsModel
   });
 
-  // 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,

+ 8 - 2
src/services/atendimentosQueryService.js

@@ -414,10 +414,16 @@ export async function resolverContextoAtendimentos(message, history = []) {
     resultados = await buscarPorCodigos(codigos);
   } else if (filtros.clienteNome) {
     resultados = await buscarPorNomeCliente(filtros);
-  } else if (config.atendimentosRag.searchEnabled) {
-    resultados = await buscarAtendimentosPorVetor(filtros);
   } else {
     resultados = await buscarAtendimentosPorTexto(filtros);
+    if (!resultados.length && config.atendimentosRag.searchEnabled) {
+      resultados = await buscarAtendimentosPorVetor(filtros);
+      const scores = resultados.map((r) => r.score).filter((s) => typeof s === "number");
+      console.log(
+        `[atendimentosQueryService] fallback vetor acionado (fulltext vazio): ${resultados.length} resultado(s)` +
+          (scores.length ? `, scores=[${scores.map((s) => s.toFixed(4)).join(", ")}]` : "")
+      );
+    }
   }
   return resultados.map(({ atendimento: a, score }) => ({
     id: `atendimento:${a.Codigo}`,

+ 3 - 2
src/services/ingestService.js

@@ -49,7 +49,8 @@ function stableUuid(seed) {
 export async function ingestDocuments(documents, {
   collectionName = config.qdrant.collection,
   chunkSize = config.rag.chunkSize,
-  chunkOverlap = config.rag.chunkOverlap
+  chunkOverlap = config.rag.chunkOverlap,
+  embeddingsModel
 } = {}) {
   const allChunks = [];
   const ingestedAt = new Date().toISOString();
@@ -76,7 +77,7 @@ export async function ingestDocuments(documents, {
 
   if (allChunks.length === 0) return { upserted: 0 };
 
-  const vectors = await embedTexts(allChunks.map((c) => c.text), { role: "passage" });
+  const vectors = await embedTexts(allChunks.map((c) => c.text), { role: "passage", model: embeddingsModel });
   const vectorSize = vectors[0]?.length ?? 0;
   if (!vectorSize) {
     const err = new Error("embeddings_empty");

+ 8 - 10
src/services/ollamaClient.js

@@ -1,9 +1,7 @@
 import { config } from "../config/index.js";
 import { createTtlCache } from "./simpleCache.js";
 
-// combina um AbortSignal externo (opcional, ex.: desconexão do cliente) com um timeout
-// interno — sem isso, uma chamada ao Ollama que trava nunca resolve nem rejeita, o que
-// já travou o lock de avaliaçao em lote e deixava o cancelamento de chat sem efeito.
+
 function timeoutSignal(externalSignal, timeoutMs) {
   const controller = new AbortController();
   const timer = setTimeout(() => controller.abort(new Error(`ollama_timeout:${timeoutMs}ms`)), timeoutMs);
@@ -68,31 +66,31 @@ function embedPrefixFor(role) {
   return "";
 }
 
-// embeddings de um texto+role são determinísticos — cachear evita reprocessar perguntas
-// repetidas/idênticas (comum num chatbot interno) do zero a cada vez.
+
 const EMBED_CACHE_TTL_MS = 15 * 60 * 1000;
 const embedCache = createTtlCache(EMBED_CACHE_TTL_MS);
 
-async function embedSingle(text, { role } = {}) {
-  const cacheKey = `${role ?? ""}:${text}`;
+async function embedSingle(text, { role, model } = {}) {
+  const effectiveModel = model ?? config.ollama.embeddingsModel;
+  const cacheKey = `${effectiveModel}:${role ?? ""}:${text}`;
   const cached = embedCache.get(cacheKey);
   if (cached) return cached;
 
   const prefix = embedPrefixFor(role);
   const data = await ollamaFetch("/api/embeddings", {
-    model: config.ollama.embeddingsModel,
+    model: effectiveModel,
     prompt: `${prefix}${text}`
   });
   embedCache.set(cacheKey, data.embedding);
   return data.embedding;
 }
 
-export async function embedTexts(texts, { role } = {}) {
+export async function embedTexts(texts, { role, model } = {}) {
   const BATCH = 10;
   const results = [];
   for (let i = 0; i < texts.length; i += BATCH) {
     const batch = texts.slice(i, i + BATCH);
-    const embeddings = await Promise.all(batch.map((text) => embedSingle(text, { role })));
+    const embeddings = await Promise.all(batch.map((text) => embedSingle(text, { role, model })));
     results.push(...embeddings);
   }
   return results;

+ 2 - 2
src/services/searchService.js

@@ -2,8 +2,8 @@ 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, collectionName = config.qdrant.collection }) {
-  const [vector] = await embedTexts([query], { role: embedRole });
+export async function searchDocs({ query, topK, embedRole = "query", minScore, collectionName = config.qdrant.collection, embeddingsModel }) {
+  const [vector] = await embedTexts([query], { role: embedRole, model: embeddingsModel });
 
   let result;
   try {

+ 40 - 10
src/utils/atendimentoFormat.js

@@ -1,9 +1,6 @@
 import { estaForaExpediente } from "../config/expediente.js";
 
-// mensagens do ifbot: Resposta 0 = cliente, 1 = atendente; status de sistema usam
-// códigos variados (8, 9, 10, 21, 22, ...) sempre com Tipodemidia "ifstatus".
-// o campo Autor normalmente vem null; o nome do atendente é embutido no próprio
-// Body como HTML: "<b>Nome:</b><br/>texto da mensagem".
+
 const ATENDENTE_PREFIX_RE = /^\s*<b>\s*([^<:]+?)\s*:?\s*<\/b>\s*(?:<br\s*\/?>)?\s*/i;
 
 function decodeHtmlEntities(str) {
@@ -66,8 +63,7 @@ export function estruturarMensagem(m) {
     papel = "cliente";
     texto = stripHtml(m.Body);
   } else if (m.Resposta === 9 || m.Tipodemidia === "ifstatus") {
-    // eventos de status do ifbot chegam com vários códigos de Resposta (8, 10, 21, 22, ...),
-    // mas sempre com Tipodemidia "ifstatus" — todos são mensagens de sistema
+    
     papel = "sistema";
     texto = stripHtml(m.Body);
   } else {
@@ -75,8 +71,7 @@ export function estruturarMensagem(m) {
     texto = stripHtml(m.Body);
   }
 
-  // áudios chegam com o Body sendo só o nome do arquivo; quando o ifbot manda
-  // a transcrição, ela substitui esse texto sem conteúdo
+  
   if (transcricao) {
     texto = `[áudio] ${transcricao}`;
   } else if (!texto && m.Tipodemidia && m.Tipodemidia !== "text") {
@@ -86,8 +81,7 @@ export function estruturarMensagem(m) {
   return { papel, autor, texto };
 }
 
-// URL pública do arquivo de mídia da mensagem. O caminho normalmente vem em Midia
-// (ex.: /data/img/xxx.jpg); em áudios antigos o Body é o próprio path e Midia é null.
+
 export function montarUrlMidia(m, baseUrl) {
   let caminho = typeof m.Midia === "string" && m.Midia.trim() ? m.Midia.trim() : null;
   if (!caminho) {
@@ -141,6 +135,42 @@ export function buildConversaTexto({ atendimento, cliente, mensagens }) {
   return [...cabecalho, "", ...linhas].join("\n").trim();
 }
 
+function normalizarParaBoilerplate(texto) {
+  return String(texto ?? "")
+    .trim()
+    .toLowerCase()
+    .replace(/\s+/g, " ");
+}
+
+export function buildConversaTextoParaRag({ atendimento, cliente, mensagens }, { boilerplateSet = new Set() } = {}) {
+  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) => {
+      const { papel, texto } = estruturarMensagem(m);
+      if (papel === "sistema" || !texto) return null;
+      if (boilerplateSet.has(normalizarParaBoilerplate(texto))) return null;
+      return formatMensagem(m, cliente?.Nome, {
+        foraExpediente: m.Resposta === 0 && estaForaExpediente(atendimento.Setor, m.Timestamp)
+      });
+    })
+    .filter(Boolean);
+
+  return [...cabecalho, "", ...linhas].join("\n").trim();
+}
+
 export function calcularUltimaMensagemId(mensagens) {
   return mensagens.reduce((max, m) => (Number(m.Id) > max ? Number(m.Id) : max), 0) || null;
 }