|
@@ -2,6 +2,7 @@ import { config } from "../config/index.js";
|
|
|
import { chatCompletion, chatCompletionStream } from "../services/ollamaClient.js";
|
|
import { chatCompletion, chatCompletionStream } from "../services/ollamaClient.js";
|
|
|
import { getRecentMessages } from "../services/conversationsService.js";
|
|
import { getRecentMessages } from "../services/conversationsService.js";
|
|
|
import { searchDocs } from "../services/searchService.js";
|
|
import { searchDocs } from "../services/searchService.js";
|
|
|
|
|
+import { resolverContextoAtendimentos } from "../services/atendimentosQueryService.js";
|
|
|
|
|
|
|
|
const SYSTEM_PROMPT = [
|
|
const SYSTEM_PROMPT = [
|
|
|
"Você é o Oráculo, assistente interno da Star.",
|
|
"Você é o Oráculo, assistente interno da Star.",
|
|
@@ -15,10 +16,12 @@ const SYSTEM_PROMPT = [
|
|
|
const ATENDIMENTOS_SYSTEM_PROMPT = [
|
|
const ATENDIMENTOS_SYSTEM_PROMPT = [
|
|
|
"Você é o Oráculo, assistente interno da Star, no modo de consulta a atendimentos de suporte ao cliente.",
|
|
"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.",
|
|
"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."
|
|
|
|
|
|
|
+ "O CONTEXTO abaixo vem diretamente do banco de dados interno de atendimentos (sem busca por similaridade) e pode ser de dois tipos:",
|
|
|
|
|
+ "(1) uma contagem/estatística já calculada (ex: quantidade de atendimentos por setor) — nesse caso, apenas apresente os números exatamente como estão, sem recalcular ou arredondar;",
|
|
|
|
|
+ "(2) trechos de conversas reais entre atendentes e clientes, cada um com código do protocolo, setor, cliente e data — use-os para responder o que foi dito, reclamado, resolvido ou combinado, citando o código do protocolo entre parênteses.",
|
|
|
|
|
+ "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.",
|
|
|
|
|
+ "Nunca invente números, falas, nomes de clientes, códigos de protocolo ou desfechos que não estejam literalmente no CONTEXTO."
|
|
|
].join("\n");
|
|
].join("\n");
|
|
|
|
|
|
|
|
function buildContextBlock(hits) {
|
|
function buildContextBlock(hits) {
|
|
@@ -184,20 +187,11 @@ const HYDE_SYSTEM_PROMPT = [
|
|
|
"Responda em português brasileiro."
|
|
"Responda em português brasileiro."
|
|
|
].join("\n");
|
|
].join("\n");
|
|
|
|
|
|
|
|
-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) {
|
|
|
|
|
|
|
+export async function generateHydeDocument(query) {
|
|
|
try {
|
|
try {
|
|
|
const { content } = await chatCompletion({
|
|
const { content } = await chatCompletion({
|
|
|
messages: [
|
|
messages: [
|
|
|
- { role: "system", content: hydeSystemPrompt },
|
|
|
|
|
|
|
+ { role: "system", content: HYDE_SYSTEM_PROMPT },
|
|
|
{ role: "user", content: query }
|
|
{ role: "user", content: query }
|
|
|
],
|
|
],
|
|
|
options: { temperature: 0.2 }
|
|
options: { temperature: 0.2 }
|
|
@@ -240,12 +234,12 @@ function shouldSkipHyde(originalMessage, searchQuery) {
|
|
|
return false;
|
|
return false;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function resolveEmbeddingQuery(searchQuery, originalMessage, hydeSystemPrompt = HYDE_SYSTEM_PROMPT) {
|
|
|
|
|
|
|
+async function resolveEmbeddingQuery(searchQuery, originalMessage) {
|
|
|
if (!config.rag.hyde) return { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: false };
|
|
if (!config.rag.hyde) return { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: false };
|
|
|
if (shouldSkipHyde(originalMessage, searchQuery)) {
|
|
if (shouldSkipHyde(originalMessage, searchQuery)) {
|
|
|
return { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: true };
|
|
return { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: true };
|
|
|
}
|
|
}
|
|
|
- const hydeText = await generateHydeDocument(searchQuery, hydeSystemPrompt);
|
|
|
|
|
|
|
+ const hydeText = await generateHydeDocument(searchQuery);
|
|
|
return hydeText
|
|
return hydeText
|
|
|
? { embeddingQuery: hydeText, embedRole: "passage", hydeSkipped: false }
|
|
? { embeddingQuery: hydeText, embedRole: "passage", hydeSkipped: false }
|
|
|
: { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: false };
|
|
: { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: false };
|
|
@@ -263,18 +257,6 @@ const REWRITE_HYDE_SYSTEM_PROMPT = [
|
|
|
"Não inclua explicações, texto extra ou markdown fora do JSON."
|
|
"Não inclua explicações, texto extra ou markdown fora do JSON."
|
|
|
].join(" ");
|
|
].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) {
|
|
function parseRewriteHydeResult(content, originalQuery) {
|
|
|
try {
|
|
try {
|
|
|
const cleaned = String(content ?? "")
|
|
const cleaned = String(content ?? "")
|
|
@@ -296,11 +278,11 @@ function parseRewriteHydeResult(content, originalQuery) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function rewriteAndHyde(query, history, rewriteHydeSystemPrompt = REWRITE_HYDE_SYSTEM_PROMPT) {
|
|
|
|
|
|
|
+async function rewriteAndHyde(query, history) {
|
|
|
try {
|
|
try {
|
|
|
const hasHistory = history.length > 0;
|
|
const hasHistory = history.length > 0;
|
|
|
const messages = [
|
|
const messages = [
|
|
|
- { role: "system", content: rewriteHydeSystemPrompt },
|
|
|
|
|
|
|
+ { role: "system", content: REWRITE_HYDE_SYSTEM_PROMPT },
|
|
|
...(hasHistory
|
|
...(hasHistory
|
|
|
? [{ role: "system", content: `HISTÓRICO DA CONVERSA:\n${history.map((m) => `${m.Role}: ${m.Content}`).join("\n")}` }]
|
|
? [{ role: "system", content: `HISTÓRICO DA CONVERSA:\n${history.map((m) => `${m.Role}: ${m.Content}`).join("\n")}` }]
|
|
|
: []),
|
|
: []),
|
|
@@ -316,11 +298,11 @@ async function rewriteAndHyde(query, history, rewriteHydeSystemPrompt = REWRITE_
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function resolveSearchAndEmbedding(message, history, timer, modeConfig = MODES.documentos) {
|
|
|
|
|
|
|
+async function resolveSearchAndEmbedding(message, history, timer) {
|
|
|
const bothEnabled = config.rag.queryRewrite && config.rag.hyde;
|
|
const bothEnabled = config.rag.queryRewrite && config.rag.hyde;
|
|
|
|
|
|
|
|
if (bothEnabled) {
|
|
if (bothEnabled) {
|
|
|
- const { rewrittenQuery, hydeDocument } = await rewriteAndHyde(message, history, modeConfig.rewriteHydeSystemPrompt);
|
|
|
|
|
|
|
+ const { rewrittenQuery, hydeDocument } = await rewriteAndHyde(message, history);
|
|
|
timer?.mark("rewrite_hyde");
|
|
timer?.mark("rewrite_hyde");
|
|
|
const skip = shouldSkipHyde(message, rewrittenQuery) || !hydeDocument;
|
|
const skip = shouldSkipHyde(message, rewrittenQuery) || !hydeDocument;
|
|
|
if (skip) {
|
|
if (skip) {
|
|
@@ -332,33 +314,14 @@ async function resolveSearchAndEmbedding(message, history, timer, modeConfig = M
|
|
|
|
|
|
|
|
const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message, history) : message;
|
|
const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message, history) : message;
|
|
|
timer?.mark("rewrite");
|
|
timer?.mark("rewrite");
|
|
|
- const { embeddingQuery, embedRole, hydeSkipped } = await resolveEmbeddingQuery(
|
|
|
|
|
- searchQuery,
|
|
|
|
|
- message,
|
|
|
|
|
- modeConfig.hydeSystemPrompt
|
|
|
|
|
- );
|
|
|
|
|
|
|
+ const { embeddingQuery, embedRole, hydeSkipped } = await resolveEmbeddingQuery(searchQuery, message);
|
|
|
timer?.mark("hyde", hydeSkipped ? "skipped" : undefined);
|
|
timer?.mark("hyde", hydeSkipped ? "skipped" : undefined);
|
|
|
return { searchQuery, embeddingQuery, embedRole, hydeSkipped };
|
|
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 = {
|
|
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
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ documentos: { systemPrompt: SYSTEM_PROMPT, emptyContextGuard: EMPTY_CONTEXT_GUARD },
|
|
|
|
|
+ atendimentos: { systemPrompt: ATENDIMENTOS_SYSTEM_PROMPT, emptyContextGuard: ATENDIMENTOS_EMPTY_CONTEXT_GUARD }
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
function resolveMode(mode) {
|
|
function resolveMode(mode) {
|
|
@@ -387,13 +350,21 @@ export async function answerWithContext({ message, conversationId, userId, optio
|
|
|
const timer = createTimer();
|
|
const timer = createTimer();
|
|
|
const history = await loadHistory(conversationId, userId);
|
|
const history = await loadHistory(conversationId, userId);
|
|
|
timer.mark("history");
|
|
timer.mark("history");
|
|
|
- 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");
|
|
|
|
|
|
|
+
|
|
|
|
|
+ let hits;
|
|
|
|
|
+ if (mode === "atendimentos") {
|
|
|
|
|
+ hits = await resolverContextoAtendimentos(message, history);
|
|
|
|
|
+ timer.mark("atendimentos_query");
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer);
|
|
|
|
|
+ hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
|
|
|
|
|
+ timer.mark("search");
|
|
|
|
|
+ if (config.rag.rerank && hits.length > 1) {
|
|
|
|
|
+ hits = await rerankHits(searchQuery, hits);
|
|
|
|
|
+ timer.mark("rerank");
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
const context = buildContextBlock(hits);
|
|
const context = buildContextBlock(hits);
|
|
|
const messages = buildMessages(message, context, history, modeConfig);
|
|
const messages = buildMessages(message, context, history, modeConfig);
|
|
|
const completion = await chatCompletion({ messages, options: buildDefaultOptions(options) });
|
|
const completion = await chatCompletion({ messages, options: buildDefaultOptions(options) });
|
|
@@ -422,15 +393,22 @@ export async function answerWithContextStream({
|
|
|
onStatus?.("buscando");
|
|
onStatus?.("buscando");
|
|
|
const history = await loadHistory(conversationId, userId);
|
|
const history = await loadHistory(conversationId, userId);
|
|
|
timer.mark("history");
|
|
timer.mark("history");
|
|
|
- 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);
|
|
|
|
|
-
|
|
|
|
|
- if (config.rag.rerank && hits.length > 1) {
|
|
|
|
|
- onStatus?.("reordenando");
|
|
|
|
|
- hits = await rerankHits(searchQuery, hits);
|
|
|
|
|
- timer.mark("rerank");
|
|
|
|
|
|
|
+
|
|
|
|
|
+ let hits;
|
|
|
|
|
+ if (mode === "atendimentos") {
|
|
|
|
|
+ hits = await resolverContextoAtendimentos(message, history);
|
|
|
|
|
+ timer.mark("atendimentos_query");
|
|
|
|
|
+ onStatus?.("encontrou", hits.length);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer);
|
|
|
|
|
+ hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
|
|
|
|
|
+ timer.mark("search");
|
|
|
|
|
+ onStatus?.("encontrou", hits.length);
|
|
|
|
|
+ if (config.rag.rerank && hits.length > 1) {
|
|
|
|
|
+ onStatus?.("reordenando");
|
|
|
|
|
+ hits = await rerankHits(searchQuery, hits);
|
|
|
|
|
+ timer.mark("rerank");
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const sources = hitsToSources(hits);
|
|
const sources = hitsToSources(hits);
|