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:
@@ -11,6 +11,7 @@
|
|||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.ProtocolEngine = void 0;
|
exports.ProtocolEngine = void 0;
|
||||||
const tools_1 = require("./tools");
|
const tools_1 = require("./tools");
|
||||||
|
const embeddings_1 = require("./embeddings");
|
||||||
class ProtocolEngine {
|
class ProtocolEngine {
|
||||||
constructor(db, config) {
|
constructor(db, config) {
|
||||||
this.db = db;
|
this.db = db;
|
||||||
@@ -25,7 +26,7 @@ class ProtocolEngine {
|
|||||||
if (!agent)
|
if (!agent)
|
||||||
throw new Error('Agente não encontrado');
|
throw new Error('Agente não encontrado');
|
||||||
// Monta system prompt a partir dos nós ativos + contexto externo
|
// 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)
|
// Carrega apenas as últimas N mensagens (não o histórico completo)
|
||||||
const contextWindow = agent.context_window ?? 8;
|
const contextWindow = agent.context_window ?? 8;
|
||||||
const recentMessages = await this.db('sec_messages')
|
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())}`;
|
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 ─────────────────────────────────────────────────
|
// ── System Prompt Builder ─────────────────────────────────────────────────
|
||||||
async buildSystemPrompt(agent, conversation, opts) {
|
async buildSystemPrompt(agent, conversation, opts, userMessage) {
|
||||||
const nodes = await this.db('sec_brain_nodes')
|
const nodes = await this.db('sec_brain_nodes')
|
||||||
.where({ agent_id: agent.id, active: true })
|
.where({ agent_id: agent.id, active: true })
|
||||||
.orderBy('sort_order');
|
.orderBy('sort_order');
|
||||||
@@ -148,9 +149,12 @@ class ProtocolEngine {
|
|||||||
case 'persona':
|
case 'persona':
|
||||||
prompt += `${node.content}\n\n`;
|
prompt += `${node.content}\n\n`;
|
||||||
break;
|
break;
|
||||||
case 'knowledge':
|
case 'knowledge': {
|
||||||
prompt += `=== BASE DE CONHECIMENTO ===\n${node.content}\n\n`;
|
// 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;
|
break;
|
||||||
|
}
|
||||||
case 'rules':
|
case 'rules':
|
||||||
prompt += `=== REGRAS ===\n${node.content}\n\n`;
|
prompt += `=== REGRAS ===\n${node.content}\n\n`;
|
||||||
break;
|
break;
|
||||||
@@ -181,6 +185,77 @@ class ProtocolEngine {
|
|||||||
}
|
}
|
||||||
return prompt.trim();
|
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 ─────────────────────────────────────────────────────
|
// ── Finalize Protocol ─────────────────────────────────────────────────────
|
||||||
async finalizeProtocol(conversationId) {
|
async finalizeProtocol(conversationId) {
|
||||||
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first();
|
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Knex } from 'knex'
|
|||||||
import type { PluginConfigStore } from '../../backend/src/core/plugin-config'
|
import type { PluginConfigStore } from '../../backend/src/core/plugin-config'
|
||||||
import type { HookBus } from '../../backend/src/core/hook-bus'
|
import type { HookBus } from '../../backend/src/core/hook-bus'
|
||||||
import { type ToolDef, type ToolContext, resolveTools, ALL_TOOL_NAMES } from './tools'
|
import { type ToolDef, type ToolContext, resolveTools, ALL_TOOL_NAMES } from './tools'
|
||||||
|
import { embed, cosineSimilarity, chunkText, hashText, hasEmbeddingKey } from './embeddings'
|
||||||
|
|
||||||
export class ProtocolEngine {
|
export class ProtocolEngine {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -39,7 +40,7 @@ export class ProtocolEngine {
|
|||||||
if (!agent) throw new Error('Agente não encontrado')
|
if (!agent) throw new Error('Agente não encontrado')
|
||||||
|
|
||||||
// Monta system prompt a partir dos nós ativos + contexto externo
|
// 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)
|
// Carrega apenas as últimas N mensagens (não o histórico completo)
|
||||||
const contextWindow: number = agent.context_window ?? 8
|
const contextWindow: number = agent.context_window ?? 8
|
||||||
@@ -154,6 +155,7 @@ export class ProtocolEngine {
|
|||||||
agent: any,
|
agent: any,
|
||||||
conversation: any,
|
conversation: any,
|
||||||
opts?: { contextData?: Record<string, unknown>; systemExtra?: string },
|
opts?: { contextData?: Record<string, unknown>; systemExtra?: string },
|
||||||
|
userMessage?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const nodes = await this.db('sec_brain_nodes')
|
const nodes = await this.db('sec_brain_nodes')
|
||||||
.where({ agent_id: agent.id, active: true })
|
.where({ agent_id: agent.id, active: true })
|
||||||
@@ -185,9 +187,12 @@ export class ProtocolEngine {
|
|||||||
case 'persona':
|
case 'persona':
|
||||||
prompt += `${node.content}\n\n`
|
prompt += `${node.content}\n\n`
|
||||||
break
|
break
|
||||||
case 'knowledge':
|
case 'knowledge': {
|
||||||
prompt += `=== BASE DE CONHECIMENTO ===\n${node.content}\n\n`
|
// 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
|
break
|
||||||
|
}
|
||||||
case 'rules':
|
case 'rules':
|
||||||
prompt += `=== REGRAS ===\n${node.content}\n\n`
|
prompt += `=== REGRAS ===\n${node.content}\n\n`
|
||||||
break
|
break
|
||||||
@@ -223,6 +228,63 @@ export class ProtocolEngine {
|
|||||||
return prompt.trim()
|
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 ─────────────────────────────────────────────────────
|
// ── Finalize Protocol ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
async finalizeProtocol(conversationId: string): Promise<{ summary: string; protocol_number: string }> {
|
async finalizeProtocol(conversationId: string): Promise<{ summary: string; protocol_number: string }> {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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<number[] | null> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -137,6 +137,23 @@ async function runMigrations(db) {
|
|||||||
t.timestamps(true, true);
|
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 ───────────────────────────────
|
// ── Seeds: agente padrão + nós + calendário ───────────────────────────────
|
||||||
const agentCount = await db('sec_agents').count('id as c').first();
|
const agentCount = await db('sec_agents').count('id as c').first();
|
||||||
if (Number(agentCount?.c ?? 0) === 0) {
|
if (Number(agentCount?.c ?? 0) === 0) {
|
||||||
|
|||||||
@@ -147,6 +147,24 @@ export async function runMigrations(db: Knex): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 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 ───────────────────────────────
|
// ── Seeds: agente padrão + nós + calendário ───────────────────────────────
|
||||||
const agentCount = await db('sec_agents').count('id as c').first()
|
const agentCount = await db('sec_agents').count('id as c').first()
|
||||||
if (Number(agentCount?.c ?? 0) === 0) {
|
if (Number(agentCount?.c ?? 0) === 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user