Переглянути джерело

ajustes para trazer dados dos atendimentos.

leonardo 2 місяців тому
батько
коміт
955a1a0cc8

+ 97 - 21
src/chat/chatChain.js

@@ -9,7 +9,16 @@ const SYSTEM_PROMPT = [
   "Quando o CONTEXTO abaixo cobrir a pergunta, use-o e cite a fonte entre parênteses.",
   "Para perguntas gerais sem relação com documentos internos, responda normalmente com seu conhecimento.",
   "Nunca invente dados específicos da empresa (processos, valores, nomes) que não estejam no CONTEXTO."
-  
+
+].join("\n");
+
+const ATENDIMENTOS_SYSTEM_PROMPT = [
+  "Você é o Oráculo, assistente interno da Star, no modo de consulta a atendimentos de suporte ao cliente.",
+  "Responda em português brasileiro de forma natural, clara e objetiva.",
+  "O CONTEXTO abaixo contém trechos de conversas reais entre atendentes e clientes, extraídos do sistema de atendimento (ifbot); cada trecho traz o código do protocolo, setor, cliente e data.",
+  "Use o CONTEXTO para responder o que foi dito, reclamado, resolvido ou combinado em atendimentos específicos, citando o código do protocolo entre parênteses.",
+  "Nunca invente falas, nomes de clientes ou desfechos que não estejam no CONTEXTO.",
+  "Se a pergunta pedir estatística agregada (contagens, totais, rankings) que não dá para responder apenas lendo trechos de conversas, avise que esse tipo de análise não é suportado neste modo."
 ].join("\n");
 
 function buildContextBlock(hits) {
@@ -30,12 +39,18 @@ const EMPTY_CONTEXT_GUARD = [
   "Perguntas gerais podem ser respondidas normalmente."
 ].join("\n");
 
-function buildMessages(message, context, history = []) {
+const ATENDIMENTOS_EMPTY_CONTEXT_GUARD = [
+  "Nenhuma conversa de atendimento foi encontrada para esta pergunta.",
+  "Informe que não há registros correspondentes na base de atendimentos indexada e não invente.",
+  "Sugira refinar por setor, período ou código de protocolo, se fizer sentido."
+].join("\n");
+
+function buildMessages(message, context, history = [], modeConfig = MODES.documentos) {
   return [
-    { role: "system", content: SYSTEM_PROMPT },
+    { role: "system", content: modeConfig.systemPrompt },
     ...(context
       ? [{ role: "system", content: `CONTEXTO:\n${context}` }]
-      : [{ role: "system", content: EMPTY_CONTEXT_GUARD }]),
+      : [{ role: "system", content: modeConfig.emptyContextGuard }]),
     ...history.map((m) => ({ role: m.Role.toLowerCase(), content: m.Content })),
     { role: "user", content: message }
   ];
@@ -169,11 +184,20 @@ const HYDE_SYSTEM_PROMPT = [
   "Responda em português brasileiro."
 ].join("\n");
 
-export async function generateHydeDocument(query) {
+const ATENDIMENTOS_HYDE_SYSTEM_PROMPT = [
+  "Você simula um trecho plausível de conversa real entre um atendente de suporte e um cliente,",
+  "extraído de um sistema de atendimento via WhatsApp. Dada a pergunta abaixo, escreva um parágrafo",
+  "curto (3 a 6 frases) no estilo de uma troca de mensagens entre cliente e atendente, mencionando",
+  "o tipo de problema, sintomas ou solução relacionados à pergunta. Não inclua a pergunta,",
+  "saudações genéricas ou ressalvas de incerteza — o texto só será usado para busca por",
+  "similaridade, nunca mostrado ao usuário. Responda em português brasileiro."
+].join("\n");
+
+export async function generateHydeDocument(query, hydeSystemPrompt = HYDE_SYSTEM_PROMPT) {
   try {
     const { content } = await chatCompletion({
       messages: [
-        { role: "system", content: HYDE_SYSTEM_PROMPT },
+        { role: "system", content: hydeSystemPrompt },
         { role: "user", content: query }
       ],
       options: { temperature: 0.2 }
@@ -216,12 +240,12 @@ function shouldSkipHyde(originalMessage, searchQuery) {
   return false;
 }
 
-async function resolveEmbeddingQuery(searchQuery, originalMessage) {
+async function resolveEmbeddingQuery(searchQuery, originalMessage, hydeSystemPrompt = HYDE_SYSTEM_PROMPT) {
   if (!config.rag.hyde) return { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: false };
   if (shouldSkipHyde(originalMessage, searchQuery)) {
     return { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: true };
   }
-  const hydeText = await generateHydeDocument(searchQuery);
+  const hydeText = await generateHydeDocument(searchQuery, hydeSystemPrompt);
   return hydeText
     ? { embeddingQuery: hydeText, embedRole: "passage", hydeSkipped: false }
     : { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: false };
@@ -239,6 +263,18 @@ const REWRITE_HYDE_SYSTEM_PROMPT = [
   "Não inclua explicações, texto extra ou markdown fora do JSON."
 ].join(" ");
 
+const ATENDIMENTOS_REWRITE_HYDE_SYSTEM_PROMPT = [
+  'Você é um motor de busca interno. Responda APENAS com um objeto JSON no formato',
+  '{"rewrittenQuery": "...", "hydeDocument": "..."}.',
+  '"rewrittenQuery": reescreva a ÚLTIMA pergunta do usuário como uma query autocontida e específica',
+  "para busca em conversas de atendimento ao cliente, em linguagem natural, resolvendo",
+  "pronomes/referências via HISTÓRICO DA CONVERSA quando houver.",
+  '"hydeDocument": um parágrafo curto (3 a 6 frases) simulando um trecho plausível de conversa real',
+  "entre atendente e cliente de suporte (estilo WhatsApp) relacionado a essa pergunta, plausível mesmo",
+  "sem certeza, pois será usado apenas para busca por similaridade, nunca mostrado ao usuário.",
+  "Responda em português brasileiro. Não inclua explicações, texto extra ou markdown fora do JSON."
+].join(" ");
+
 function parseRewriteHydeResult(content, originalQuery) {
   try {
     const cleaned = String(content ?? "")
@@ -260,11 +296,11 @@ function parseRewriteHydeResult(content, originalQuery) {
   }
 }
 
-async function rewriteAndHyde(query, history) {
+async function rewriteAndHyde(query, history, rewriteHydeSystemPrompt = REWRITE_HYDE_SYSTEM_PROMPT) {
   try {
     const hasHistory = history.length > 0;
     const messages = [
-      { role: "system", content: REWRITE_HYDE_SYSTEM_PROMPT },
+      { role: "system", content: rewriteHydeSystemPrompt },
       ...(hasHistory
         ? [{ role: "system", content: `HISTÓRICO DA CONVERSA:\n${history.map((m) => `${m.Role}: ${m.Content}`).join("\n")}` }]
         : []),
@@ -280,11 +316,11 @@ async function rewriteAndHyde(query, history) {
   }
 }
 
-async function resolveSearchAndEmbedding(message, history, timer) {
+async function resolveSearchAndEmbedding(message, history, timer, modeConfig = MODES.documentos) {
   const bothEnabled = config.rag.queryRewrite && config.rag.hyde;
 
   if (bothEnabled) {
-    const { rewrittenQuery, hydeDocument } = await rewriteAndHyde(message, history);
+    const { rewrittenQuery, hydeDocument } = await rewriteAndHyde(message, history, modeConfig.rewriteHydeSystemPrompt);
     timer?.mark("rewrite_hyde");
     const skip = shouldSkipHyde(message, rewrittenQuery) || !hydeDocument;
     if (skip) {
@@ -296,11 +332,39 @@ async function resolveSearchAndEmbedding(message, history, timer) {
 
   const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message, history) : message;
   timer?.mark("rewrite");
-  const { embeddingQuery, embedRole, hydeSkipped } = await resolveEmbeddingQuery(searchQuery, message);
+  const { embeddingQuery, embedRole, hydeSkipped } = await resolveEmbeddingQuery(
+    searchQuery,
+    message,
+    modeConfig.hydeSystemPrompt
+  );
   timer?.mark("hyde", hydeSkipped ? "skipped" : undefined);
   return { searchQuery, embeddingQuery, embedRole, hydeSkipped };
 }
 
+const ATENDIMENTOS_FILTER = { must: [{ key: "metadata.type", match: { value: "atendimento" } }] };
+const DOCUMENTOS_FILTER = { must_not: [{ key: "metadata.type", match: { value: "atendimento" } }] };
+
+const MODES = {
+  documentos: {
+    systemPrompt: SYSTEM_PROMPT,
+    emptyContextGuard: EMPTY_CONTEXT_GUARD,
+    hydeSystemPrompt: HYDE_SYSTEM_PROMPT,
+    rewriteHydeSystemPrompt: REWRITE_HYDE_SYSTEM_PROMPT,
+    filter: DOCUMENTOS_FILTER
+  },
+  atendimentos: {
+    systemPrompt: ATENDIMENTOS_SYSTEM_PROMPT,
+    emptyContextGuard: ATENDIMENTOS_EMPTY_CONTEXT_GUARD,
+    hydeSystemPrompt: ATENDIMENTOS_HYDE_SYSTEM_PROMPT,
+    rewriteHydeSystemPrompt: ATENDIMENTOS_REWRITE_HYDE_SYSTEM_PROMPT,
+    filter: ATENDIMENTOS_FILTER
+  }
+};
+
+function resolveMode(mode) {
+  return MODES[mode] ?? MODES.documentos;
+}
+
 function createTimer() {
   const t0 = Date.now();
   let last = t0;
@@ -318,19 +382,20 @@ function createTimer() {
   };
 }
 
-export async function answerWithContext({ message, conversationId, userId, options }) {
+export async function answerWithContext({ message, conversationId, userId, options, mode }) {
+  const modeConfig = resolveMode(mode);
   const timer = createTimer();
   const history = await loadHistory(conversationId, userId);
   timer.mark("history");
-  const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer);
-  let hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
+  const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer, modeConfig);
+  let hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole, filter: modeConfig.filter });
   timer.mark("search");
   if (config.rag.rerank && hits.length > 1) {
     hits = await rerankHits(searchQuery, hits);
     timer.mark("rerank");
   }
   const context = buildContextBlock(hits);
-  const messages = buildMessages(message, context, history);
+  const messages = buildMessages(message, context, history, modeConfig);
   const completion = await chatCompletion({ messages, options: buildDefaultOptions(options) });
   timer.mark("llm");
   timer.log(`conversationId=${conversationId ?? "-"}`);
@@ -341,13 +406,24 @@ export async function answerWithContext({ message, conversationId, userId, optio
   };
 }
 
-export async function answerWithContextStream({ message, conversationId, userId, options, onChunk, onSources, onStatus, signal }) {
+export async function answerWithContextStream({
+  message,
+  conversationId,
+  userId,
+  options,
+  mode,
+  onChunk,
+  onSources,
+  onStatus,
+  signal
+}) {
+  const modeConfig = resolveMode(mode);
   const timer = createTimer();
   onStatus?.("buscando");
   const history = await loadHistory(conversationId, userId);
   timer.mark("history");
-  const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer);
-  let hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
+  const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer, modeConfig);
+  let hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole, filter: modeConfig.filter });
   timer.mark("search");
   onStatus?.("encontrou", hits.length);
 
@@ -360,7 +436,7 @@ export async function answerWithContextStream({ message, conversationId, userId,
   const sources = hitsToSources(hits);
   onSources?.(sources);
   const context = buildContextBlock(hits);
-  const messages = buildMessages(message, context, history);
+  const messages = buildMessages(message, context, history, modeConfig);
   onStatus?.("gerando");
   const completion = await chatCompletionStream({ messages, onChunk, signal, options: buildDefaultOptions(options) });
   timer.mark("llm");

+ 3 - 1
src/controllers/Chat.Controller.js

@@ -22,7 +22,8 @@ export const ChatController = {
         message: body.message,
         conversationId: body.conversationId,
         userId: req.userId,
-        options: body.options
+        options: body.options,
+        mode: body.mode
       });
 
       await tryPersistMessages(body.conversationId, req.userId, body.message, result);
@@ -51,6 +52,7 @@ export const ChatController = {
         conversationId: body.conversationId,
         userId: req.userId,
         options: body.options,
+        mode: body.mode,
         signal: abortController.signal,
         onStatus: (stage, count) => {
           const payload = count !== undefined ? { type: "status", stage, count } : { type: "status", stage };

+ 1 - 0
src/middleware/schemas/Chat.Schema.js

@@ -5,6 +5,7 @@ export const chatBodySchema = z.object({
   conversationId: z.coerce.number().int().positive().optional(),
   sessionId: z.string().min(1).optional(),
   model: z.string().min(1).optional(),
+  mode: z.enum(["documentos", "atendimentos"]).optional(),
   options: z
     .object({
       temperature: z.number().min(0).max(2).optional(),

+ 123 - 0
src/services/atendimentoRagService.js

@@ -0,0 +1,123 @@
+import { ingestDocuments } from "./ingestService.js";
+
+// mensagens do ifbot: Resposta 0 = cliente, 1 = atendente, 9 = sistema.
+// o campo Autor normalmente vem null; o nome do atendente é embutido no próprio
+// Body como HTML: "<b>Nome:</b><br/>texto da mensagem".
+const ATENDENTE_PREFIX_RE = /^\s*<b>\s*([^<:]+?)\s*:?\s*<\/b>\s*(?:<br\s*\/?>)?\s*/i;
+
+function decodeHtmlEntities(str) {
+  return str
+    .replace(/&nbsp;/g, " ")
+    .replace(/&amp;/g, "&")
+    .replace(/&lt;/g, "<")
+    .replace(/&gt;/g, ">")
+    .replace(/&quot;/g, '"')
+    .replace(/&#39;/g, "'");
+}
+
+function stripHtml(html) {
+  if (!html) return "";
+  return decodeHtmlEntities(
+    String(html)
+      .replace(/<br\s*\/?>/gi, "\n")
+      .replace(/<\/p>/gi, "\n")
+      .replace(/<[^>]+>/g, "")
+  )
+    .replace(/[ \t]+/g, " ")
+    .replace(/\n{2,}/g, "\n")
+    .trim();
+}
+
+function parseAtendenteBody(bodyHtml) {
+  const raw = String(bodyHtml ?? "");
+  const match = ATENDENTE_PREFIX_RE.exec(raw);
+  if (!match) return { nome: null, texto: stripHtml(raw) };
+  return { nome: match[1].trim(), texto: stripHtml(raw.slice(match[0].length)) };
+}
+
+function formatDateTime(value) {
+  if (!value) return null;
+  const d = value instanceof Date ? value : new Date(value);
+  if (Number.isNaN(d.getTime())) return null;
+  return new Intl.DateTimeFormat("pt-BR", {
+    timeZone: "America/Sao_Paulo",
+    day: "2-digit",
+    month: "2-digit",
+    year: "numeric",
+    hour: "2-digit",
+    minute: "2-digit"
+  }).format(d);
+}
+
+function formatMensagem(m, clienteNome) {
+  let label;
+  let texto;
+
+  if (m.Resposta === 1) {
+    const parsed = parseAtendenteBody(m.Body);
+    label = parsed.nome ? `Atendente (${parsed.nome})` : "Atendente";
+    texto = parsed.texto;
+  } else if (m.Resposta === 0) {
+    label = clienteNome ? `Cliente (${clienteNome})` : "Cliente";
+    texto = stripHtml(m.Body);
+  } else if (m.Resposta === 9) {
+    label = "Sistema";
+    texto = stripHtml(m.Body);
+  } else {
+    label = "Desconhecido";
+    texto = stripHtml(m.Body);
+  }
+
+  if (!texto && m.Tipodemidia && m.Tipodemidia !== "text") {
+    texto = `[mídia: ${m.Tipodemidia}]`;
+  }
+  if (!texto) return null;
+
+  const timestamp = formatDateTime(m.Timestamp);
+  return timestamp ? `[${timestamp}] ${label}: ${texto}` : `${label}: ${texto}`;
+}
+
+export function buildAtendimentoDocument({ atendimento, cliente, mensagens }) {
+  const ordenadas = [...mensagens].sort((a, b) => {
+    const ta = a.Timestamp ? new Date(a.Timestamp).getTime() : 0;
+    const tb = b.Timestamp ? new Date(b.Timestamp).getTime() : 0;
+    return ta - tb || (a.Id ?? 0) - (b.Id ?? 0);
+  });
+
+  const cabecalho = [
+    `Atendimento ${atendimento.Codigo}` +
+      (atendimento.Setor ? ` — Setor: ${atendimento.Setor}` : "") +
+      (atendimento.Status !== null && atendimento.Status !== undefined ? ` — Status: ${atendimento.Status}` : ""),
+    `Cliente: ${cliente?.Nome ?? "desconhecido"}${cliente?.Telefone ? ` (Tel: ${cliente.Telefone})` : ""}`,
+    atendimento.Abertura ? `Aberto em: ${formatDateTime(atendimento.Abertura)}` : null
+  ].filter(Boolean);
+
+  const linhasMensagens = ordenadas.map((m) => formatMensagem(m, cliente?.Nome)).filter(Boolean);
+
+  const text = [...cabecalho, "", ...linhasMensagens].join("\n").trim();
+
+  return {
+    id: `atendimento:${atendimento.Codigo}`,
+    text,
+    source: `atendimento:${atendimento.Codigo}`,
+    title: `Atendimento ${atendimento.Codigo} — ${cliente?.Nome ?? "cliente desconhecido"}`,
+    metadata: {
+      type: "atendimento",
+      codigo: atendimento.Codigo,
+      protocoloId: atendimento.Id,
+      setor: atendimento.Setor ?? null,
+      status: atendimento.Status ?? null,
+      clienteId: cliente?.Id ?? null,
+      clienteNome: cliente?.Nome ?? null,
+      abertura: atendimento.Abertura ? new Date(atendimento.Abertura).toISOString() : null,
+      totalMensagens: linhasMensagens.length
+    }
+  };
+}
+
+export async function indexAtendimento({ atendimento, cliente, mensagens }) {
+  if (!mensagens?.length) return { upserted: 0 };
+  const doc = buildAtendimentoDocument({ atendimento, cliente, mensagens });
+  if (!doc.text.trim()) return { upserted: 0 };
+  return ingestDocuments([doc]);
+}

+ 11 - 0
src/services/atendimentosService.js

@@ -9,6 +9,7 @@ import {
   protocoloMensagensSchema
 } from "../middleware/schemas/Atendimento.Schema.js";
 import { assertPublicHttpsUrl } from "./ingestService.js";
+import { indexAtendimento } from "./atendimentoRagService.js";
 import { BadRequestError } from "../shared/errors/index.js";
 
 const MAX_REDIRECTS = 5;
@@ -153,6 +154,16 @@ export async function salvarAtendimento({ payload, setor, sourceUrl, abertura, t
 
     await trx.commit();
 
+    try {
+      await indexAtendimento({
+        atendimento: { Id: p.Id, Codigo: p.Codigo, Status: p.Status, Setor: setor ?? null, Abertura: toDate(abertura) },
+        cliente: { ...c, Telefone: clienteTelefone },
+        mensagens
+      });
+    } catch (err) {
+      console.error(`[atendimentos] falha ao indexar RAG (${p.Codigo}):`, err.message);
+    }
+
     return {
       protocoloId: p.Id,
       codigo: p.Codigo,

+ 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 }) {
+export async function searchDocs({ query, topK, embedRole = "query", minScore, filter }) {
   const collectionName = config.qdrant.collection;
   const [vector] = await embedTexts([query], { role: embedRole });
 
@@ -13,7 +13,8 @@ export async function searchDocs({ query, topK, embedRole = "query", minScore })
       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;