Spaces:
Paused
Paused
| /** | |
| * 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); | |