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
@@ -12,6 +12,7 @@ import { Knex } from 'knex'
import type { PluginConfigStore } from '../../backend/src/core/plugin-config'
import type { HookBus } from '../../backend/src/core/hook-bus'
import { type ToolDef, type ToolContext, resolveTools, ALL_TOOL_NAMES } from './tools'
import { embed, cosineSimilarity, chunkText, hashText, hasEmbeddingKey } from './embeddings'
export class ProtocolEngine {
constructor(
@@ -39,7 +40,7 @@ export 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: number = agent.context_window ?? 8
@@ -154,6 +155,7 @@ export class ProtocolEngine {
agent: any,
conversation: any,
opts?: { contextData?: Record<string, unknown>; systemExtra?: string },
userMessage?: string,
): Promise<string> {
const nodes = await this.db('sec_brain_nodes')
.where({ agent_id: agent.id, active: true })
@@ -185,9 +187,12 @@ export 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
@@ -223,6 +228,63 @@ export 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.
*/
private async knowledgeContext(node: any, userMessage?: string): Promise<string> {
if (!userMessage?.trim()) return node.content
let cfg: any
try { cfg = await this.config.get('secretaria') } catch { return node.content }
if (!hasEmbeddingKey(cfg)) return node.content
try {
await this.ensureKnowledgeIndexed(node, cfg)
const qVec = await 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: any) => {
let v: number[] = []
try { v = JSON.parse(c.embedding) } catch { v = [] }
return { content: c.content, score: cosineSimilarity(qVec, v) }
})
.sort((a: any, b: any) => b.score - a.score)
.slice(0, 4)
.filter((r: any) => r.score > 0)
return ranked.length ? ranked.map((r: any) => 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). */
private async ensureKnowledgeIndexed(node: any, cfg: any): Promise<void> {
const hash = 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 = chunkText(node.content ?? '')
let idx = 0
for (const ch of chunks) {
const vec = await 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: string): Promise<{ summary: string; protocol_number: string }> {