Procházet zdrojové kódy

ajuste chat atendimentos: melhora ao trazer atendimento especifico

leonardo před 2 měsíci
rodič
revize
0771beb01a
2 změnil soubory, kde provedl 32 přidání a 52 odebrání
  1. 2 0
      .gitignore
  2. 30 52
      src/services/atendimentosQueryService.js

+ 2 - 0
.gitignore

@@ -8,3 +8,5 @@ yarn.lock
 training/.venv/
 training/adapters/
 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;
 
-// 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) {
@@ -27,24 +26,23 @@ async function buscarPorCodigos(codigos) {
   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 [];
 
   const clientes = await AtendimentoCliente.query().where("Nome", "like", `%${termo}%`).select("Id");
   const clienteIds = clientes.map((c) => c.Id);
   if (!clienteIds.length) return [];
 
-  const atendimentos = await Atendimento.query()
+  let query = Atendimento.query()
     .whereIn("ClienteId", clienteIds)
     .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 }));
 }
 
@@ -69,15 +67,14 @@ function classificacaoSystemPrompt() {
     '"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.',
-    '"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.',
     "Não inclua explicações, texto extra ou markdown fora do JSON."
   ].join("\n");
 }
 
 export async function classificarPergunta(message, history = []) {
-  // fail-open: se a classificação falhar/não parsear, nunca bloquear uma pergunta
-  // legítima por causa disso — relacionado=true segue pro caminho de busca normal.
+  
   const fallback = { relacionado: true, tipo: "busca", metrica: null, setor: null, status: null, dataInicio: null, dataFim: null, clienteNome: null, termos: message };
   try {
     const hasHistory = history.length > 0;
@@ -94,11 +91,9 @@ export async function classificarPergunta(message, history = []) {
       options: { temperature: 0.1, num_ctx: config.llm.atendimentosNumCtx, num_predict: config.llm.auxNumPredict }
     });
     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";
-    // 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 };
     }
@@ -133,8 +128,6 @@ async function agregarAtendimentos({ 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) {
   const partes = [];
   if (filtros.setor) partes.push(`setor ${filtros.setor}`);
@@ -207,12 +200,10 @@ function formatarLicoesAprendidas(avaliacoes, filtros) {
   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;
 
-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();
   if (!termo) return [];
 
@@ -228,7 +219,7 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA
     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")
@@ -248,6 +239,8 @@ async function buscarAtendimentosPorTexto({ termos, setor, status, limit = BUSCA
 
   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 atendimentos = await query;
   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 }));
 }
 
-// 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();
   if (!termo) return [];
 
@@ -283,6 +273,8 @@ async function buscarAtendimentosPorVetor({ termos, setor, status, limit = BUSCA
 
   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 atendimentos = await query;
   return atendimentos
@@ -291,18 +283,14 @@ async function buscarAtendimentosPorVetor({ termos, setor, status, limit = BUSCA
     .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) {
   const sequenciaAtendentes = [];
   let midiaCount = 0;
   for (const m of mensagens) {
     if (m.Resposta === 1) {
       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) {
         sequenciaAtendentes.push(nome);
       }
@@ -312,9 +300,7 @@ function resumoParticipantes(mensagens) {
   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;
 
 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) {
   const av = atendimento.avaliacao;
   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);
   if (!codigos.length && history.length) {
     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 [];
   }
@@ -435,7 +413,7 @@ export async function resolverContextoAtendimentos(message, history = []) {
   if (codigos.length) {
     resultados = await buscarPorCodigos(codigos);
   } else if (filtros.clienteNome) {
-    resultados = await buscarPorNomeCliente(filtros.clienteNome);
+    resultados = await buscarPorNomeCliente(filtros);
   } else if (config.atendimentosRag.searchEnabled) {
     resultados = await buscarAtendimentosPorVetor(filtros);
   } else {