/** * 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 // Preferência: OpenAI; em QUALQUER falha (ex.: 429 insufficient_quota), cai para // o Gemini — assim uma chave OpenAI sem créditos não derruba o RAG. // ⚠️ OpenAI (1536-dim) e Gemini (768-dim) geram espaços vetoriais incompatíveis. // Ao trocar de provider é preciso REINDEXAR (sync-knowledge + memória de contato), // senão o cosseno mistura dimensões e o retrieval fica sem sentido. if (openaiKey) { const v = await embedOpenAI(input, openaiKey) if (v) return v } if (geminiKey) { const v = await embedGemini(input, geminiKey) if (v) return v } return null } async function embedOpenAI(input: string, apiKey: string): Promise { try { const res = await fetch('https://api.openai.com/v1/embeddings', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, 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 } catch { return null } } async function embedGemini(input: string, apiKey: string): Promise { try { const url = `https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=${apiKey}` 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 } } export function hasEmbeddingKey(cfg: any): boolean { return !!(cfg?.openai_key || cfg?.gemini_key) }