|
@@ -9,7 +9,16 @@ const SYSTEM_PROMPT = [
|
|
|
"Quando o CONTEXTO abaixo cobrir a pergunta, use-o e cite a fonte entre parênteses.",
|
|
"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.",
|
|
"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."
|
|
"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");
|
|
].join("\n");
|
|
|
|
|
|
|
|
function buildContextBlock(hits) {
|
|
function buildContextBlock(hits) {
|
|
@@ -30,12 +39,18 @@ const EMPTY_CONTEXT_GUARD = [
|
|
|
"Perguntas gerais podem ser respondidas normalmente."
|
|
"Perguntas gerais podem ser respondidas normalmente."
|
|
|
].join("\n");
|
|
].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 [
|
|
return [
|
|
|
- { role: "system", content: SYSTEM_PROMPT },
|
|
|
|
|
|
|
+ { role: "system", content: modeConfig.systemPrompt },
|
|
|
...(context
|
|
...(context
|
|
|
? [{ role: "system", content: `CONTEXTO:\n${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 })),
|
|
...history.map((m) => ({ role: m.Role.toLowerCase(), content: m.Content })),
|
|
|
{ role: "user", content: message }
|
|
{ role: "user", content: message }
|
|
|
];
|
|
];
|
|
@@ -169,11 +184,20 @@ const HYDE_SYSTEM_PROMPT = [
|
|
|
"Responda em português brasileiro."
|
|
"Responda em português brasileiro."
|
|
|
].join("\n");
|
|
].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 {
|
|
try {
|
|
|
const { content } = await chatCompletion({
|
|
const { content } = await chatCompletion({
|
|
|
messages: [
|
|
messages: [
|
|
|
- { role: "system", content: HYDE_SYSTEM_PROMPT },
|
|
|
|
|
|
|
+ { role: "system", content: hydeSystemPrompt },
|
|
|
{ role: "user", content: query }
|
|
{ role: "user", content: query }
|
|
|
],
|
|
],
|
|
|
options: { temperature: 0.2 }
|
|
options: { temperature: 0.2 }
|
|
@@ -216,12 +240,12 @@ function shouldSkipHyde(originalMessage, searchQuery) {
|
|
|
return false;
|
|
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 (!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);
|
|
|
|
|
|
|
+ const hydeText = await generateHydeDocument(searchQuery, hydeSystemPrompt);
|
|
|
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 };
|
|
@@ -239,6 +263,18 @@ 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 ?? "")
|
|
@@ -260,11 +296,11 @@ function parseRewriteHydeResult(content, originalQuery) {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function rewriteAndHyde(query, history) {
|
|
|
|
|
|
|
+async function rewriteAndHyde(query, history, rewriteHydeSystemPrompt = REWRITE_HYDE_SYSTEM_PROMPT) {
|
|
|
try {
|
|
try {
|
|
|
const hasHistory = history.length > 0;
|
|
const hasHistory = history.length > 0;
|
|
|
const messages = [
|
|
const messages = [
|
|
|
- { role: "system", content: REWRITE_HYDE_SYSTEM_PROMPT },
|
|
|
|
|
|
|
+ { role: "system", content: rewriteHydeSystemPrompt },
|
|
|
...(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")}` }]
|
|
|
: []),
|
|
: []),
|
|
@@ -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;
|
|
const bothEnabled = config.rag.queryRewrite && config.rag.hyde;
|
|
|
|
|
|
|
|
if (bothEnabled) {
|
|
if (bothEnabled) {
|
|
|
- const { rewrittenQuery, hydeDocument } = await rewriteAndHyde(message, history);
|
|
|
|
|
|
|
+ const { rewrittenQuery, hydeDocument } = await rewriteAndHyde(message, history, modeConfig.rewriteHydeSystemPrompt);
|
|
|
timer?.mark("rewrite_hyde");
|
|
timer?.mark("rewrite_hyde");
|
|
|
const skip = shouldSkipHyde(message, rewrittenQuery) || !hydeDocument;
|
|
const skip = shouldSkipHyde(message, rewrittenQuery) || !hydeDocument;
|
|
|
if (skip) {
|
|
if (skip) {
|
|
@@ -296,11 +332,39 @@ async function resolveSearchAndEmbedding(message, history, timer) {
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
+ const { embeddingQuery, embedRole, hydeSkipped } = await resolveEmbeddingQuery(
|
|
|
|
|
+ searchQuery,
|
|
|
|
|
+ message,
|
|
|
|
|
+ modeConfig.hydeSystemPrompt
|
|
|
|
|
+ );
|
|
|
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 = {
|
|
|
|
|
+ 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() {
|
|
function createTimer() {
|
|
|
const t0 = Date.now();
|
|
const t0 = Date.now();
|
|
|
let last = t0;
|
|
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 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);
|
|
|
|
|
- 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");
|
|
timer.mark("search");
|
|
|
if (config.rag.rerank && hits.length > 1) {
|
|
if (config.rag.rerank && hits.length > 1) {
|
|
|
hits = await rerankHits(searchQuery, hits);
|
|
hits = await rerankHits(searchQuery, hits);
|
|
|
timer.mark("rerank");
|
|
timer.mark("rerank");
|
|
|
}
|
|
}
|
|
|
const context = buildContextBlock(hits);
|
|
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) });
|
|
const completion = await chatCompletion({ messages, options: buildDefaultOptions(options) });
|
|
|
timer.mark("llm");
|
|
timer.mark("llm");
|
|
|
timer.log(`conversationId=${conversationId ?? "-"}`);
|
|
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();
|
|
const timer = createTimer();
|
|
|
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);
|
|
|
|
|
- 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");
|
|
timer.mark("search");
|
|
|
onStatus?.("encontrou", hits.length);
|
|
onStatus?.("encontrou", hits.length);
|
|
|
|
|
|
|
@@ -360,7 +436,7 @@ export async function answerWithContextStream({ message, conversationId, userId,
|
|
|
const sources = hitsToSources(hits);
|
|
const sources = hitsToSources(hits);
|
|
|
onSources?.(sources);
|
|
onSources?.(sources);
|
|
|
const context = buildContextBlock(hits);
|
|
const context = buildContextBlock(hits);
|
|
|
- const messages = buildMessages(message, context, history);
|
|
|
|
|
|
|
+ const messages = buildMessages(message, context, history, modeConfig);
|
|
|
onStatus?.("gerando");
|
|
onStatus?.("gerando");
|
|
|
const completion = await chatCompletionStream({ messages, onChunk, signal, options: buildDefaultOptions(options) });
|
|
const completion = await chatCompletionStream({ messages, onChunk, signal, options: buildDefaultOptions(options) });
|
|
|
timer.mark("llm");
|
|
timer.mark("llm");
|