leonardo 3 miesięcy temu
rodzic
commit
ffbb02f1ee

+ 57 - 18
chat/chatChain.js

@@ -1,7 +1,22 @@
 import { config } from "../src/config/index.js";
 import { chatCompletion, chatCompletionStream } from "../src/services/ollamaClient.js";
+import { getRecentMessages } from "../src/services/conversationsService.js";
 import { searchDocs } from "../chat/searchChat.js";
 
+const SYSTEM_PROMPT = [
+  "Você é um assistente de atendimento interno da empresa Star.",
+  "Seu papel é responder perguntas dos colaboradores com base nos documentos internos fornecidos no CONTEXTO.",
+  "",
+  "Regras:",
+  "- Responda sempre em português brasileiro.",
+  "- Baseie-se exclusivamente no CONTEXTO quando ele estiver disponível.",
+  '- Se a informação não estiver no CONTEXTO, diga claramente: "Não encontrei essa informação na base de conhecimento."',
+  "- Nunca invente informações ou suponha dados que não estejam no CONTEXTO.",
+  "- Seja direto, objetivo e use bullet points quando a resposta tiver múltiplos itens.",
+  "- Se a pergunta for vaga, peça esclarecimento antes de responder.",
+  "- Ao citar informações, mencione a fonte (nome do documento) quando disponível."
+].join("\n");
+
 function buildContextBlock(hits) {
   const lines = [];
   hits.forEach((h, i) => {
@@ -13,18 +28,11 @@ function buildContextBlock(hits) {
   return lines.join("\n").trim();
 }
 
-function buildMessages(message, context, sessionId) {
-  const system = [
-    "Você é um assistente de atendimento interno de uma empresa.",
-    "Responda apenas com base no CONTEXTO quando ele estiver disponível.",
-    "Se o CONTEXTO não tiver a informação, diga que não encontrou na base da empresa e sugira o que solicitar ao time responsável.",
-    "Seja direto e em português."
-  ].join("\n");
-
+function buildMessages(message, context, history = []) {
   return [
-    { role: "system", content: system },
+    { role: "system", content: SYSTEM_PROMPT },
     ...(context ? [{ role: "system", content: `CONTEXTO:\n${context}` }] : []),
-    ...(sessionId ? [{ role: "system", content: `session_id:${sessionId}` }] : []),
+    ...history.map((m) => ({ role: m.Role.toLowerCase(), content: m.Content })),
     { role: "user", content: message }
   ];
 }
@@ -39,11 +47,40 @@ function hitsToSources(hits) {
   }));
 }
 
-export async function answerWithContext({ message, sessionId }) {
-  const hits = await searchDocs({ query: message, topK: config.rag.topK });
+async function rewriteQuery(query) {
+  try {
+    const { content } = await chatCompletion({
+      messages: [
+        {
+          role: "system",
+          content:
+            "Você é um motor de busca interno. Reescreva a pergunta abaixo de forma mais específica e completa para busca em documentos internos de empresa. Responda APENAS com a query reescrita, sem explicações ou formatação extra."
+        },
+        { role: "user", content: query }
+      ]
+    });
+    return content?.trim() || query;
+  } catch {
+    return query;
+  }
+}
+
+async function loadHistory(conversationId) {
+  if (!conversationId) return [];
+  try {
+    return await getRecentMessages(conversationId, 12);
+  } catch {
+    return [];
+  }
+}
+
+export async function answerWithContext({ message, conversationId, options }) {
+  const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message) : message;
+  const hits = await searchDocs({ query: searchQuery, topK: config.rag.topK });
   const context = buildContextBlock(hits);
-  const messages = buildMessages(message, context, sessionId);
-  const completion = await chatCompletion({ messages });
+  const history = await loadHistory(conversationId);
+  const messages = buildMessages(message, context, history);
+  const completion = await chatCompletion({ messages, options });
 
   return {
     answer: completion.content,
@@ -51,13 +88,15 @@ export async function answerWithContext({ message, sessionId }) {
   };
 }
 
-export async function answerWithContextStream({ message, sessionId, onChunk, signal }) {
-  const hits = await searchDocs({ query: message, topK: config.rag.topK });
+export async function answerWithContextStream({ message, conversationId, options, onChunk, signal }) {
+  const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message) : message;
+  const hits = await searchDocs({ query: searchQuery, topK: config.rag.topK });
   const context = buildContextBlock(hits);
-  const messages = buildMessages(message, context, sessionId);
+  const history = await loadHistory(conversationId);
+  const messages = buildMessages(message, context, history);
   const sources = hitsToSources(hits);
 
-  const completion = await chatCompletionStream({ messages, onChunk, signal });
+  const completion = await chatCompletionStream({ messages, onChunk, signal, options });
 
   return {
     answer: completion.content,

+ 1 - 0
chat/searchChat.js

@@ -17,6 +17,7 @@ export async function searchDocs({ query, topK }) {
   const result = await qdrant.search(collectionName, {
     vector,
     limit: topK ?? config.rag.topK,
+    score_threshold: config.rag.minScore,
     with_payload: true,
     with_vector: false
   });

+ 3 - 1
src/config/index.js

@@ -28,7 +28,9 @@ export const config = {
   },
   rag: {
     topK: Number(process.env.RAG_TOP_K ?? 6),
+    minScore: Number(process.env.RAG_MIN_SCORE ?? 0.45),
     chunkSize: Number(process.env.RAG_CHUNK_SIZE ?? 900),
-    chunkOverlap: Number(process.env.RAG_CHUNK_OVERLAP ?? 150)
+    chunkOverlap: Number(process.env.RAG_CHUNK_OVERLAP ?? 150),
+    queryRewrite: process.env.RAG_QUERY_REWRITE === "true"
   }
 };

+ 4 - 2
src/routes/chat.js

@@ -23,7 +23,8 @@ chatRouter.post("/", async (req, res, next) => {
     const body = chatBodySchema.parse(req.body);
     const result = await answerWithContext({
       message: body.message,
-      sessionId: body.sessionId ?? (body.conversationId ? String(body.conversationId) : undefined)
+      conversationId: body.conversationId,
+      options: body.options
     });
 
     const userId = req.user?.sub;
@@ -60,7 +61,8 @@ chatRouter.post("/stream", async (req, res, next) => {
 
     const result = await answerWithContextStream({
       message: body.message,
-      sessionId: body.sessionId ?? (body.conversationId ? String(body.conversationId) : undefined),
+      conversationId: body.conversationId,
+      options: body.options,
       signal: abortController.signal,
       onChunk: (delta) => {
         res.write(`data: ${JSON.stringify({ type: "delta", delta })}\n\n`);

+ 9 - 0
src/services/conversationsService.js

@@ -27,6 +27,15 @@ export async function getConversationMessages(conversationId, userId) {
   return msgs;
 }
 
+export async function getRecentMessages(conversationId, limit = 12) {
+  const msgs = await db("messages")
+    .where({ ConversationId: conversationId })
+    .orderBy("SentAt", "desc")
+    .limit(limit)
+    .select("Role", "Content");
+  return msgs.reverse();
+}
+
 export async function addMessage(conversationId, { role, content, sources = null }) {
   await db("messages").insert({
     ConversationId: conversationId,

+ 10 - 4
src/services/ollamaClient.js

@@ -53,11 +53,12 @@ export async function embedTexts(texts) {
   return results;
 }
 
-export async function chatCompletion({ messages }) {
+export async function chatCompletion({ messages, options }) {
   const data = await ollamaFetch("/api/chat", {
     model: config.ollama.chatModel,
     messages,
-    stream: false
+    stream: false,
+    ...(options && Object.keys(options).length > 0 && { options })
   });
 
   return {
@@ -65,11 +66,16 @@ export async function chatCompletion({ messages }) {
   };
 }
 
-export async function chatCompletionStream({ messages, onChunk, signal }) {
+export async function chatCompletionStream({ messages, onChunk, signal, options }) {
   const res = await fetch(`${config.ollama.url}/api/chat`, {
     method: "POST",
     headers: { "content-type": "application/json" },
-    body: JSON.stringify({ model: config.ollama.chatModel, messages, stream: true }),
+    body: JSON.stringify({
+      model: config.ollama.chatModel,
+      messages,
+      stream: true,
+      ...(options && Object.keys(options).length > 0 && { options })
+    }),
     signal
   });
 

+ 57 - 10
src/services/textChunker.js

@@ -1,17 +1,64 @@
-export function chunkText(text, { chunkSize, chunkOverlap }) {
+function splitBySentence(text, chunkSize) {
+  const parts = text.split(/(?<=[.!?])\s+/);
+  const chunks = [];
+  let current = "";
+  for (const part of parts) {
+    if (!current) { current = part; continue; }
+    const candidate = current + " " + part;
+    if (candidate.length <= chunkSize) {
+      current = candidate;
+    } else {
+      if (current) chunks.push(current);
+      current = part;
+    }
+  }
+  if (current) chunks.push(current);
+  return chunks;
+}
+
+function getOverlapTail(text, overlapSize) {
+  if (text.length <= overlapSize) return text;
+  const tail = text.slice(-overlapSize * 2);
+  const match = tail.search(/[.!?]\s+\S/);
+  if (match !== -1) return tail.slice(match + 1).trim();
+  return text.slice(-overlapSize).trim();
+}
+
+export function chunkText(text, { chunkSize = 900, chunkOverlap = 150 }) {
   const normalized = String(text ?? "").replace(/\r\n/g, "\n").trim();
   if (!normalized) return [];
 
+  const paragraphs = normalized.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
   const chunks = [];
-  let start = 0;
-
-  while (start < normalized.length) {
-    const end = Math.min(start + chunkSize, normalized.length);
-    const chunk = normalized.slice(start, end).trim();
-    if (chunk) chunks.push(chunk);
-    if (end >= normalized.length) break;
-    start = Math.max(0, end - chunkOverlap);
+  let current = "";
+
+  for (const para of paragraphs) {
+    if (!current) {
+      current = para;
+      continue;
+    }
+
+    const candidate = current + "\n\n" + para;
+
+    if (candidate.length <= chunkSize) {
+      current = candidate;
+    } else {
+      chunks.push(current.trim());
+
+      if (para.length > chunkSize) {
+        const sentences = splitBySentence(para, chunkSize);
+        for (let i = 0; i < sentences.length - 1; i++) {
+          if (sentences[i].trim()) chunks.push(sentences[i].trim());
+        }
+        current = sentences[sentences.length - 1] ?? "";
+      } else {
+        const overlap = getOverlapTail(current, chunkOverlap);
+        current = overlap ? overlap + "\n\n" + para : para;
+      }
+    }
   }
 
-  return chunks;
+  if (current.trim()) chunks.push(current.trim());
+
+  return chunks.filter(Boolean);
 }