feat(secretaria): RAG sem pgvector no conhecimento (embeddings + cosseno)

Adiciona busca semântica na Base de Conhecimento da secretária:
- embeddings.ts: chunking, embed (OpenAI text-embedding-3-small ou Gemini
  text-embedding-004) e similaridade cosseno — tudo na aplicação, sem pgvector.
- tabela sec_knowledge_chunks (vetor em JSON/text) com índices.
- brain.ts: no nó 'knowledge', injeta só os top-4 trechos relevantes à pergunta
  do usuário; indexação lazy por hash do conteúdo. Fallback para o conteúdo
  inteiro se não houver chave de embedding ou em qualquer falha (não regride).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Deploy Agent
2026-06-28 07:08:42 +02:00
parent 36405a46f0
commit 31bf45faf5
6 changed files with 368 additions and 7 deletions
@@ -11,6 +11,7 @@
Object.defineProperty(exports, "__esModule", { value: true });
exports.ProtocolEngine = void 0;
const tools_1 = require("./tools");
const embeddings_1 = require("./embeddings");
class ProtocolEngine {
constructor(db, config) {
this.db = db;
@@ -25,7 +26,7 @@ class ProtocolEngine {
if (!agent)
throw new Error('Agente não encontrado');
// Monta system prompt a partir dos nós ativos + contexto externo
const systemPrompt = await this.buildSystemPrompt(agent, conversation, opts);
const systemPrompt = await this.buildSystemPrompt(agent, conversation, opts, userMessage);
// Carrega apenas as últimas N mensagens (não o histórico completo)
const contextWindow = agent.context_window ?? 8;
const recentMessages = await this.db('sec_messages')
@@ -121,7 +122,7 @@ class ProtocolEngine {
return `${p(now.getDate())}${p(now.getMonth() + 1)}${String(now.getFullYear()).slice(-2)}${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
}
// ── System Prompt Builder ─────────────────────────────────────────────────
async buildSystemPrompt(agent, conversation, opts) {
async buildSystemPrompt(agent, conversation, opts, userMessage) {
const nodes = await this.db('sec_brain_nodes')
.where({ agent_id: agent.id, active: true })
.orderBy('sort_order');
@@ -148,9 +149,12 @@ class ProtocolEngine {
case 'persona':
prompt += `${node.content}\n\n`;
break;
case 'knowledge':
prompt += `=== BASE DE CONHECIMENTO ===\n${node.content}\n\n`;
case 'knowledge': {
// RAG: injeta só os trechos relevantes à pergunta (fallback = tudo).
const kb = await this.knowledgeContext(node, userMessage);
prompt += `=== BASE DE CONHECIMENTO ===\n${kb}\n\n`;
break;
}
case 'rules':
prompt += `=== REGRAS ===\n${node.content}\n\n`;
break;
@@ -181,6 +185,77 @@ class ProtocolEngine {
}
return prompt.trim();
}
// ── RAG: contexto de conhecimento por similaridade (sem pgvector) ──────────
/**
* Retorna apenas os trechos do conhecimento relevantes à pergunta do usuário,
* via embeddings + cosseno. Cai no conteúdo INTEIRO (comportamento anterior)
* se não houver chave de embedding, se a indexação/embedding falhar, ou se
* não houver chunks — ou seja, nunca piora o que já funcionava.
*/
async knowledgeContext(node, userMessage) {
if (!userMessage?.trim())
return node.content;
let cfg;
try {
cfg = await this.config.get('secretaria');
}
catch {
return node.content;
}
if (!(0, embeddings_1.hasEmbeddingKey)(cfg))
return node.content;
try {
await this.ensureKnowledgeIndexed(node, cfg);
const qVec = await (0, embeddings_1.embed)(userMessage, cfg);
if (!qVec)
return node.content;
const chunks = await this.db('sec_knowledge_chunks').where({ node_id: node.id });
if (!chunks.length)
return node.content;
const ranked = chunks
.map((c) => {
let v = [];
try {
v = JSON.parse(c.embedding);
}
catch {
v = [];
}
return { content: c.content, score: (0, embeddings_1.cosineSimilarity)(qVec, v) };
})
.sort((a, b) => b.score - a.score)
.slice(0, 4)
.filter((r) => r.score > 0);
return ranked.length ? ranked.map((r) => r.content).join('\n\n') : node.content;
}
catch {
return node.content;
}
}
/** Reindexa os chunks do nó quando o conteúdo muda (detecção por hash MD5). */
async ensureKnowledgeIndexed(node, cfg) {
const hash = (0, embeddings_1.hashText)(node.content ?? '');
const existing = await this.db('sec_knowledge_chunks').where({ node_id: node.id }).first();
if (existing && existing.content_hash === hash)
return;
await this.db('sec_knowledge_chunks').where({ node_id: node.id }).del();
const chunks = (0, embeddings_1.chunkText)(node.content ?? '');
let idx = 0;
for (const ch of chunks) {
const vec = await (0, embeddings_1.embed)(ch, cfg);
if (!vec)
continue;
await this.db('sec_knowledge_chunks').insert({
id: this.uuid(),
agent_id: node.agent_id,
node_id: node.id,
content_hash: hash,
chunk_index: idx++,
content: ch,
embedding: JSON.stringify(vec),
});
}
}
// ── Finalize Protocol ─────────────────────────────────────────────────────
async finalizeProtocol(conversationId) {
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first();