Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/plugins/secretaria/embeddings.ts
T
VPS 4 Deploy Agent 9ba3c78752 fix(secretaria): embeddings Gemini usam gemini-embedding-001 (768d)
text-embedding-004 foi descontinuado (404 'model not found'). Migra para
gemini-embedding-001 com outputDimensionality=768 (vetores leves p/ JSON+cosseno).
Validado: embed() retorna vetor 768d via Gemini.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:29:09 +02:00

112 lines
4.1 KiB
TypeScript

/**
* RAG sem pgvector — embeddings + similaridade calculados na aplicação.
*
* Os vetores são gerados via OpenAI (text-embedding-3-small) ou Gemini
* (gemini-embedding-001 @ 768 dims), 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
// 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<number[] | null> {
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
}
}
// gemini-embedding-001 (text-embedding-004 foi descontinuado). Default = 3072 dims;
// fixamos 768 (outputDimensionality) para vetores mais leves em JSON + cosseno.
const GEMINI_EMBED_MODEL = 'gemini-embedding-001'
const GEMINI_EMBED_DIMS = 768
async function embedGemini(input: string, apiKey: string): Promise<number[] | null> {
try {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_EMBED_MODEL}:embedContent?key=${apiKey}`
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: `models/${GEMINI_EMBED_MODEL}`,
content: { parts: [{ text: input }] },
outputDimensionality: GEMINI_EMBED_DIMS,
}),
})
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)
}