diff --git a/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.js b/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.js index 4696470..ead0601 100644 --- a/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.js +++ b/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.js @@ -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(); diff --git a/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.ts b/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.ts index 19fd69d..74b25f8 100644 --- a/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.ts +++ b/newwhats.clube67.com/newwhats.local/plugins/secretaria/brain.ts @@ -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; systemExtra?: string }, + userMessage?: string, ): Promise { 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 { + 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 { + 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 }> { diff --git a/newwhats.clube67.com/newwhats.local/plugins/secretaria/embeddings.js b/newwhats.clube67.com/newwhats.local/plugins/secretaria/embeddings.js new file mode 100644 index 0000000..6e09427 --- /dev/null +++ b/newwhats.clube67.com/newwhats.local/plugins/secretaria/embeddings.js @@ -0,0 +1,107 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.chunkText = chunkText; +exports.hashText = hashText; +exports.cosineSimilarity = cosineSimilarity; +exports.embed = embed; +exports.hasEmbeddingKey = hasEmbeddingKey; +/** + * RAG sem pgvector — embeddings + similaridade calculados na aplicação. + * + * Os vetores são gerados via OpenAI (text-embedding-3-small) ou Gemini + * (text-embedding-004), conforme a chave configurada no plugin, e guardados + * como JSON em coluna text. A busca por similaridade (cosseno) roda em Node. + * Para a base pequena da secretária (dezenas/centenas de chunks) é rápido e + * dispensa a extensão pgvector. + */ +const crypto_1 = __importDefault(require("crypto")); +/** Quebra o texto em chunks (~maxLen chars), preferindo limites de parágrafo. */ +function chunkText(text, maxLen = 600) { + const paras = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean); + const merged = []; + let buf = ''; + for (const p of paras) { + if (buf && (buf.length + 2 + p.length) > maxLen) { + merged.push(buf); + buf = p; + } + else { + buf = buf ? `${buf}\n\n${p}` : p; + } + } + if (buf) + merged.push(buf); + // Fatia parágrafos gigantes que excedam bastante o limite. + const out = []; + for (const c of merged) { + if (c.length <= maxLen * 1.5) { + out.push(c); + continue; + } + for (let i = 0; i < c.length; i += maxLen) + out.push(c.slice(i, i + maxLen)); + } + return out.filter(Boolean); +} +function hashText(text) { + return crypto_1.default.createHash('md5').update(text).digest('hex'); +} +function cosineSimilarity(a, b) { + const len = Math.min(a.length, b.length); + let dot = 0, na = 0, nb = 0; + for (let i = 0; i < len; i++) { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + if (!na || !nb) + return 0; + return dot / (Math.sqrt(na) * Math.sqrt(nb)); +} +/** + * Gera o embedding de um texto. Usa OpenAI se houver openai_key, senão Gemini. + * Retorna null em qualquer falha (o chamador deve cair no fallback). + */ +async function embed(text, cfg) { + const input = (text ?? '').slice(0, 8000); + if (!input.trim()) + return null; + const openaiKey = cfg?.openai_key; + const geminiKey = cfg?.gemini_key; + try { + if (openaiKey) { + const res = await fetch('https://api.openai.com/v1/embeddings', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${openaiKey}` }, + body: JSON.stringify({ model: 'text-embedding-3-small', input }), + }); + if (!res.ok) + return null; + const data = await res.json(); + return data?.data?.[0]?.embedding ?? null; + } + if (geminiKey) { + const url = `https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=${geminiKey}`; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'models/text-embedding-004', content: { parts: [{ text: input }] } }), + }); + if (!res.ok) + return null; + const data = await res.json(); + return data?.embedding?.values ?? null; + } + } + catch { + return null; + } + return null; +} +function hasEmbeddingKey(cfg) { + return !!(cfg?.openai_key || cfg?.gemini_key); +} +//# sourceMappingURL=embeddings.js.map \ No newline at end of file diff --git a/newwhats.clube67.com/newwhats.local/plugins/secretaria/embeddings.ts b/newwhats.clube67.com/newwhats.local/plugins/secretaria/embeddings.ts new file mode 100644 index 0000000..1f204d3 --- /dev/null +++ b/newwhats.clube67.com/newwhats.local/plugins/secretaria/embeddings.ts @@ -0,0 +1,82 @@ +/** + * RAG sem pgvector — embeddings + similaridade calculados na aplicação. + * + * Os vetores são gerados via OpenAI (text-embedding-3-small) ou Gemini + * (text-embedding-004), conforme a chave configurada no plugin, e guardados + * como JSON em coluna text. A busca por similaridade (cosseno) roda em Node. + * Para a base pequena da secretária (dezenas/centenas de chunks) é rápido e + * dispensa a extensão pgvector. + */ +import crypto from 'crypto' + +/** Quebra o texto em chunks (~maxLen chars), preferindo limites de parágrafo. */ +export function chunkText(text: string, maxLen = 600): string[] { + const paras = text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean) + const merged: string[] = [] + let buf = '' + for (const p of paras) { + if (buf && (buf.length + 2 + p.length) > maxLen) { merged.push(buf); buf = p } + else { buf = buf ? `${buf}\n\n${p}` : p } + } + if (buf) merged.push(buf) + // Fatia parágrafos gigantes que excedam bastante o limite. + const out: string[] = [] + for (const c of merged) { + if (c.length <= maxLen * 1.5) { out.push(c); continue } + for (let i = 0; i < c.length; i += maxLen) out.push(c.slice(i, i + maxLen)) + } + return out.filter(Boolean) +} + +export function hashText(text: string): string { + return crypto.createHash('md5').update(text).digest('hex') +} + +export function cosineSimilarity(a: number[], b: number[]): number { + const len = Math.min(a.length, b.length) + let dot = 0, na = 0, nb = 0 + for (let i = 0; i < len; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i] } + if (!na || !nb) return 0 + return dot / (Math.sqrt(na) * Math.sqrt(nb)) +} + +/** + * Gera o embedding de um texto. Usa OpenAI se houver openai_key, senão Gemini. + * Retorna null em qualquer falha (o chamador deve cair no fallback). + */ +export async function embed(text: string, cfg: any): Promise { + const input = (text ?? '').slice(0, 8000) + if (!input.trim()) return null + const openaiKey = cfg?.openai_key as string | undefined + const geminiKey = cfg?.gemini_key as string | undefined + try { + if (openaiKey) { + const res = await fetch('https://api.openai.com/v1/embeddings', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${openaiKey}` }, + body: JSON.stringify({ model: 'text-embedding-3-small', input }), + }) + if (!res.ok) return null + const data: any = await res.json() + return data?.data?.[0]?.embedding ?? null + } + if (geminiKey) { + const url = `https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=${geminiKey}` + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'models/text-embedding-004', content: { parts: [{ text: input }] } }), + }) + if (!res.ok) return null + const data: any = await res.json() + return data?.embedding?.values ?? null + } + } catch { + return null + } + return null +} + +export function hasEmbeddingKey(cfg: any): boolean { + return !!(cfg?.openai_key || cfg?.gemini_key) +} diff --git a/newwhats.clube67.com/newwhats.local/plugins/secretaria/migrate.js b/newwhats.clube67.com/newwhats.local/plugins/secretaria/migrate.js index cffde35..26a07f6 100644 --- a/newwhats.clube67.com/newwhats.local/plugins/secretaria/migrate.js +++ b/newwhats.clube67.com/newwhats.local/plugins/secretaria/migrate.js @@ -137,6 +137,23 @@ async function runMigrations(db) { t.timestamps(true, true); }); } + // ── sec_knowledge_chunks (RAG sem pgvector) ─────────────────────────────── + // Chunks de conhecimento + embedding (JSON em text). Busca por similaridade + // roda na aplicação (cosseno), então não exige a extensão pgvector. + if (!(await db.schema.hasTable('sec_knowledge_chunks'))) { + await db.schema.createTable('sec_knowledge_chunks', (t) => { + t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()')); + t.uuid('agent_id').notNullable().references('id').inTable('sec_agents').onDelete('CASCADE'); + t.uuid('node_id').notNullable(); + t.string('content_hash', 40).notNullable(); // detecta quando reindexar + t.integer('chunk_index').notNullable().defaultTo(0); + t.text('content').notNullable(); + t.text('embedding').notNullable(); // vetor serializado em JSON + t.timestamps(true, true); + }); + await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_chunks_agent ON sec_knowledge_chunks (agent_id)'); + await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_chunks_node ON sec_knowledge_chunks (node_id)'); + } // ── Seeds: agente padrão + nós + calendário ─────────────────────────────── const agentCount = await db('sec_agents').count('id as c').first(); if (Number(agentCount?.c ?? 0) === 0) { diff --git a/newwhats.clube67.com/newwhats.local/plugins/secretaria/migrate.ts b/newwhats.clube67.com/newwhats.local/plugins/secretaria/migrate.ts index 6347307..d39de38 100644 --- a/newwhats.clube67.com/newwhats.local/plugins/secretaria/migrate.ts +++ b/newwhats.clube67.com/newwhats.local/plugins/secretaria/migrate.ts @@ -147,6 +147,24 @@ export async function runMigrations(db: Knex): Promise { }) } + // ── sec_knowledge_chunks (RAG sem pgvector) ─────────────────────────────── + // Chunks de conhecimento + embedding (JSON em text). Busca por similaridade + // roda na aplicação (cosseno), então não exige a extensão pgvector. + if (!(await db.schema.hasTable('sec_knowledge_chunks'))) { + await db.schema.createTable('sec_knowledge_chunks', (t) => { + t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()')) + t.uuid('agent_id').notNullable().references('id').inTable('sec_agents').onDelete('CASCADE') + t.uuid('node_id').notNullable() + t.string('content_hash', 40).notNullable() // detecta quando reindexar + t.integer('chunk_index').notNullable().defaultTo(0) + t.text('content').notNullable() + t.text('embedding').notNullable() // vetor serializado em JSON + t.timestamps(true, true) + }) + await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_chunks_agent ON sec_knowledge_chunks (agent_id)') + await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_chunks_node ON sec_knowledge_chunks (node_id)') + } + // ── Seeds: agente padrão + nós + calendário ─────────────────────────────── const agentCount = await db('sec_agents').count('id as c').first() if (Number(agentCount?.c ?? 0) === 0) {