leonardo hace 1 mes
padre
commit
cc6e410a6f

+ 16 - 0
db/migrations/20260806130000_add_avaliacao_updated_at_to_atendimento_rag_index.cjs

@@ -0,0 +1,16 @@
+exports.up = function (knex) {
+  return knex.schema.alterTable("atendimento_rag_index", (t) => {
+    // UpdatedAt da avaliação (atendimento_avaliacoes.UpdatedAt) já incorporada ao texto/
+    // metadata indexado deste atendimento. null = nunca indexado com avaliação (inclui todo
+    // o histórico pré-feature, que fica pendente para reindex).
+    t.timestamp("AvaliacaoUpdatedAt").nullable();
+    t.index(["AvaliacaoUpdatedAt"]);
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.alterTable("atendimento_rag_index", (t) => {
+    t.dropIndex(["AvaliacaoUpdatedAt"]);
+    t.dropColumn("AvaliacaoUpdatedAt");
+  });
+};

+ 1 - 1
src/chat/chatChain.js

@@ -30,7 +30,7 @@ const ATENDIMENTOS_SYSTEM_PROMPT = [
   "Quando o CONTEXTO trouxer vários atendimentos do mesmo cliente, eles vêm ordenados do mais recente para o mais antigo (compare o campo 'Aberto em') — use isso para responder perguntas sobre a última/mais recente conversa com um cliente.",
   "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.",
-  "Mensagens marcadas como '[áudio] texto' JÁ SÃO a transcrição real do que foi dito — trate esse texto como conteúdo legítimo da conversa, cite e resuma normalmente, igual a uma mensagem de texto comum. Já mensagens marcadas como '[mídia: tipo]' (sem transcrição) são anexos cujo conteúdo você realmente não conhece — só mencione que foram enviados, sem descrever ou supor o que mostram.",
+  "Mensagens marcadas como '[áudio] texto' JÁ SÃO a transcrição real do que foi dito — trate esse texto como conteúdo legítimo da conversa, cite e resuma normalmente, igual a uma mensagem de texto comum. Já mensagens marcadas como '[áudio sem transcrição disponível]' ou '[mídia: tipo]' são áudios/anexos cujo conteúdo você realmente não conhece — só mencione que foram enviados, sem descrever ou supor o que foi dito ou mostrado.",
   "Nunca invente números, datas, horários, prazos de espera, falas, nomes de clientes, códigos de protocolo ou desfechos que não estejam literalmente escritos no CONTEXTO — mas dentro do que está escrito, seja o mais completo possível.",
   "Para atendimentos longos (muitas mensagens), NÃO se limite a 'alguns pontos importantes' — percorra a conversa cronologicamente e descreva o que aconteceu em cada fase/assunto tratado, com o máximo de detalhe real disponível. Evite frases genéricas de ressalva como 'a conversa foi muito extensa', 'pode haver informações faltando' ou 'os áudios não são transcrições exatas' — se o CONTEXTO tem a informação, relate-a com confiança; só sinalize incerteza quando algo específico realmente não estiver claro no texto.",
   "(6) um registro real de cancelamento vindo AO VIVO do ERP (IXC) — cliente, plano, valor, data de ativação/cancelamento, meses de contrato, motivo, vendedor e local — ou um resumo agregado desses cancelamentos reais (ranking por motivo/cidade/vendedor); trate como o dado definitivo e confirmado de cancelamento, diferente de qualquer avaliação automática por IA;",

+ 5 - 1
src/config/index.js

@@ -106,7 +106,11 @@ export const config = {
   atendenteSugestao: {
     model: process.env.OLLAMA_ATENDENTE_MODEL ?? "star-atendente",
     historyLimit: Number(process.env.ATENDENTE_SUGESTAO_HISTORY_LIMIT ?? 20),
-    maxChars: Number(process.env.ATENDENTE_SUGESTAO_MAX_CHARS ?? 8000)
+    maxChars: Number(process.env.ATENDENTE_SUGESTAO_MAX_CHARS ?? 8000),
+    ragEnabled: process.env.ATENDENTE_SUGESTAO_RAG_ENABLED !== "false",
+    ragTopK: Number(process.env.ATENDENTE_SUGESTAO_RAG_TOP_K ?? 3),
+    ragMinScore: Number(process.env.ATENDENTE_SUGESTAO_RAG_MIN_SCORE ?? process.env.ATENDIMENTOS_RAG_MIN_SCORE ?? 0.5),
+    ragMaxCharsPerHit: Number(process.env.ATENDENTE_SUGESTAO_RAG_MAX_CHARS_POR_HIT ?? 600)
   },
   whatsapp: {
     apiVersion: process.env.WHATSAPP_API_VERSION || "v21.0",

+ 45 - 0
src/services/atendenteSugestaoService.js

@@ -3,6 +3,47 @@ import { chatCompletion } from "./ollamaClient.js";
 import { WhatsappConversa } from "../models/WhatsappConversa.model.js";
 import { WhatsappMensagem } from "../models/WhatsappMensagem.model.js";
 import { NotFoundError } from "../shared/errors/index.js";
+import { buscarAtendimentosSemelhantes } from "./atendimentoRagService.js";
+
+const RAG_MIN_QUERY_LENGTH = 8;
+
+function formatarContextoRag(hits, maxCharsPorHit) {
+    if (!hits?.length) return null;
+    const blocos = hits.map((h, i) => `Exemplo ${i + 1}:\n${h.text.slice(0, maxCharsPorHit)}`);
+    return [
+        "Referência: trechos de avaliações de atendimentos anteriores bem avaliados, com resumo",
+        "do problema, o que foi feito e o resultado. NÃO fazem parte da conversa atual — use",
+        "apenas se forem relevantes ao problema do cliente agora, como inspiração de",
+        "abordagem/solução; ignore se não tiverem relação.",
+        "",
+        blocos.join("\n\n---\n\n")
+    ].join("\n");
+}
+
+async function buscarContextoRag(queryTexto) {
+    if (!config.atendenteSugestao.ragEnabled) return null;
+    if (!queryTexto || queryTexto.trim().length < RAG_MIN_QUERY_LENGTH) return null;
+
+    try {
+        const hits = await buscarAtendimentosSemelhantes({
+            query: queryTexto,
+            topK: config.atendenteSugestao.ragTopK,
+            minScore: config.atendenteSugestao.ragMinScore,
+            hyde: false,
+            rerank: false,
+            filter: {
+                must: [
+                    { key: "metadata.qualidade", match: { value: "boa" } },
+                    { key: "metadata.tipo", match: { value: "avaliacao" } }
+                ]
+            }
+        });
+        return formatarContextoRag(hits, config.atendenteSugestao.ragMaxCharsPerHit);
+    } catch (err) {
+        console.warn("[atendenteSugestaoService] RAG indisponível, seguindo sem contexto extra:", err.message);
+        return null;
+    }
+}
 
 const SYSTEM_PROMPT = [
     "Você é um atendente de suporte da star internet, respondendo pelo WhatsApp.",
@@ -54,8 +95,12 @@ export async function gerarSugestaoResposta(conversaId) {
 
     const recorte = recortarHistorico(historico, config.atendenteSugestao.maxChars);
 
+    const ultimaMsgCliente = historico[historico.length - 1];
+    const contextoRagTexto = await buscarContextoRag(paraTextoLLM(ultimaMsgCliente));
+
     const messages = [
         { role: "system", content: SYSTEM_PROMPT },
+        ...(contextoRagTexto ? [{ role: "system", content: contextoRagTexto }] : []),
         ...recorte.map((m) => ({
             role: m.Direcao === "entrada" ? "user" : "assistant",
             content: paraTextoLLM(m)

+ 21 - 2
src/services/atendimentoAvaliacaoService.js

@@ -1,7 +1,8 @@
 import { config } from "../config/index.js";
 import { chatCompletion } from "./ollamaClient.js";
-import { buildConversaTexto, calcularUltimaMensagemId } from "../utils/atendimentoFormat.js";
+import { buildConversaTexto, calcularUltimaMensagemId, SCORE_RUIM_MAX } from "../utils/atendimentoFormat.js";
 import { buscarContextoIxc } from "./atendimentoIxcContextoService.js";
+import { ingestarAtendimento } from "./atendimentoRagService.js";
 import { Atendimento } from "../models/Atendimento.model.js";
 import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
@@ -28,6 +29,15 @@ const AVALIACAO_SYSTEM_PROMPT = [
   "julgue nem penalize pelo desfecho técnico registrado no ERP, que pode depender de",
   "fatores fora do controle do atendente do chat.",
   "",
+  'Mensagens marcadas como "[áudio sem transcrição disponível]" representam um áudio que o',
+  "sistema não conseguiu transcrever — o conteúdo é desconhecido, não silêncio nem falha de",
+  "resposta. Não penalize score_atendente por essas mensagens especificamente e não presuma",
+  "o que foi dito nelas, nem para o bem nem para o mal. Julgue normalmente com base no resto",
+  "da conversa; se o desfecho ficar ambíguo por depender do conteúdo desse áudio (ex.: não",
+  'dá para saber se a explicação resolveu o problema), prefira resolvido="indefinido" ou',
+  '"parcial" e sentimento_cliente_final="indefinido" em vez de presumir — e mencione essa',
+  "limitação de forma breve na justificativa_score e/ou no resumo quando for relevante.",
+  "",
   "Tempo de resposta NÃO é o critério principal da nota. Intervalos de minutos até cerca de uma hora",
   "entre mensagens são comuns na operação e, sozinhos, não devem derrubar o score — priorize sempre se",
   "o problema foi entendido, se a solução foi correta e clara, e se o cliente saiu satisfeito. Só deixe",
@@ -353,6 +363,15 @@ export async function avaliarAtendimento(atendimentoId) {
     TotalMensagens: mensagens.length
   });
 
+  // best-effort: a avaliação já foi persistida com sucesso: se o Qdrant estiver indisponível,
+  // não falha a avaliação — o pendentesQuery de atendimentoRagService (que também detecta
+  // avaliação desatualizada) cobre o caso no próximo ciclo do job de RAG.
+  try {
+    await ingestarAtendimento(atendimento.Id);
+  } catch (err) {
+    console.warn("[atendimentoAvaliacaoService] falha ao reindexar RAG após avaliação:", err.message);
+  }
+
   return { avaliado: true, avaliacao };
 }
 
@@ -560,7 +579,7 @@ export async function estatisticasAvaliacoes({ setor, resolvido, sentimento, sta
 // usado pelo chat de atendimentos (atendimentosQueryService.js) para responder perguntas
 // agregadas de qualidade — dedicado, em vez de reaproveitar estatisticasAvaliacoes (que
 // não filtra por status/período e mistura métricas do dashboard admin como visitas/pendentes).
-export const SCORE_RUIM_MAX = 5; // consistente com SCORE_MINIMO_RESOLVIDO_POSITIVO = 6 já existente
+export { SCORE_RUIM_MAX }; // consistente com SCORE_MINIMO_RESOLVIDO_POSITIVO = 6 já existente
 
 export async function agregarQualidade({ setor, status, dataInicio, dataFim } = {}) {
   const base = () => {

+ 64 - 23
src/services/atendimentoRagService.js

@@ -5,7 +5,7 @@ import { config } from "../config/index.js";
 import { Atendimento } from "../models/Atendimento.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
 import { AtendimentoRagIndex } from "../models/AtendimentoRagIndex.model.js";
-import { buildConversaTextoParaRag, calcularUltimaMensagemId } from "../utils/atendimentoFormat.js";
+import { buildConversaTextoParaRag, calcularUltimaMensagemId, classificarQualidadeAtendimento, buildBlocoAvaliacaoParaRag } from "../utils/atendimentoFormat.js";
 import { ingestDocuments } from "./ingestService.js";
 import { searchDocs } from "./searchService.js";
 import { generateHydeDocument } from "./hydeService.js";
@@ -47,47 +47,79 @@ async function upsertRagIndex(atendimentoId, dados) {
 export async function ingestarAtendimento(atendimentoId) {
   const atendimento = await Atendimento.query()
     .findById(atendimentoId)
-    .withGraphFetched("[cliente, mensagens]")
+    .withGraphFetched("[cliente, mensagens, avaliacao]")
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
 
   if (!atendimento) throw new NotFoundError("atendimento_not_found");
 
   const mensagens = atendimento.mensagens ?? [];
   const ultimaMensagemId = calcularUltimaMensagemId(mensagens);
+  const avaliacao = atendimento.avaliacao ?? null;
+  const avaliacaoUpdatedAt = avaliacao?.UpdatedAt ?? null;
   const conversaTexto = buildConversaTextoParaRag(
     { atendimento, cliente: atendimento.cliente, mensagens },
     { boilerplateSet }
   );
 
-  
   if (!conversaTexto) {
     await upsertRagIndex(atendimento.Id, {
       UltimaMensagemId: ultimaMensagemId,
+      AvaliacaoUpdatedAt: avaliacaoUpdatedAt,
       ChunksCount: 0,
       EmbeddingModel: null
     });
     return { ingestado: false, chunks: 0 };
   }
 
-  const { upserted } = await ingestDocuments(
-    [
-      {
-        id: atendimento.Id,
-        source: `atendimento:${atendimento.Codigo}`,
-        title: `Atendimento ${atendimento.Codigo}`,
-        metadata: { atendimentoId: atendimento.Id, codigo: atendimento.Codigo, setor: atendimento.Setor },
-        text: conversaTexto
-      }
-    ],
+  const qualidade = classificarQualidadeAtendimento({
+    scoreAtendente: avaliacao?.ScoreAtendente ?? null,
+    resolvido: avaliacao?.Resolvido ?? null,
+    sentimento: avaliacao?.Sentimento ?? null
+  });
+  const blocoAvaliacao = buildBlocoAvaliacaoParaRag(avaliacao);
+
+  const metadataBase = {
+    atendimentoId: atendimento.Id,
+    codigo: atendimento.Codigo,
+    setor: atendimento.Setor,
+    avaliado: Boolean(avaliacao?.Avaliavel),
+    qualidade,
+    scoreAtendente: avaliacao?.ScoreAtendente ?? null,
+    resolvido: avaliacao?.Resolvido ?? null,
+    sentimento: avaliacao?.Sentimento ?? null,
+    statusCancelamento: avaliacao?.StatusCancelamento ?? null
+  };
+
+  const documentos = [
     {
-      collectionName: config.atendimentosRag.collection,
-      chunkSize: config.atendimentosRag.chunkSize,
-      chunkOverlap: config.atendimentosRag.chunkOverlap
-    }
-  );
+      id: `${atendimento.Id}-conversa`,
+      source: `atendimento:${atendimento.Codigo}`,
+      title: `Atendimento ${atendimento.Codigo}`,
+      metadata: { ...metadataBase, tipo: "conversa" },
+      text: conversaTexto
+    },
+    ...(blocoAvaliacao
+      ? [
+          {
+            id: `${atendimento.Id}-avaliacao`,
+            source: `atendimento:${atendimento.Codigo}`,
+            title: `Atendimento ${atendimento.Codigo} — Avaliação`,
+            metadata: { ...metadataBase, tipo: "avaliacao" },
+            text: blocoAvaliacao
+          }
+        ]
+      : [])
+  ];
+
+  const { upserted } = await ingestDocuments(documentos, {
+    collectionName: config.atendimentosRag.collection,
+    chunkSize: config.atendimentosRag.chunkSize,
+    chunkOverlap: config.atendimentosRag.chunkOverlap
+  });
 
   await upsertRagIndex(atendimento.Id, {
     UltimaMensagemId: ultimaMensagemId,
+    AvaliacaoUpdatedAt: avaliacaoUpdatedAt,
     ChunksCount: upserted,
     EmbeddingModel: config.ollama.embeddingsModel
   });
@@ -109,10 +141,16 @@ function pendentesQuery({ setor } = {}) {
     .select("atendimentos.Id")
     .join(subMax, "m.AtendimentoId", "atendimentos.Id")
     .leftJoin("atendimento_rag_index as ri", "ri.AtendimentoId", "atendimentos.Id")
+    .leftJoin("atendimento_avaliacoes as av", "av.AtendimentoId", "atendimentos.Id")
     .where((q) => {
       q.whereNull("ri.AtendimentoId")
         .orWhereNull("ri.UltimaMensagemId")
-        .orWhereRaw("ri.UltimaMensagemId < m.MaxMsgId");
+        .orWhereRaw("ri.UltimaMensagemId < m.MaxMsgId")
+        .orWhere((q2) => {
+          q2.where("av.Avaliavel", true)
+            .whereNotNull("av.UpdatedAt")
+            .andWhere((q3) => q3.whereNull("ri.AvaliacaoUpdatedAt").orWhereRaw("ri.AvaliacaoUpdatedAt < av.UpdatedAt"));
+        });
     });
 
   if (setor) query.where("atendimentos.Setor", setor);
@@ -161,8 +199,10 @@ export async function buscarAtendimentosSemelhantes({
   topK = config.atendimentosRag.topK,
   minScore = config.atendimentosRag.minScore,
   hyde = config.atendimentosRag.hyde,
+  rerank = config.atendimentosRag.rerank,
   collectionName = config.atendimentosRag.collection,
-  embeddingsModel = config.ollama.embeddingsModel
+  embeddingsModel = config.ollama.embeddingsModel,
+  filter
 }) {
   let embeddingQuery = query;
   let embedRole = "query";
@@ -183,11 +223,12 @@ export async function buscarAtendimentosSemelhantes({
     minScore,
     collectionName,
     embedRole,
-    embeddingsModel
+    embeddingsModel,
+    filter
   });
 
-  
-  if (!config.atendimentosRag.rerank || hits.length <= 1) return hits;
+
+  if (!rerank || hits.length <= 1) return hits;
   return rerankHits(query, hits, {
     topN: config.atendimentosRag.rerankTopN,
     numCtx: config.llm.atendimentosNumCtx

+ 3 - 2
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, collectionName = config.qdrant.collection, embeddingsModel }) {
+export async function searchDocs({ query, topK, embedRole = "query", minScore, collectionName = config.qdrant.collection, embeddingsModel, filter }) {
   const [vector] = await embedTexts([query], { role: embedRole, model: embeddingsModel });
 
   let result;
@@ -12,7 +12,8 @@ export async function searchDocs({ query, topK, embedRole = "query", minScore, c
       limit: topK ?? config.rag.topK,
       score_threshold: minScore ?? config.rag.minScore,
       with_payload: true,
-      with_vector: false
+      with_vector: false,
+      ...(filter ? { filter } : {})
     });
   } catch (err) {
     const status = err?.status ?? err?.statusCode ?? err?.response?.status ?? null;

+ 48 - 1
src/utils/atendimentoFormat.js

@@ -3,6 +3,15 @@ import { estaForaExpediente } from "../config/expediente.js";
 
 const ATENDENTE_PREFIX_RE = /^\s*<b>\s*([^<:]+?)\s*:?\s*<\/b>\s*(?:<br\s*\/?>)?\s*/i;
 
+// sentinels de controle do ifbot em Transcricao — nunca são texto de fala real
+const TRANSCRICAO_SENTINELS = new Set(["__processando__", "__erro__"]);
+
+// tipos de mídia que são áudio (ifbot nunca transcreve ~97% deles — ver memória
+// cobertura_transcricao_audio_atendente); usados pra dar um placeholder autoexplicativo
+// em vez da sigla crua do codec, que juízes/LLMs não necessariamente reconhecem como áudio.
+export const AUDIO_MEDIA_TYPES = new Set(["mpga", "mp3", "audio", "oga"]);
+const PLACEHOLDER_AUDIO_SEM_TRANSCRICAO = "[áudio sem transcrição disponível]";
+
 function decodeHtmlEntities(str) {
   return str
     .replace(/&nbsp;/g, " ")
@@ -52,7 +61,8 @@ export function estruturarMensagem(m) {
   let autor = null;
   let texto;
 
-  const transcricao = typeof m.Transcricao === "string" ? m.Transcricao.trim() : "";
+  const transcricaoRaw = typeof m.Transcricao === "string" ? m.Transcricao.trim() : "";
+  const transcricao = TRANSCRICAO_SENTINELS.has(transcricaoRaw) ? "" : transcricaoRaw;
 
   if (m.Resposta === 1) {
     const parsed = parseAtendenteBody(m.Body);
@@ -74,6 +84,8 @@ export function estruturarMensagem(m) {
   
   if (transcricao) {
     texto = `[áudio] ${transcricao}`;
+  } else if (AUDIO_MEDIA_TYPES.has(m.Tipodemidia)) {
+    texto = PLACEHOLDER_AUDIO_SEM_TRANSCRICAO;
   } else if (m.Tipodemidia && m.Tipodemidia !== "text" && m.Tipodemidia !== "ifstatus") {
     // Body de mídia sem transcrição é só o caminho do arquivo (ex.: /data/aud/x.mp3), não texto
     // real — sempre usa o placeholder aqui, em vez de só quando o stripHtml sair vazio.
@@ -176,3 +188,38 @@ export function buildConversaTextoParaRag({ atendimento, cliente, mensagens }, {
 export function calcularUltimaMensagemId(mensagens) {
   return mensagens.reduce((max, m) => (Number(m.Id) > max ? Number(m.Id) : max), 0) || null;
 }
+
+export const SCORE_RUIM_MAX = 5;
+export const SCORE_BOM_MIN = 8;
+
+// null = não avaliável/sem score; "boa" | "ruim" | "neutra" caso contrário.
+// Critério de "boa" consistente com o usado em scripts/exportarDatasetTreinamento.js
+// (exemplos usados no fine-tuning do star-atendente).
+export function classificarQualidadeAtendimento({ scoreAtendente, resolvido, sentimento }) {
+  if (scoreAtendente === null || scoreAtendente === undefined) return null;
+  if (scoreAtendente >= SCORE_BOM_MIN && ["sim", "parcial"].includes(resolvido) && sentimento !== "negativo") {
+    return "boa";
+  }
+  if (scoreAtendente <= SCORE_RUIM_MAX) return "ruim";
+  return "neutra";
+}
+
+// Bloco textual curto e autocontido com o resultado da avaliação por IA — pensado para
+// virar seu próprio chunk no RAG (denso em sinal, independente do tamanho da conversa).
+export function buildBlocoAvaliacaoParaRag(avaliacao) {
+  if (!avaliacao?.Avaliavel) return null;
+  const nota = avaliacao.ScoreAtendente != null ? `${avaliacao.ScoreAtendente}/10` : "não aplicável";
+  return [
+    "=== Avaliação deste atendimento (feita por IA) ===",
+    `Nota do atendente: ${nota}`,
+    avaliacao.JustificativaScore ? `Justificativa: ${avaliacao.JustificativaScore}` : null,
+    `Resolvido: ${avaliacao.Resolvido} — Sentimento do cliente: ${avaliacao.Sentimento}`,
+    avaliacao.Resumo ? `Resumo: ${avaliacao.Resumo}` : null,
+    avaliacao.Sugestoes?.length ? `Sugestões de melhoria apontadas pela IA: ${avaliacao.Sugestoes.join("; ")}` : null,
+    avaliacao.Atendentes?.length ? `Atendentes envolvidos: ${avaliacao.Atendentes.join(", ")}` : null,
+    avaliacao.StatusCancelamento && avaliacao.StatusCancelamento !== "nao"
+      ? `Cancelamento: ${avaliacao.StatusCancelamento}${avaliacao.MotivoCancelamento ? ` — Motivo: ${avaliacao.MotivoCancelamento}` : ""}`
+      : null,
+    "=== fim da avaliação ==="
+  ].filter(Boolean).join("\n");
+}