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.",
   "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 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.",
   "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.",
   "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.",
   "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;",
   "(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: {
   atendenteSugestao: {
     model: process.env.OLLAMA_ATENDENTE_MODEL ?? "star-atendente",
     model: process.env.OLLAMA_ATENDENTE_MODEL ?? "star-atendente",
     historyLimit: Number(process.env.ATENDENTE_SUGESTAO_HISTORY_LIMIT ?? 20),
     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: {
   whatsapp: {
     apiVersion: process.env.WHATSAPP_API_VERSION || "v21.0",
     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 { WhatsappConversa } from "../models/WhatsappConversa.model.js";
 import { WhatsappMensagem } from "../models/WhatsappMensagem.model.js";
 import { WhatsappMensagem } from "../models/WhatsappMensagem.model.js";
 import { NotFoundError } from "../shared/errors/index.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 = [
 const SYSTEM_PROMPT = [
     "Você é um atendente de suporte da star internet, respondendo pelo WhatsApp.",
     "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 recorte = recortarHistorico(historico, config.atendenteSugestao.maxChars);
 
 
+    const ultimaMsgCliente = historico[historico.length - 1];
+    const contextoRagTexto = await buscarContextoRag(paraTextoLLM(ultimaMsgCliente));
+
     const messages = [
     const messages = [
         { role: "system", content: SYSTEM_PROMPT },
         { role: "system", content: SYSTEM_PROMPT },
+        ...(contextoRagTexto ? [{ role: "system", content: contextoRagTexto }] : []),
         ...recorte.map((m) => ({
         ...recorte.map((m) => ({
             role: m.Direcao === "entrada" ? "user" : "assistant",
             role: m.Direcao === "entrada" ? "user" : "assistant",
             content: paraTextoLLM(m)
             content: paraTextoLLM(m)

+ 21 - 2
src/services/atendimentoAvaliacaoService.js

@@ -1,7 +1,8 @@
 import { config } from "../config/index.js";
 import { config } from "../config/index.js";
 import { chatCompletion } from "./ollamaClient.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 { buscarContextoIxc } from "./atendimentoIxcContextoService.js";
+import { ingestarAtendimento } from "./atendimentoRagService.js";
 import { Atendimento } from "../models/Atendimento.model.js";
 import { Atendimento } from "../models/Atendimento.model.js";
 import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
 import { AtendimentoAvaliacao } from "../models/AtendimentoAvaliacao.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.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",
   "julgue nem penalize pelo desfecho técnico registrado no ERP, que pode depender de",
   "fatores fora do controle do atendente do chat.",
   "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",
   "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",
   "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",
   "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
     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 };
   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
 // usado pelo chat de atendimentos (atendimentosQueryService.js) para responder perguntas
 // agregadas de qualidade — dedicado, em vez de reaproveitar estatisticasAvaliacoes (que
 // 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).
 // 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 } = {}) {
 export async function agregarQualidade({ setor, status, dataInicio, dataFim } = {}) {
   const base = () => {
   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 { Atendimento } from "../models/Atendimento.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
 import { AtendimentoMensagem } from "../models/AtendimentoMensagem.model.js";
 import { AtendimentoRagIndex } from "../models/AtendimentoRagIndex.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 { ingestDocuments } from "./ingestService.js";
 import { searchDocs } from "./searchService.js";
 import { searchDocs } from "./searchService.js";
 import { generateHydeDocument } from "./hydeService.js";
 import { generateHydeDocument } from "./hydeService.js";
@@ -47,47 +47,79 @@ async function upsertRagIndex(atendimentoId, dados) {
 export async function ingestarAtendimento(atendimentoId) {
 export async function ingestarAtendimento(atendimentoId) {
   const atendimento = await Atendimento.query()
   const atendimento = await Atendimento.query()
     .findById(atendimentoId)
     .findById(atendimentoId)
-    .withGraphFetched("[cliente, mensagens]")
+    .withGraphFetched("[cliente, mensagens, avaliacao]")
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
     .modifyGraph("mensagens", (q) => q.orderBy("Timestamp", "asc").orderBy("Id", "asc"));
 
 
   if (!atendimento) throw new NotFoundError("atendimento_not_found");
   if (!atendimento) throw new NotFoundError("atendimento_not_found");
 
 
   const mensagens = atendimento.mensagens ?? [];
   const mensagens = atendimento.mensagens ?? [];
   const ultimaMensagemId = calcularUltimaMensagemId(mensagens);
   const ultimaMensagemId = calcularUltimaMensagemId(mensagens);
+  const avaliacao = atendimento.avaliacao ?? null;
+  const avaliacaoUpdatedAt = avaliacao?.UpdatedAt ?? null;
   const conversaTexto = buildConversaTextoParaRag(
   const conversaTexto = buildConversaTextoParaRag(
     { atendimento, cliente: atendimento.cliente, mensagens },
     { atendimento, cliente: atendimento.cliente, mensagens },
     { boilerplateSet }
     { boilerplateSet }
   );
   );
 
 
-  
   if (!conversaTexto) {
   if (!conversaTexto) {
     await upsertRagIndex(atendimento.Id, {
     await upsertRagIndex(atendimento.Id, {
       UltimaMensagemId: ultimaMensagemId,
       UltimaMensagemId: ultimaMensagemId,
+      AvaliacaoUpdatedAt: avaliacaoUpdatedAt,
       ChunksCount: 0,
       ChunksCount: 0,
       EmbeddingModel: null
       EmbeddingModel: null
     });
     });
     return { ingestado: false, chunks: 0 };
     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, {
   await upsertRagIndex(atendimento.Id, {
     UltimaMensagemId: ultimaMensagemId,
     UltimaMensagemId: ultimaMensagemId,
+    AvaliacaoUpdatedAt: avaliacaoUpdatedAt,
     ChunksCount: upserted,
     ChunksCount: upserted,
     EmbeddingModel: config.ollama.embeddingsModel
     EmbeddingModel: config.ollama.embeddingsModel
   });
   });
@@ -109,10 +141,16 @@ function pendentesQuery({ setor } = {}) {
     .select("atendimentos.Id")
     .select("atendimentos.Id")
     .join(subMax, "m.AtendimentoId", "atendimentos.Id")
     .join(subMax, "m.AtendimentoId", "atendimentos.Id")
     .leftJoin("atendimento_rag_index as ri", "ri.AtendimentoId", "atendimentos.Id")
     .leftJoin("atendimento_rag_index as ri", "ri.AtendimentoId", "atendimentos.Id")
+    .leftJoin("atendimento_avaliacoes as av", "av.AtendimentoId", "atendimentos.Id")
     .where((q) => {
     .where((q) => {
       q.whereNull("ri.AtendimentoId")
       q.whereNull("ri.AtendimentoId")
         .orWhereNull("ri.UltimaMensagemId")
         .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);
   if (setor) query.where("atendimentos.Setor", setor);
@@ -161,8 +199,10 @@ export async function buscarAtendimentosSemelhantes({
   topK = config.atendimentosRag.topK,
   topK = config.atendimentosRag.topK,
   minScore = config.atendimentosRag.minScore,
   minScore = config.atendimentosRag.minScore,
   hyde = config.atendimentosRag.hyde,
   hyde = config.atendimentosRag.hyde,
+  rerank = config.atendimentosRag.rerank,
   collectionName = config.atendimentosRag.collection,
   collectionName = config.atendimentosRag.collection,
-  embeddingsModel = config.ollama.embeddingsModel
+  embeddingsModel = config.ollama.embeddingsModel,
+  filter
 }) {
 }) {
   let embeddingQuery = query;
   let embeddingQuery = query;
   let embedRole = "query";
   let embedRole = "query";
@@ -183,11 +223,12 @@ export async function buscarAtendimentosSemelhantes({
     minScore,
     minScore,
     collectionName,
     collectionName,
     embedRole,
     embedRole,
-    embeddingsModel
+    embeddingsModel,
+    filter
   });
   });
 
 
-  
-  if (!config.atendimentosRag.rerank || hits.length <= 1) return hits;
+
+  if (!rerank || hits.length <= 1) return hits;
   return rerankHits(query, hits, {
   return rerankHits(query, hits, {
     topN: config.atendimentosRag.rerankTopN,
     topN: config.atendimentosRag.rerankTopN,
     numCtx: config.llm.atendimentosNumCtx
     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 { embedTexts } from "./ollamaClient.js";
 import { qdrant } from "./qdrantClient.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 });
   const [vector] = await embedTexts([query], { role: embedRole, model: embeddingsModel });
 
 
   let result;
   let result;
@@ -12,7 +12,8 @@ export async function searchDocs({ query, topK, embedRole = "query", minScore, c
       limit: topK ?? config.rag.topK,
       limit: topK ?? config.rag.topK,
       score_threshold: minScore ?? config.rag.minScore,
       score_threshold: minScore ?? config.rag.minScore,
       with_payload: true,
       with_payload: true,
-      with_vector: false
+      with_vector: false,
+      ...(filter ? { filter } : {})
     });
     });
   } catch (err) {
   } catch (err) {
     const status = err?.status ?? err?.statusCode ?? err?.response?.status ?? null;
     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;
 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) {
 function decodeHtmlEntities(str) {
   return str
   return str
     .replace(/&nbsp;/g, " ")
     .replace(/&nbsp;/g, " ")
@@ -52,7 +61,8 @@ export function estruturarMensagem(m) {
   let autor = null;
   let autor = null;
   let texto;
   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) {
   if (m.Resposta === 1) {
     const parsed = parseAtendenteBody(m.Body);
     const parsed = parseAtendenteBody(m.Body);
@@ -74,6 +84,8 @@ export function estruturarMensagem(m) {
   
   
   if (transcricao) {
   if (transcricao) {
     texto = `[áudio] ${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") {
   } 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
     // 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.
     // 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) {
 export function calcularUltimaMensagemId(mensagens) {
   return mensagens.reduce((max, m) => (Number(m.Id) > max ? Number(m.Id) : max), 0) || null;
   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");
+}