zelin-bot / src /analysis.js
Z User
v5.8.5: Gemma 4, MC Wiki, MC Player, anti-hallucination, CPU optimizations
ee826ee
/**
* ============================================
* πŸ” analysis.js β€” AnΓ‘lisis de Mensajes
* ============================================
* Context Weaver: detecta preguntas implΓ­citas
* Proactive Helper: decide si Zelin debe intervenir
* Conflict Detection: detecta tensiΓ³n antes de escalar
*/
import { callAI } from './ai.js';
import { buildProactivePrompt, buildConflictPrompt } from './prompt.js';
import { sanitizeOutput } from './security.js';
import { detectConflictSignals, detectSentiment } from './moderation.js';
import * as memory from './memory.js';
import { readConfig } from './utils.js';
const config = readConfig();
// Cooldowns por canal para evitar intervenciones masivas
const proactiveCooldown = new Map(); // channelId β†’ lastProactiveAt
const conflictCooldown = new Map(); // channelId β†’ lastConflictAt
const unansweredQuestions = new Map(); // channelId β†’ { question, timestamp, userId }
// Limpiar preguntas sin responder despuΓ©s de 15 minutos
setInterval(() => {
const cutoff = Date.now() - 15 * 60 * 1000;
for (const [ch, q] of unansweredQuestions.entries()) {
if (q.timestamp < cutoff) unansweredQuestions.delete(ch);
}
}, 5 * 60 * 1000);
// ── Context Weaver ────────────────────────────────────────────────────────────
/**
* Detecta si un mensaje es una pregunta implΓ­cita sin respuesta dirigida a nadie.
* Ej: "alguien sabe cΓ³mo entrar desde bedrock?" sin mencionar a nadie
*/
export function detectImplicitQuestion(content) {
const lower = content.toLowerCase();
const questionPatterns = [
/\balguien\s+(sabe|puede|tiene|conoce)/i,
/\b(cΓ³mo|como)\s+(entro|conecto|hago|consigo|llego)/i,
/\b(dΓ³nde|donde)\s+(estΓ‘|queda|hay)/i,
/\b(cuΓ‘ndo|cuando)\s+(es|serΓ‘|hay|abre)/i,
/\b(cuΓ‘l|cual)\s+(es|son|fue)/i,
/\?$/,
/\bno\s+(sΓ©|se)\s+(cΓ³mo|como|quΓ©|que)/i,
/\bme\s+(pueden|podΓ©is|podeis)\s+(ayudar|decir|explicar)/i,
/\btengo\s+(una\s+)?(pregunta|duda)/i,
];
for (const p of questionPatterns) {
if (p.test(lower)) return true;
}
return false;
}
/**
* Registra una pregunta sin responder para monitoreo proactivo.
*/
export function registerUnansweredQuestion(channelId, content, userId) {
unansweredQuestions.set(channelId, {
question : content,
userId,
timestamp: Date.now(),
});
}
/**
* Verifica si hay una pregunta sin respuesta que lleva mΓ‘s de N segundos.
*/
export function checkUnansweredQuestion(channelId, thresholdMs = 90000) {
const q = unansweredQuestions.get(channelId);
if (!q) return null;
if (Date.now() - q.timestamp > thresholdMs) return q;
return null;
}
export function markQuestionAnswered(channelId) {
unansweredQuestions.delete(channelId);
}
// ── Proactive Helper ──────────────────────────────────────────────────────────
/**
* Decide si Zelin debe responder proactivamente.
* Llama a la IA solo si supera el umbral de confianza inicial.
*
* @returns {string|null} Respuesta de Zelin o null si no debe intervenir
*/
export async function evaluateProactiveResponse(channelId, question, userId) {
// Cooldown de 3 minutos por canal
const lastProactive = proactiveCooldown.get(channelId) ?? 0;
if (Date.now() - lastProactive < 180000) return null;
const channelCtx = await memory.getChannelContext(channelId);
try {
const raw = await callAI([
{ role: 'system', content: 'Eres Zelin. Responde SOLO JSON vΓ‘lido.' },
{ role: 'user', content: buildProactivePrompt(channelCtx, question) },
], 'fast', 200);
const clean = raw.replace(/```json|```/g, '').trim();
const result = JSON.parse(clean);
if (!result.shouldRespond || result.confidence < (config.behavior?.proactiveThreshold ?? 0.65)) {
return null;
}
proactiveCooldown.set(channelId, Date.now());
markQuestionAnswered(channelId);
return sanitizeOutput(result.response ?? '');
} catch {
return null;
}
}
// ── Conflict Mediator ─────────────────────────────────────────────────────────
// Seguimiento de tensiΓ³n por canal
const channelTension = new Map(); // channelId β†’ { score, messages: [] }
/**
* Actualiza el nivel de tensiΓ³n de un canal con un nuevo mensaje.
* @returns {boolean} true si la tensiΓ³n supera el umbral y hay que mediar
*/
export function updateChannelTension(channelId, content, username) {
const score = detectConflictSignals(content);
if (!channelTension.has(channelId)) {
channelTension.set(channelId, { score: 0, messages: [], lastUpdate: Date.now() });
}
const tension = channelTension.get(channelId);
// Aplicar decay basado en tiempo transcurrido desde ΓΊltimo mensaje conflictivo
const msSinceLast = Date.now() - (tension.lastUpdate ?? Date.now());
const decayAmount = Math.floor(msSinceLast / 30000) * 0.1; // 0.1 cada 30s
tension.score = Math.max(0, tension.score - decayAmount);
tension.score = Math.min(tension.score + score, 1.0);
tension.lastUpdate = Date.now();
tension.messages.push(`[${username}]: ${content}`);
if (tension.messages.length > 10) tension.messages.shift();
return tension.score >= (config.behavior?.conflictThreshold ?? 0.6);
}
/**
* Genera una respuesta mediadora para un canal en conflicto.
* @returns {string|null}
*/
export async function mediateConflict(channelId) {
const lastMediation = conflictCooldown.get(channelId) ?? 0;
if (Date.now() - lastMediation < 300000) return null; // 5 min cooldown
const tension = channelTension.get(channelId);
if (!tension) return null;
const participants = [...new Set(
tension.messages.map(m => m.match(/^\[([^\]]+)\]/)?.[1]).filter(Boolean)
)];
try {
const response = await callAI([
{ role: 'system', content: 'Eres Zelin mediando un conflicto. SΓ© neutral, breve (1-2 lΓ­neas) y en el estilo de TomateSMP.' },
{ role: 'user', content: buildConflictPrompt(tension.messages.join('\n'), participants) },
], 'reasoning', 150);
conflictCooldown.set(channelId, Date.now());
// Reducir tensiΓ³n tras mediar
tension.score = Math.max(0, tension.score - 0.4);
return sanitizeOutput(response);
} catch {
return null;
}
}
export function resetChannelTension(channelId) {
channelTension.delete(channelId);
}
// ── Knowledge Graph Builder ───────────────────────────────────────────────────
// Grafo simple en memoria (se persiste en Turso via zelin_memory)
let knowledgeGraph = {};
let _kgUpdateCount = 0; // contador separado, no se serializa
export async function loadKnowledgeGraph() {
const { memGet } = await import('./db.js');
knowledgeGraph = await memGet('knowledge.graph') ?? {};
}
export async function updateKnowledgeGraph(entity, type, connections = []) {
if (!knowledgeGraph[entity]) {
knowledgeGraph[entity] = { type, connections: [], mentions: 0 };
}
knowledgeGraph[entity].mentions++;
for (const conn of connections) {
if (!knowledgeGraph[entity].connections.includes(conn)) {
knowledgeGraph[entity].connections.push(conn);
}
}
// Persistir cada 50 actualizaciones β€” sin _updateCount en el objeto serializado
_kgUpdateCount = (_kgUpdateCount + 1);
if (_kgUpdateCount % 50 === 0) {
const { memSet } = await import('./db.js');
const toSave = { ...knowledgeGraph };
delete toSave._updateCount; // por si viene de versiΓ³n vieja
await memSet('knowledge.graph', toSave, 'knowledge');
}
}
export function queryKnowledgeGraph(query) {
const lower = query.toLowerCase();
const results = [];
for (const [entity, data] of Object.entries(knowledgeGraph)) {
if (entity.startsWith('_') || !data || typeof data !== 'object') continue;
if (entity.toLowerCase().includes(lower)) {
results.push({ entity, ...data });
}
}
return results.sort((a, b) => b.mentions - a.mentions).slice(0, 5);
}