Просмотр исходного кода

ajuste chat atendimentos: melhora ao trazer atendimento especifico

leonardo 2 месяцев назад
Родитель
Сommit
0771beb01a
2 измененных файлов с 32 добавлено и 52 удалено
  1. 2 0
      .gitignore
  2. 30 52
      src/services/atendimentosQueryService.js

+ 2 - 0
.gitignore

@@ -8,3 +8,5 @@ yarn.lock
 training/.venv/
 training/.venv/
 training/adapters/
 training/adapters/
 training/*.log
 training/*.log
+scripts/logs/
+scripts/

+ 30 - 52
src/services/atendimentosQueryService.js

@@ -9,8 +9,7 @@ import { agregarQualidade, buscarAvaliacoesParaLicoes, SCORE_RUIM_MAX } from "./
 
 
 const BUSCA_LIMIT_ATENDIMENTOS = 5;
 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;
 const CODIGO_PROTOCOLO_RE = /\b[A-Z]{2,4}\d{4,}\/\d{4}\b/gi;
 
 
 function extrairCodigosProtocolo(texto) {
 function extrairCodigosProtocolo(texto) {
@@ -27,24 +26,23 @@ async function buscarPorCodigos(codigos) {
   return atendimentos.map((atendimento) => ({ atendimento, score: null }));
   return atendimentos.map((atendimento) => ({ atendimento, score: null }));
 }
 }
 
 
-// nome do cliente vive em atendimento_clientes.Nome — assim como o código, não
-// aparece no texto das mensagens, então precisa de lookup próprio (não FULLTEXT).
-// Ordena do atendimento mais recente pro mais antigo, já que perguntas por nome
-// costumam ser sobre a última conversa com aquele cliente.
-async function buscarPorNomeCliente(nome, limit = BUSCA_LIMIT_ATENDIMENTOS) {
-  const termo = String(nome ?? "").trim();
+async function buscarPorNomeCliente({ clienteNome, dataInicio, dataFim, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
+  const termo = String(clienteNome ?? "").trim();
   if (!termo) return [];
   if (!termo) return [];
 
 
   const clientes = await AtendimentoCliente.query().where("Nome", "like", `%${termo}%`).select("Id");
   const clientes = await AtendimentoCliente.query().where("Nome", "like", `%${termo}%`).select("Id");
   const clienteIds = clientes.map((c) => c.Id);
   const clienteIds = clientes.map((c) => c.Id);
   if (!clienteIds.length) return [];
   if (!clienteIds.length) return [];
 
 
-  const atendimentos = await Atendimento.query()
+  let query = Atendimento.query()
     .whereIn("ClienteId", clienteIds)
     .whereIn("ClienteId", clienteIds)
     .withGraphFetched("[cliente, mensagens, avaliacao]")
     .withGraphFetched("[cliente, mensagens, avaliacao]")
-    .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"))
-    .orderByRaw("COALESCE(Abertura, IngestedAt) DESC")
-    .limit(limit);
+    .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
+
+  if (dataInicio) query = query.where("Abertura", ">=", dataInicio);
+  if (dataFim) query = query.where("Abertura", "<=", `${dataFim} 23:59:59`);
+
+  const atendimentos = await query.orderByRaw("COALESCE(Abertura, IngestedAt) DESC").limit(limit);
   return atendimentos.map((atendimento) => ({ atendimento, score: null }));
   return atendimentos.map((atendimento) => ({ atendimento, score: null }));
 }
 }
 
 
@@ -69,15 +67,14 @@ function classificacaoSystemPrompt() {
     '"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.',
-    '"clienteNome": nome (completo ou parcial) do cliente mencionado na pergunta, ou null se não mencionado. Use o HISTÓRICO DA CONVERSA (se houver) para resolver referências como "ele", "esse cliente", "o mesmo cliente de antes".',
+    '"clienteNome": nome (completo ou parcial) do cliente mencionado na pergunta, ou null se não mencionado. Preste atenção em frases como "atendimento de [Nome]", "atendimento da [Nome]", "conversa com [Nome]" — o texto após "de"/"da"/"do"/"com" quase sempre É o nome do cliente, mesmo sem a palavra "cliente" explícita antes dele (ex: "atendimento de MOTEL DISKRETUS LTDA" -> clienteNome="MOTEL DISKRETUS LTDA"). Só deixe null quando for claramente uma categoria/setor genérico, não um nome próprio (ex: "atendimento de suporte", "atendimento de vendas"). Use o HISTÓRICO DA CONVERSA (se houver) para resolver referências como "ele", "esse cliente", "o mesmo cliente de antes".',
     '"termos": para tipo="busca" SEM clienteNome, palavras-chave relevantes para buscar no texto das mensagens (nome do problema, etc). Se clienteNome estiver preenchido, termos pode ser null.',
     '"termos": para tipo="busca" SEM clienteNome, palavras-chave relevantes para buscar no texto das mensagens (nome do problema, etc). Se clienteNome estiver preenchido, termos pode ser null.',
     "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("\n");
   ].join("\n");
 }
 }
 
 
 export async function classificarPergunta(message, history = []) {
 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 };
   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;
@@ -94,11 +91,9 @@ export async function classificarPergunta(message, history = []) {
       options: { temperature: 0.1, num_ctx: config.llm.atendimentosNumCtx, num_predict: config.llm.auxNumPredict }
       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());
-    // 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";
     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) {
     if (!relacionado) {
       return { ...fallback, relacionado: false };
       return { ...fallback, relacionado: false };
     }
     }
@@ -133,8 +128,6 @@ async function agregarAtendimentos({ setor, status, dataInicio, dataFim }) {
   return { linhas, totalGeral, filtros: { setor, status, dataInicio, dataFim } };
   return { linhas, totalGeral, filtros: { setor, status, dataInicio, dataFim } };
 }
 }
 
 
-// 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) {
 function descreverFiltros(filtros) {
   const partes = [];
   const partes = [];
   if (filtros.setor) partes.push(`setor ${filtros.setor}`);
   if (filtros.setor) partes.push(`setor ${filtros.setor}`);
@@ -207,12 +200,10 @@ function formatarLicoesAprendidas(avaliacoes, filtros) {
   return [cabecalho, ...linhas].join("\n");
   return [cabecalho, ...linhas].join("\n");
 }
 }
 
 
-// relevância do MySQL FULLTEXT não é normalizada (0-1) — em vez de um valor fixo,
-// descarta matches fracos relativos ao melhor resultado, pra não misturar atendimentos
-// só incidentalmente relacionados quando existe um resultado claramente mais forte.
+
 const RELEVANCIA_MIN_RATIO = 0.3;
 const RELEVANCIA_MIN_RATIO = 0.3;
 
 
-async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
+async function buscarAtendimentosPorTexto({ termos, setor, status, dataInicio, dataFim, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
   const termo = String(termos ?? "").trim();
   const termo = String(termos ?? "").trim();
   if (!termo) return [];
   if (!termo) return [];
 
 
@@ -228,7 +219,7 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA
     const topScore = Number(ranqueados[0].relevancia);
     const topScore = Number(ranqueados[0].relevancia);
     ranqueados = ranqueados.filter((r) => Number(r.relevancia) >= topScore * RELEVANCIA_MIN_RATIO);
     ranqueados = ranqueados.filter((r) => Number(r.relevancia) >= topScore * RELEVANCIA_MIN_RATIO);
   } else {
   } else {
-    // fallback LIKE: termos curtos ou compostos só de stopwords não casam em FULLTEXT natural language mode
+    
     ranqueados = (
     ranqueados = (
       await AtendimentoMensagem.query()
       await AtendimentoMensagem.query()
         .select("AtendimentoId")
         .select("AtendimentoId")
@@ -248,6 +239,8 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA
 
 
   if (setor) query = query.where("Setor", setor);
   if (setor) query = query.where("Setor", setor);
   if (status !== null && status !== undefined) query = query.where("Status", status);
   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 atendimentos = await query;
   const atendimentos = await query;
   const ordem = new Map(ids.map((id, idx) => [id, idx]));
   const ordem = new Map(ids.map((id, idx) => [id, idx]));
@@ -255,11 +248,8 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA
   return atendimentos.map((atendimento) => ({ atendimento, score: null }));
   return atendimentos.map((atendimento) => ({ atendimento, score: null }));
 }
 }
 
 
-// busca semântica via Qdrant (coleção separada de atendimentos, config.atendimentosRag) —
-// alternativa ao FULLTEXT acima, atrás de config.atendimentosRag.searchEnabled. Os hits do
-// Qdrant vêm em nível de CHUNK; agrupamos por atendimentoId mantendo o melhor score entre
-// os chunks do mesmo atendimento antes de recarregar os registros completos do MySQL.
-async function buscarAtendimentosPorVetor({ termos, setor, status, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
+
+async function buscarAtendimentosPorVetor({ termos, setor, status, dataInicio, dataFim, limit = BUSCA_LIMIT_ATENDIMENTOS }) {
   const termo = String(termos ?? "").trim();
   const termo = String(termos ?? "").trim();
   if (!termo) return [];
   if (!termo) return [];
 
 
@@ -283,6 +273,8 @@ async function buscarAtendimentosPorVetor({ termos, setor, status, limit = BUSCA
 
 
   if (setor) query = query.where("Setor", setor);
   if (setor) query = query.where("Setor", setor);
   if (status !== null && status !== undefined) query = query.where("Status", status);
   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 atendimentos = await query;
   const atendimentos = await query;
   return atendimentos
   return atendimentos
@@ -291,18 +283,14 @@ async function buscarAtendimentosPorVetor({ termos, setor, status, limit = BUSCA
     .slice(0, limit);
     .slice(0, limit);
 }
 }
 
 
-// calcula, de forma determinística (não depende do LLM "contar certo"), quais
-// atendentes participaram e em que ordem — inclusive transferências entre eles —
-// e quantas mensagens são mídia sem transcrição de texto.
+
 function resumoParticipantes(mensagens) {
 function resumoParticipantes(mensagens) {
   const sequenciaAtendentes = [];
   const sequenciaAtendentes = [];
   let midiaCount = 0;
   let midiaCount = 0;
   for (const m of mensagens) {
   for (const m of mensagens) {
     if (m.Resposta === 1) {
     if (m.Resposta === 1) {
       const { nome } = parseAtendenteBody(m.Body);
       const { nome } = parseAtendenteBody(m.Body);
-      // mensagens de mídia pura (sem texto) não têm o prefixo "<b>Nome:</b>" pra extrair o
-      // nome — nesse caso, assume que é o mesmo atendente da mensagem anterior em vez de
-      // criar uma identidade "não identificada" fantasma que infla a contagem de atendentes.
+    
       if (nome && sequenciaAtendentes[sequenciaAtendentes.length - 1] !== nome) {
       if (nome && sequenciaAtendentes[sequenciaAtendentes.length - 1] !== nome) {
         sequenciaAtendentes.push(nome);
         sequenciaAtendentes.push(nome);
       }
       }
@@ -312,9 +300,7 @@ function resumoParticipantes(mensagens) {
   return { sequenciaAtendentes, distintos: [...new Set(sequenciaAtendentes)], midiaCount };
   return { sequenciaAtendentes, distintos: [...new Set(sequenciaAtendentes)], midiaCount };
 }
 }
 
 
-// tickets muito longos (centenas de mensagens) podem ter dezenas de transferências
-// reais entre atendentes — mostrar a cadeia inteira vira ruído ilegível, então acima
-// de um teto mostra só início/fim + contagem, sem perder a informação de quantos são.
+
 const SEQUENCIA_ATENDENTES_MAX = 12;
 const SEQUENCIA_ATENDENTES_MAX = 12;
 
 
 function formatarSequenciaAtendentes(sequenciaAtendentes, distintos) {
 function formatarSequenciaAtendentes(sequenciaAtendentes, distintos) {
@@ -332,10 +318,7 @@ 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) {
 function formatarBlocoAvaliacao(atendimento) {
   const av = atendimento.avaliacao;
   const av = atendimento.avaliacao;
   if (!av?.Avaliavel) return null;
   if (!av?.Avaliavel) return null;
@@ -415,18 +398,13 @@ export async function resolverContextoAtendimentos(message, history = []) {
     ];
     ];
   }
   }
 
 
-  // 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);
   let codigos = extrairCodigosProtocolo(message);
   if (!codigos.length && history.length) {
   if (!codigos.length && history.length) {
     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) {
   if (!codigos.length && !filtros.clienteNome && !filtros.relacionado) {
     return [];
     return [];
   }
   }
@@ -435,7 +413,7 @@ export async function resolverContextoAtendimentos(message, history = []) {
   if (codigos.length) {
   if (codigos.length) {
     resultados = await buscarPorCodigos(codigos);
     resultados = await buscarPorCodigos(codigos);
   } else if (filtros.clienteNome) {
   } else if (filtros.clienteNome) {
-    resultados = await buscarPorNomeCliente(filtros.clienteNome);
+    resultados = await buscarPorNomeCliente(filtros);
   } else if (config.atendimentosRag.searchEnabled) {
   } else if (config.atendimentosRag.searchEnabled) {
     resultados = await buscarAtendimentosPorVetor(filtros);
     resultados = await buscarAtendimentosPorVetor(filtros);
   } else {
   } else {