zelin-bot / src /semantic-cache.js
Z User
v5.8.5: Gemma 4, MC Wiki, MC Player, anti-hallucination, CPU optimizations
ee826ee
/**
* 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);