/** * semantic-cache.js — Cache Semántico con Embeddings * ==================================================== * Usa Gemini text-embedding-004 (gratis) para convertir preguntas * en vectores y comparar por similitud coseno. * "cuáles son las reglas" y "dime las reglas del server" → mismo hit. * Reduce hasta 73% de llamadas a la API en tráfico repetitivo. */ import { readConfig } from './utils.js'; const config = readConfig(); // Rotar keys de Gemini — si una falla pasa a la siguiente const _failedGeminiKeys = new Set(); // Registro de función de embedding local — se registra desde local-ai.js al estar listo // Evita importación circular entre semantic-cache ↔ local-ai let _localEmbedFn = null; export function registerLocalEmbedFn(fn) { _localEmbedFn = fn; console.log('[SemanticCache] Función de embedding local registrada ✅'); } function getGeminiKey() { const keys = [ ...(config.ai?.gemini?.keys ?? []), config.ai?.gemini?.apiKey, ].filter(Boolean); // Devolver primera key que no haya fallado for (const k of keys) { if (!_failedGeminiKeys.has(k)) return k; } // Si todas fallaron, resetear y reintentar con la primera _failedGeminiKeys.clear(); return keys[0] ?? null; } function markGeminiKeyFailed(key) { if (key) _failedGeminiKeys.add(key); // Si todas las keys fallaron, deshabilitar embeddings para no spamear logs const allKeys = [ ...(config.ai?.gemini?.keys ?? []), config.ai?.gemini?.apiKey, ].filter(Boolean); if (_failedGeminiKeys.size >= allKeys.length) { _embeddingDisabled = true; console.log('[SemanticCache] Todas las keys Gemini fallaron — usando cache exacto (sin embeddings)'); } } // ── Coseno puro en JS (sin librerías) ──────────────────────────────────────── function cosineSimilarity(a, b) { if (!a?.length || !b?.length || a.length !== b.length) return 0; let dot = 0, normA = 0, normB = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } const denom = Math.sqrt(normA) * Math.sqrt(normB); return denom === 0 ? 0 : dot / denom; } // ── Embedding via Gemini API ────────────────────────────────────────────────── const _embedCache = new Map(); // texto → embedding (evita re-embedear lo mismo) let _embeddingDisabled = false; // si todas las keys fallan, deshabilitar silenciosamente export async function embed(text) { const key = text.slice(0, 200); if (_embedCache.has(key)) return _embedCache.get(key); let vector = null; // 1. Intentar con función de embedding local registrada (evita import circular) if (_localEmbedFn) { try { vector = await _localEmbedFn(text); } catch { /* local no disponible */ } } // 2. Fallback: Gemini API si el local no está listo if (!vector && !_embeddingDisabled) { const geminiKey = getGeminiKey(); if (geminiKey) { try { const res = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent?key=${geminiKey}`, { method : 'POST', headers: { 'Content-Type': 'application/json' }, body : JSON.stringify({ model: 'models/text-embedding-004', content: { parts: [{ text }] } }), signal : AbortSignal.timeout(8000), } ); if (!res.ok) { markGeminiKeyFailed(geminiKey); } else { vector = (await res.json()).embedding?.values ?? null; } } catch {} } } if (!vector?.length) throw new Error('Sin vector de embedding disponible'); _embedCache.set(key, vector); if (_embedCache.size > 2000) _embedCache.delete(_embedCache.keys().next().value); return vector; } // ── Semantic Cache ──────────────────────────────────────────────────────────── export class SemanticCache { constructor(threshold = 0.88) { this.entries = []; // { embedding, response, ts, ttl, query } this.threshold = threshold; this.hits = 0; this.misses = 0; this.errors = 0; this.maxSize = 500; } async get(query) { try { const now = Date.now(); // Limpiar expirados this.entries = this.entries.filter(e => now - e.ts < e.ttl); const qEmb = await embed(query); for (const e of this.entries) { const sim = cosineSimilarity(qEmb, e.embedding); if (sim >= this.threshold) { this.hits++; console.log(`[SemanticCache] HIT (sim=${sim.toFixed(3)}): "${query.slice(0,60)}"`); return e.response; } } this.misses++; return null; } catch (e) { this.errors++; return null; // fallo silencioso — usar cache normal } } async set(query, response, ttlMs = 3_600_000) { try { const embedding = await embed(query); this.entries.push({ embedding, response, ts: Date.now(), ttl: ttlMs, query: query.slice(0, 100) }); if (this.entries.length > this.maxSize) this.entries.shift(); } catch { /* silencioso */ } } // TTL semántico según tipo de pregunta getTTL(query) { const q = query.toLowerCase(); if (/regla|norma|info|servidor|comandos|plugin/.test(q)) return 86_400_000; // 24h if (/online|jugadores|tps|lag|estado/.test(q)) return 30_000; // 30s return 3_600_000; // 1h default } stats() { const total = this.hits + this.misses; return { hits : this.hits, misses : this.misses, hitRate : total > 0 ? ((this.hits / total) * 100).toFixed(1) + '%' : '0%', entries : this.entries.length, threshold: this.threshold, }; } } export const semanticCache = new SemanticCache(0.88);