File size: 8,284 Bytes
ee826ee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
/**
 * ============================================
 * πŸ” 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);
}