leonardo преди 1 месец
родител
ревизия
5638c0923a

+ 224 - 0
src/config/atendimentoBoilerplate.json

@@ -0,0 +1,224 @@
+{
+  "geradoEm": "2026-07-20T19:23:31.362Z",
+  "totalMensagens": 45837,
+  "totalAtendimentos": 2448,
+  "limiarPercentual": 0.03,
+  "candidatos": [
+    {
+      "texto": "cliente aguardando resposta há mais de 20 minuto(s).",
+      "atendimentos": 1136,
+      "percentual": 0.46405228758169936,
+      "papelPredominante": "sistema"
+    },
+    {
+      "texto": "💫 pode me contar o que está acontecendo? quanto mais detalhes você der, mais rápido conseguimos te ajudar. se quiser encerrar o atendimento, é só digitar *sair* 💬",
+      "atendimentos": 741,
+      "percentual": 0.30269607843137253,
+      "papelPredominante": "atendente"
+    },
+    {
+      "texto": "o atendente bruna assumiu o atendimento!",
+      "atendimentos": 441,
+      "percentual": 0.1801470588235294,
+      "papelPredominante": "sistema"
+    },
+    {
+      "texto": "bom dia",
+      "atendimentos": 329,
+      "percentual": 0.1343954248366013,
+      "papelPredominante": "cliente"
+    },
+    {
+      "texto": "o atendente carla assumiu o atendimento!",
+      "atendimentos": 256,
+      "percentual": 0.10457516339869281,
+      "papelPredominante": "sistema"
+    },
+    {
+      "texto": "o atendente fernanda assumiu o atendimento!",
+      "atendimentos": 251,
+      "percentual": 0.10253267973856209,
+      "papelPredominante": "sistema"
+    },
+    {
+      "texto": "boa tarde",
+      "atendimentos": 231,
+      "percentual": 0.09436274509803921,
+      "papelPredominante": "cliente"
+    },
+    {
+      "texto": "o atendente jonas assumiu o atendimento!",
+      "atendimentos": 199,
+      "percentual": 0.08129084967320262,
+      "papelPredominante": "sistema"
+    },
+    {
+      "texto": "bom dia!",
+      "atendimentos": 194,
+      "percentual": 0.07924836601307189,
+      "papelPredominante": "atendente"
+    },
+    {
+      "texto": "o atendente lucas assumiu o atendimento!",
+      "atendimentos": 181,
+      "percentual": 0.07393790849673203,
+      "papelPredominante": "sistema"
+    },
+    {
+      "texto": "o atendente thiago assumiu o atendimento!",
+      "atendimentos": 176,
+      "percentual": 0.0718954248366013,
+      "papelPredominante": "sistema"
+    },
+    {
+      "texto": "o atendente helen assumiu o atendimento!",
+      "atendimentos": 161,
+      "percentual": 0.06576797385620915,
+      "papelPredominante": "sistema"
+    },
+    {
+      "texto": "obrigada",
+      "atendimentos": 142,
+      "percentual": 0.05800653594771242,
+      "papelPredominante": "cliente"
+    },
+    {
+      "texto": "certo",
+      "atendimentos": 140,
+      "percentual": 0.05718954248366013,
+      "papelPredominante": "atendente"
+    },
+    {
+      "texto": "obrigado",
+      "atendimentos": 118,
+      "percentual": 0.04820261437908497,
+      "papelPredominante": "cliente"
+    },
+    {
+      "texto": "o atendente jake assumiu o atendimento!",
+      "atendimentos": 114,
+      "percentual": 0.04656862745098039,
+      "papelPredominante": "sistema"
+    },
+    {
+      "texto": "olá! como posso te ajudar?",
+      "atendimentos": 113,
+      "percentual": 0.04616013071895425,
+      "papelPredominante": "atendente"
+    },
+    {
+      "texto": "conheça os combos star fibra: combo star premium 700 🚀o máximo em velocidade e desempenho. ✅velocidade: 700 mbps ✅wi-fi 6 ax1800 | até 1.8 gbps de velocidade ➡️pode ser configurado em rede mesh wi-fi ➡️latência ultrabaixa: ideal para jogos e streaming ➡️incluso: star mail, star cloud investimento: r$ 159,00 mensais combo star premium 600 🔥o melhor custo-benefício. ✅velocidade: 600 mbps ✅wi-fi 6 ax1800 | até 1.8 gbps de velocidade ➡️pode ser configurado em rede mesh wi-fi ➡️latência ultrabaixa: ideal para jogos e streaming ➡️incluso: star mail, star cloud investimento: r$ 129,00 mensais combo star 500 ✅qualidade star com excelente economia. ✅velocidade: 500 mbps ✅incluso: wi-fi, star mail, star cloud investimento: r$ 109,00 mensais contrato com fidelidade de 12 meses.",
+      "atendimentos": 107,
+      "percentual": 0.04370915032679738,
+      "papelPredominante": "atendente"
+    },
+    {
+      "texto": "esse mês estamos com uma promoção especial. contratando nossos planos de 500, 600 ou 700 mega, você ganha 50% de desconto nas 3 primeiras mensalidades mediante a doação de uma peça de roupa e ainda concorre a uma bicicleta no final da campanha.",
+      "atendimentos": 104,
+      "percentual": 0.042483660130718956,
+      "papelPredominante": "atendente"
+    },
+    {
+      "texto": "o atendente gabriel assumiu o atendimento!",
+      "atendimentos": 98,
+      "percentual": 0.04003267973856209,
+      "papelPredominante": "sistema"
+    },
+    {
+      "texto": "oferecemos internet 100% fibra óptica, garantindo mais velocidade, estabilidade e qualidade de conexão. a instalação é gratuita, e fornecemos todos os equipamentos necessários para você começar a navegar, pagando apenas a mensalidade do combo escolhido. além da internet, nossos combos incluem benefícios exclusivos, como o star cloud, para armazenar fotos e arquivos na nuvem, e o star mail, um e-mail personalizado. tudo isso com suporte técnico local e atendimento de excelência.",
+      "atendimentos": 98,
+      "percentual": 0.04003267973856209,
+      "papelPredominante": "atendente"
+    },
+    {
+      "texto": "o atendente carla transferiu o atendimento para o setor financeiro",
+      "atendimentos": 97,
+      "percentual": 0.039624183006535946,
+      "papelPredominante": "sistema"
+    },
+    {
+      "texto": "o atendente carla transferiu o atendimento para o setor suporte",
+      "atendimentos": 95,
+      "percentual": 0.03880718954248366,
+      "papelPredominante": "sistema"
+    },
+    {
+      "texto": "boa tarde!",
+      "atendimentos": 94,
+      "percentual": 0.03839869281045752,
+      "papelPredominante": "atendente"
+    },
+    {
+      "texto": "isso",
+      "atendimentos": 94,
+      "percentual": 0.03839869281045752,
+      "papelPredominante": "cliente"
+    },
+    {
+      "texto": "estamos transferindo seu atendimento para um consultor do time *atendimento*. se quiser cancelar o atendimento é só digitar: *sair*",
+      "atendimentos": 91,
+      "percentual": 0.037173202614379085,
+      "papelPredominante": "atendente"
+    },
+    {
+      "texto": "o atendente andrei assumiu o atendimento!",
+      "atendimentos": 91,
+      "percentual": 0.037173202614379085,
+      "papelPredominante": "sistema"
+    },
+    {
+      "texto": "o atendente nicolas assumiu o atendimento!",
+      "atendimentos": 88,
+      "percentual": 0.03594771241830065,
+      "papelPredominante": "sistema"
+    },
+    {
+      "texto": "olá, bom dia",
+      "atendimentos": 81,
+      "percentual": 0.03308823529411765,
+      "papelPredominante": "atendente"
+    },
+    {
+      "texto": "pode ser",
+      "atendimentos": 81,
+      "percentual": 0.03308823529411765,
+      "papelPredominante": "cliente"
+    },
+    {
+      "texto": "disponha!",
+      "atendimentos": 80,
+      "percentual": 0.032679738562091505,
+      "papelPredominante": "atendente"
+    },
+    {
+      "texto": "como posso ajudar?",
+      "atendimentos": 79,
+      "percentual": 0.03227124183006536,
+      "papelPredominante": "atendente"
+    },
+    {
+      "texto": "boa tarde, tudo bem com você? como posso te ajudar?",
+      "atendimentos": 75,
+      "percentual": 0.030637254901960783,
+      "papelPredominante": "atendente"
+    },
+    {
+      "texto": "*documentos necessários* nome completo: cpf: rg: data de nascimento: telefone para contato: endereço/ nº da casa da instalaçao: bairro: cidade: cep: casa propria ou alugada: e-mail: dia de vencimento do boleto da internet: 5 ou 10 de cada mes plano de internet que quer contratar: foto de um documento frente e verso (rg ou cnh ) (obrigatorio) qual sua operadora de internet atual? se for predio ou condominio, precisa de alguma informaçao para entrada do técnico? *em cumprimento à lei geral de proteção de dados (lgpd), informamos que ao serem repassados os documentos informações acima solicitados, você estará consentindo com a coleta de dados pessoais para realização de cadastro pré-contratual.*",
+      "atendimentos": 75,
+      "percentual": 0.030637254901960783,
+      "papelPredominante": "atendente"
+    },
+    {
+      "texto": "bom dia, tudo bem com você? como posso te ajudar?",
+      "atendimentos": 74,
+      "percentual": 0.03022875816993464,
+      "papelPredominante": "atendente"
+    },
+    {
+      "texto": "o atendente vinicius assumiu o atendimento!",
+      "atendimentos": 74,
+      "percentual": 0.03022875816993464,
+      "papelPredominante": "sistema"
+    }
+  ]
+}

