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