|
|
@@ -51,21 +51,137 @@ function hitsToSources(hits) {
|
|
|
}));
|
|
|
}
|
|
|
|
|
|
-async function rewriteQuery(query) {
|
|
|
+const REWRITE_SYSTEM_PROMPT = [
|
|
|
+ "Você é um motor de busca interno. Reescreva a pergunta abaixo de forma mais específica e completa",
|
|
|
+ "para busca em documentos internos de empresa, sempre em linguagem natural (nunca em código, SQL,",
|
|
|
+ "fórmulas ou pseudocódigo). Responda APENAS com a query reescrita, em uma frase, sem",
|
|
|
+ "explicações, sem aspas ou formatação extra."
|
|
|
+].join(" ");
|
|
|
+
|
|
|
+const REWRITE_SYSTEM_PROMPT_WITH_HISTORY = [
|
|
|
+ "Você é um motor de busca interno. Sua tarefa é transformar a ÚLTIMA pergunta do usuário",
|
|
|
+ "em uma query de busca autocontida e específica para buscar documentos internos da empresa,",
|
|
|
+ "sempre em linguagem natural (nunca em código, SQL, fórmulas ou pseudocódigo).",
|
|
|
+ "Use o HISTÓRICO DA CONVERSA abaixo apenas para resolver pronomes, referências e continuidade",
|
|
|
+ "de assunto (ex.: 'e o segundo caso?', 'isso', 'e sobre X?').",
|
|
|
+ "Responda APENAS com a query reescrita, em uma frase, sem explicações, sem aspas."
|
|
|
+].join("\n");
|
|
|
+
|
|
|
+export async function rewriteQuery(query, history = []) {
|
|
|
+ try {
|
|
|
+ const hasHistory = history.length > 0;
|
|
|
+ const messages = hasHistory
|
|
|
+ ? [
|
|
|
+ { role: "system", content: REWRITE_SYSTEM_PROMPT_WITH_HISTORY },
|
|
|
+ {
|
|
|
+ role: "system",
|
|
|
+ content: `HISTÓRICO DA CONVERSA:\n${history.map((m) => `${m.Role}: ${m.Content}`).join("\n")}`
|
|
|
+ },
|
|
|
+ { role: "user", content: query }
|
|
|
+ ]
|
|
|
+ : [
|
|
|
+ { role: "system", content: REWRITE_SYSTEM_PROMPT },
|
|
|
+ { role: "user", content: query }
|
|
|
+ ];
|
|
|
+ const { content } = await chatCompletion({ messages, options: { temperature: 0.1 } });
|
|
|
+ return content?.trim() || query;
|
|
|
+ } catch (err) {
|
|
|
+ console.warn("[chatChain] rewriteQuery falhou, usando query original:", err.message);
|
|
|
+ return query;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const RERANK_SYSTEM_PROMPT = [
|
|
|
+ "Você reordena trechos de documentos internos por relevância à pergunta do usuário.",
|
|
|
+ 'Responda APENAS com um objeto JSON no formato {"order": [...]}, contendo TODOS os índices',
|
|
|
+ '(0-based) de cada trecho, em ordem decrescente de relevância. Exemplo: {"order": [2,0,3,1]}.',
|
|
|
+ "Não inclua explicações, texto extra ou markdown."
|
|
|
+].join("\n");
|
|
|
+
|
|
|
+const RERANK_SNIPPET_LENGTH = 300;
|
|
|
+
|
|
|
+function buildRerankPrompt(query, hits) {
|
|
|
+ const listing = hits
|
|
|
+ .map((h, i) => `[${i}] ${h.text.slice(0, RERANK_SNIPPET_LENGTH)}`)
|
|
|
+ .join("\n\n");
|
|
|
+ return `Pergunta: ${query}\n\nTrechos:\n${listing}`;
|
|
|
+}
|
|
|
+
|
|
|
+function parseRerankOrder(content, len) {
|
|
|
+ try {
|
|
|
+ const cleaned = String(content ?? "")
|
|
|
+ .trim()
|
|
|
+ .replace(/^```json\s*/i, "")
|
|
|
+ .replace(/^```\s*/, "")
|
|
|
+ .replace(/```$/, "")
|
|
|
+ .trim();
|
|
|
+ const parsed = JSON.parse(cleaned);
|
|
|
+ const arr = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.order) ? parsed.order : null;
|
|
|
+ if (!arr) return null;
|
|
|
+
|
|
|
+ const seen = new Set();
|
|
|
+ const valid = [];
|
|
|
+ for (const v of arr) {
|
|
|
+ const idx = Number(v);
|
|
|
+ if (Number.isInteger(idx) && idx >= 0 && idx < len && !seen.has(idx)) {
|
|
|
+ seen.add(idx);
|
|
|
+ valid.push(idx);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (valid.length === 0) return null;
|
|
|
+ for (let i = 0; i < len; i++) if (!seen.has(i)) valid.push(i);
|
|
|
+ return valid;
|
|
|
+ } catch {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+async function rerankHits(query, hits) {
|
|
|
+ if (hits.length <= 1) return hits;
|
|
|
+ try {
|
|
|
+ const { content } = await chatCompletion({
|
|
|
+ messages: [
|
|
|
+ { role: "system", content: RERANK_SYSTEM_PROMPT },
|
|
|
+ { role: "user", content: buildRerankPrompt(query, hits) }
|
|
|
+ ],
|
|
|
+ format: "json",
|
|
|
+ options: { temperature: 0.1 }
|
|
|
+ });
|
|
|
+ const order = parseRerankOrder(content, hits.length);
|
|
|
+ if (!order) console.warn("[chatChain] rerankHits: resposta não parseável, mantendo ordem original");
|
|
|
+ const reordered = order ? order.map((i) => hits[i]) : hits;
|
|
|
+ return reordered.slice(0, config.rag.rerankTopN);
|
|
|
+ } catch (err) {
|
|
|
+ console.warn("[chatChain] rerankHits falhou, mantendo ordem original:", err.message);
|
|
|
+ return hits.slice(0, config.rag.rerankTopN);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const HYDE_SYSTEM_PROMPT = [
|
|
|
+ "Você é um redator técnico que escreve trechos de manuais internos de redes e",
|
|
|
+ "equipamentos (comandos Huawei, configuração de roteadores TP-Link, processos e",
|
|
|
+ "políticas internas da empresa, etc.). Dada a pergunta abaixo, escreva um parágrafo",
|
|
|
+ "curto (3 a 6 frases) no estilo de um trecho real de documentação técnica que",
|
|
|
+ "responda a essa pergunta — nomes de comandos, telas, campos, passos, quando fizer",
|
|
|
+ "sentido. Não inclua a pergunta, saudações ou ressalvas de incerteza. Se não tiver",
|
|
|
+ "certeza do conteúdo exato, escreva de forma plausível no mesmo estilo, pois o texto",
|
|
|
+ "será usado apenas para busca por similaridade, nunca mostrado ao usuário.",
|
|
|
+ "Responda em português brasileiro."
|
|
|
+].join("\n");
|
|
|
+
|
|
|
+export async function generateHydeDocument(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: "system", content: HYDE_SYSTEM_PROMPT },
|
|
|
{ role: "user", content: query }
|
|
|
- ]
|
|
|
+ ],
|
|
|
+ options: { temperature: 0.2 }
|
|
|
});
|
|
|
- return content?.trim() || query;
|
|
|
- } catch {
|
|
|
- return query;
|
|
|
+ return content?.trim() || null;
|
|
|
+ } catch (err) {
|
|
|
+ console.warn("[chatChain] generateHydeDocument falhou, usando query normal para embedding:", err.message);
|
|
|
+ return null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -73,7 +189,8 @@ async function loadHistory(conversationId, userId) {
|
|
|
if (!conversationId) return [];
|
|
|
try {
|
|
|
return await getRecentMessages(conversationId, userId, config.rag.historyLimit);
|
|
|
- } catch {
|
|
|
+ } catch (err) {
|
|
|
+ console.warn("[chatChain] loadHistory falhou, seguindo sem histórico:", err.message);
|
|
|
return [];
|
|
|
}
|
|
|
}
|
|
|
@@ -90,12 +207,20 @@ function buildDefaultOptions(override) {
|
|
|
: defaults;
|
|
|
}
|
|
|
|
|
|
+async function resolveEmbeddingQuery(searchQuery) {
|
|
|
+ if (!config.rag.hyde) return { embeddingQuery: searchQuery, embedRole: "query" };
|
|
|
+ const hydeText = await generateHydeDocument(searchQuery);
|
|
|
+ return hydeText
|
|
|
+ ? { embeddingQuery: hydeText, embedRole: "passage" }
|
|
|
+ : { embeddingQuery: searchQuery, embedRole: "query" };
|
|
|
+}
|
|
|
+
|
|
|
export async function answerWithContext({ message, conversationId, userId, options }) {
|
|
|
- const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message) : message;
|
|
|
- const [hits, history] = await Promise.all([
|
|
|
- searchDocs({ query: searchQuery, topK: config.rag.topK }),
|
|
|
- loadHistory(conversationId, userId)
|
|
|
- ]);
|
|
|
+ const history = await loadHistory(conversationId, userId);
|
|
|
+ const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message, history) : message;
|
|
|
+ const { embeddingQuery, embedRole } = await resolveEmbeddingQuery(searchQuery);
|
|
|
+ let hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
|
|
|
+ if (config.rag.rerank && hits.length > 1) hits = await rerankHits(searchQuery, hits);
|
|
|
const context = buildContextBlock(hits);
|
|
|
const messages = buildMessages(message, context, history);
|
|
|
const completion = await chatCompletion({ messages, options: buildDefaultOptions(options) });
|
|
|
@@ -107,13 +232,19 @@ export async function answerWithContext({ message, conversationId, userId, optio
|
|
|
}
|
|
|
|
|
|
export async function answerWithContextStream({ message, conversationId, userId, options, onChunk, onSources, onStatus, signal }) {
|
|
|
- const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message) : message;
|
|
|
onStatus?.("buscando");
|
|
|
- const [hits, history] = await Promise.all([
|
|
|
- searchDocs({ query: searchQuery, topK: config.rag.topK }),
|
|
|
- loadHistory(conversationId, userId)
|
|
|
- ]);
|
|
|
+ const history = await loadHistory(conversationId, userId);
|
|
|
+ const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message, history) : message;
|
|
|
+ if (config.rag.hyde) onStatus?.("gerando_hipotese");
|
|
|
+ const { embeddingQuery, embedRole } = await resolveEmbeddingQuery(searchQuery);
|
|
|
+ let hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
|
|
|
onStatus?.("encontrou", hits.length);
|
|
|
+
|
|
|
+ if (config.rag.rerank && hits.length > 1) {
|
|
|
+ onStatus?.("reordenando");
|
|
|
+ hits = await rerankHits(searchQuery, hits);
|
|
|
+ }
|
|
|
+
|
|
|
const sources = hitsToSources(hits);
|
|
|
onSources?.(sources);
|
|
|
const context = buildContextBlock(hits);
|