+ 5 - 1
src/controllers/Atendimentos.Controller.js

@@ -158,7 +158,11 @@ export const AtendimentosController = {
   EstatisticasAvaliacoes: async function (req, res, next) {
     try {
       const setor = typeof req.query.setor === "string" && req.query.setor.trim() ? req.query.setor.trim() : undefined;
-      const result = await estatisticasAvaliacoes({ setor });
+      const resolvido = typeof req.query.resolvido === "string" && req.query.resolvido.trim() ? req.query.resolvido.trim() : undefined;
+      const sentimento = typeof req.query.sentimento === "string" && req.query.sentimento.trim() ? req.query.sentimento.trim() : undefined;
+      const statusCancelamento = typeof req.query.statusCancelamento === "string" && req.query.statusCancelamento.trim() ? req.query.statusCancelamento.trim() : undefined;
+      const busca = typeof req.query.busca === "string" && req.query.busca.trim() ? req.query.busca.trim() : undefined;
+      const result = await estatisticasAvaliacoes({ setor, resolvido, sentimento, statusCancelamento, busca });
       res.json(result);
     } catch (err) {
       next(err);

+ 23 - 3
src/services/atendimentoAvaliacaoService.js

@@ -459,11 +459,31 @@ export async function listarSetores() {
   return rows.map((r) => ({ sigla: r.Setor, nome: nomeSetor(r.Setor), total: Number(r.total) }));
 }
 
-export async function estatisticasAvaliacoes({ setor } = {}) {
+export async function estatisticasAvaliacoes({ setor, resolvido, sentimento, statusCancelamento, busca } = {}) {
+  const termo = typeof busca === "string" ? busca.trim() : "";
+
+  // mesmos filtros de listarAvaliacoes, para os cards do topo baterem com a lista abaixo
+  // deles na tela (pendentes/totalAtendimentos ficam de fora de propósito: representam o
+  // progresso geral de avaliação do setor, não fazem sentido recortados por um atributo
+  // que só existe em quem já foi avaliado)
   const base = () => {
     const q = AtendimentoAvaliacao.query().where("Avaliavel", true);
-    if (setor) {
-      q.join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId").where("a.Setor", setor);
+    if (setor || termo) {
+      q.join("atendimentos as a", "a.Id", "atendimento_avaliacoes.AtendimentoId");
+    }
+    if (setor) q.where("a.Setor", setor);
+    if (resolvido) q.where("Resolvido", resolvido);
+    if (sentimento) q.where("Sentimento", sentimento);
+    if (statusCancelamento) q.where("StatusCancelamento", statusCancelamento);
+    if (termo) {
+      const like = `%${termo.replace(/[\\%_]/g, (ch) => `\\${ch}`)}%`;
+      const data = parseDataBusca(termo);
+      q.leftJoin("atendimento_clientes as c", "c.Id", "a.ClienteId").where((qb) => {
+        qb.where("a.Codigo", "like", like)
+          .orWhere("c.Nome", "like", like)
+          .orWhere("atendimento_avaliacoes.Atendentes", "like", like);
+        if (data) qb.orWhereRaw("DATE(a.Abertura) = ?", [data]);
+      });
     }
     return q;
   };

+ 13 - 0
src/services/documentsService.js

@@ -38,3 +38,16 @@ export async function deleteDocumentsBySource(source, { collectionName = config.
     }
   });
 }
+
+// apaga só os chunks antigos daquele source (mantém os que acabaram de ser upsertados,
+// identificados pelo ingestedAt do lote atual) — usado no reingest, depois do upsert dos
+// novos chunks já ter tido sucesso, para nunca ficar sem nenhuma versão pesquisável do documento
+export async function deleteStaleDocumentsBySource(source, ingestedAt, { collectionName = config.qdrant.collection } = {}) {
+  await qdrant.delete(collectionName, {
+    wait: true,
+    filter: {
+      must: [{ key: "source", match: { value: source } }],
+      must_not: [{ key: "ingestedAt", match: { value: ingestedAt } }]
+    }
+  });
+}

+ 8 - 6
src/services/ingestService.js

@@ -2,7 +2,7 @@ import { config } from "../config/index.js";
 import { chunkText } from "./textChunker.js";
 import { embedTexts, visionExtractFromImage } from "./ollamaClient.js";
 import { ensureCollection } from "./collectionService.js";
-import { deleteDocumentsBySource } from "./documentsService.js";
+import { deleteStaleDocumentsBySource } from "./documentsService.js";
 import { qdrant } from "./qdrantClient.js";
 import { isRefusal } from "../utils/isRefusal.js";
 import { createHash } from "node:crypto";
@@ -87,11 +87,6 @@ export async function ingestDocuments(documents, {
 
   await ensureCollection({ vectorSize, collectionName });
 
-  const sources = [...new Set(documents.map((d) => d.source).filter(Boolean))];
-  for (const source of sources) {
-    await deleteDocumentsBySource(source, { collectionName });
-  }
-
   const points = allChunks.map((c, idx) => ({
     id: c.id,
     vector: vectors[idx],
@@ -106,11 +101,18 @@ export async function ingestDocuments(documents, {
     }
   }));
 
+  // upsert primeiro: se falhar, os chunks antigos (abaixo) continuam intactos e pesquisáveis.
+  // só depois de confirmado o sucesso é que os chunks antigos daquele source são removidos.
   await qdrant.upsert(collectionName, {
     wait: true,
     points
   });
 
+  const sources = [...new Set(documents.map((d) => d.source).filter(Boolean))];
+  for (const source of sources) {
+    await deleteStaleDocumentsBySource(source, ingestedAt, { collectionName });
+  }
+
   return { upserted: points.length };
 }
 

+ 65 - 49
src/services/whatsappService.js

@@ -78,13 +78,23 @@ async function obterOuCriarConversa(telefone, nomeContato, jid) {
     return existente.Id;
   }
 
-  const criada = await WhatsappConversa.query().insert({
-    Telefone: telefone,
-    NomeContato: nomeContato ?? null,
-    Jid: jid ?? null,
-    Status: "aberta"
-  });
-  return criada.Id;
+  try {
+    const criada = await WhatsappConversa.query().insert({
+      Telefone: telefone,
+      NomeContato: nomeContato ?? null,
+      Jid: jid ?? null,
+      Status: "aberta"
+    });
+    return criada.Id;
+  } catch (err) {
+    // corrida entre eventos messages.upsert concorrentes: outra mensagem quase simultânea
+    // do mesmo contato novo já criou a conversa (Telefone é UNIQUE) — reaproveita ela
+    if (err?.code === "ER_DUP_ENTRY" || err?.nativeError?.code === "ER_DUP_ENTRY") {
+      const criadaPelaCorrida = await WhatsappConversa.query().findOne({ Telefone: telefone });
+      if (criadaPelaCorrida) return criadaPelaCorrida.Id;
+    }
+    throw err;
+  }
 }
 
 export async function iniciarConexao() {
@@ -149,53 +159,59 @@ export async function iniciarConexao() {
       const jid = m.key.senderPn || m.key.remoteJid;
       const telefone = normalizarTelefone(jid);
 
-      let texto = m.message.conversation ?? m.message.extendedTextMessage?.text ?? null;
-      let midiaCampos = { TipoMidia: null, MidiaPath: null, MidiaMimetype: null, MidiaNomeArquivo: null };
-
-      if (texto === null) {
-        const midiaInfo = detectarMidia(m.message);
-        if (midiaInfo) {
-          try {
-            const nomeArquivo = await baixarMidia(m, midiaInfo);
-            midiaCampos = {
-              TipoMidia: midiaInfo.tipo,
-              MidiaPath: nomeArquivo,
-              MidiaMimetype: midiaInfo.mimetype,
-              MidiaNomeArquivo: midiaInfo.nomeArquivo
-            };
-            texto = midiaInfo.caption ?? "";
-          } catch (err) {
-            console.error("[whatsapp] falha ao baixar mídia:", err.message);
+      try {
+        let texto = m.message.conversation ?? m.message.extendedTextMessage?.text ?? null;
+        let midiaCampos = { TipoMidia: null, MidiaPath: null, MidiaMimetype: null, MidiaNomeArquivo: null };
+
+        if (texto === null) {
+          const midiaInfo = detectarMidia(m.message);
+          if (midiaInfo) {
+            try {
+              const nomeArquivo = await baixarMidia(m, midiaInfo);
+              midiaCampos = {
+                TipoMidia: midiaInfo.tipo,
+                MidiaPath: nomeArquivo,
+                MidiaMimetype: midiaInfo.mimetype,
+                MidiaNomeArquivo: midiaInfo.nomeArquivo
+              };
+              texto = midiaInfo.caption ?? "";
+            } catch (err) {
+              console.error("[whatsapp] falha ao baixar mídia:", err.message);
+              texto = `[mensagem do tipo não suportada]`;
+            }
+          } else {
             texto = `[mensagem do tipo não suportada]`;
           }
-        } else {
-          texto = `[mensagem do tipo não suportada]`;
         }
-      }
 
-      const timestamp = m.messageTimestamp ? new Date(Number(m.messageTimestamp) * 1000) : new Date();
-      console.log(`[whatsapp] gravando mensagem de ${telefone}: "${texto}"${midiaCampos.TipoMidia ? ` (mídia: ${midiaCampos.TipoMidia})` : ""}`);
-      const conversaId = await obterOuCriarConversa(telefone, m.pushName, jid);
-
-      // o Baileys pode reentregar a mesma mensagem; key.id é único e o insert é ignorado nesse caso
-      const inserted = await WhatsappMensagem.query()
-        .insert({
-          ConversaId: conversaId,
-          Direcao: "entrada",
-          Corpo: texto,
-          WhatsappMessageId: m.key.id ?? null,
-          Timestamp: timestamp,
-          ...midiaCampos
-        })
-        .onConflict("WhatsappMessageId")
-        .ignore();
-
-      if (!inserted?.Id) {
-        console.log(`[whatsapp] ignorada: WhatsappMessageId duplicado (${m.key.id})`);
-        continue;
-      }
+        const timestamp = m.messageTimestamp ? new Date(Number(m.messageTimestamp) * 1000) : new Date();
+        console.log(`[whatsapp] gravando mensagem de ${telefone}: "${texto}"${midiaCampos.TipoMidia ? ` (mídia: ${midiaCampos.TipoMidia})` : ""}`);
+        const conversaId = await obterOuCriarConversa(telefone, m.pushName, jid);
+
+        // o Baileys pode reentregar a mesma mensagem; key.id é único e o insert é ignorado nesse caso
+        const inserted = await WhatsappMensagem.query()
+          .insert({
+            ConversaId: conversaId,
+            Direcao: "entrada",
+            Corpo: texto,
+            WhatsappMessageId: m.key.id ?? null,
+            Timestamp: timestamp,
+            ...midiaCampos
+          })
+          .onConflict("WhatsappMessageId")
+          .ignore();
+
+        if (!inserted?.Id) {
+          console.log(`[whatsapp] ignorada: WhatsappMessageId duplicado (${m.key.id})`);
+          continue;
+        }
 
-      await WhatsappConversa.query().findById(conversaId).patch({ UltimaMensagemEm: timestamp, UpdatedAt: new Date() });
+        await WhatsappConversa.query().findById(conversaId).patch({ UltimaMensagemEm: timestamp, UpdatedAt: new Date() });
+      } catch (err) {
+        // uma falha nesta mensagem (ex.: corrida de banco, erro transitório) não pode
+        // derrubar o processamento do restante do lote
+        console.error(`[whatsapp] falha ao processar mensagem de ${telefone} (key.id=${m.key.id}):`, err);
+      }
     }
   });
 }