/** * rag.js — Wisdom Database v2.0 * * PERUBAHAN DARI v1.0: * - Schema wisdom diperluas: domain, confidence, source tracking * - topicExists() untuk validasi session dari DB * - updateTopicTitle() untuk update title setelah pesan pertama * - Deduplication di layer DB (unique constraint) * - getWisdomByDomain() untuk inject yang relevan saja * - Statistik: hitungWisdom(), getDomainStats() */ 'use strict'; const Database = require('better-sqlite3'); const path = require('path'); const crypto = require('crypto'); const dbPath = path.join(__dirname, 'reasoning_memory.db'); const db = new Database(dbPath); // WAL mode untuk concurrent reads lebih baik db.pragma('journal_mode = WAL'); db.pragma('synchronous = NORMAL'); // ================================================================ // SCHEMA // ================================================================ db.exec(` CREATE TABLE IF NOT EXISTS wisdom ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT NOT NULL DEFAULT 'general', rule TEXT NOT NULL, source TEXT NOT NULL DEFAULT 'auto-extracted', confidence INTEGER NOT NULL DEFAULT 70, use_count INTEGER NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(rule) ); -- Legacy table compat (lessons → wisdom alias) CREATE TABLE IF NOT EXISTS lessons ( id INTEGER PRIMARY KEY AUTOINCREMENT, topic TEXT NOT NULL, reasoning_rule TEXT NOT NULL, source TEXT DEFAULT 'auto', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS chat_topics ( id TEXT PRIMARY KEY, title TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS chat_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, topic_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(topic_id) REFERENCES chat_topics(id) ); CREATE INDEX IF NOT EXISTS idx_messages_topic ON chat_messages(topic_id); CREATE INDEX IF NOT EXISTS idx_wisdom_domain ON wisdom(domain); CREATE INDEX IF NOT EXISTS idx_wisdom_source ON wisdom(source); `); // ================================================================ // PREPARED STATEMENTS // ================================================================ const stmts = { // Wisdom insertWisdom: db.prepare( 'INSERT OR IGNORE INTO wisdom (domain, rule, source, confidence) VALUES (?, ?, ?, ?)' ), getWisdom: db.prepare( 'SELECT domain, rule, source, confidence, use_count FROM wisdom ORDER BY confidence DESC, use_count DESC, created_at DESC LIMIT 60' ), getWisdomByDomain: db.prepare( 'SELECT domain, rule, source, confidence FROM wisdom WHERE domain LIKE ? ORDER BY confidence DESC LIMIT 10' ), getWisdomCount: db.prepare('SELECT COUNT(*) as count FROM wisdom'), updateUseCount: db.prepare( 'UPDATE wisdom SET use_count = use_count + 1, last_used_at = CURRENT_TIMESTAMP WHERE id = ?' ), getDomainStats: db.prepare( 'SELECT domain, COUNT(*) as count, AVG(confidence) as avg_confidence FROM wisdom GROUP BY domain ORDER BY count DESC' ), // Legacy lessons (backward compat) insertLesson: db.prepare( 'INSERT INTO lessons (topic, reasoning_rule, source) VALUES (?, ?, ?)' ), // Topics createTopic: db.prepare('INSERT INTO chat_topics (id, title) VALUES (?, ?)'), topicExists: db.prepare('SELECT id FROM chat_topics WHERE id = ?'), updateTitle: db.prepare('UPDATE chat_topics SET title = ? WHERE id = ?'), getTopics: db.prepare( 'SELECT id, title, created_at FROM chat_topics ORDER BY created_at DESC LIMIT 30' ), // Messages insertMessage: db.prepare( 'INSERT INTO chat_messages (topic_id, role, content) VALUES (?, ?, ?)' ), getMessages: db.prepare( 'SELECT role, content, created_at FROM chat_messages WHERE topic_id = ? ORDER BY created_at ASC' ), countMessages: db.prepare( 'SELECT COUNT(*) as count FROM chat_messages WHERE topic_id = ?' ) }; // ================================================================ // EXPORTS // ================================================================ module.exports = { // ── WISDOM LOOP ────────────────────────────────────────────── saveReasoning(topic, rule, source = 'auto') { // Simpan ke tabel wisdom baru (dengan dedup) const result = stmts.insertWisdom.run( topic.toLowerCase().replace(/\s+/g, '_').substring(0, 50), rule.trim().substring(0, 400), source, source === 'manual' ? 90 : 70 ); // Backward compat: juga simpan ke lessons if (result.changes > 0) { try { stmts.insertLesson.run(topic, rule, source); } catch (_) { /* tidak fatal */ } console.log(`[RAG] Wisdom (${source}): ${topic} → ${rule.substring(0, 60)}`); } return result.changes > 0; }, getAllWisdom() { try { return stmts.getWisdom.all(); } catch { return []; } }, getWisdomByDomain(domain) { try { return stmts.getWisdomByDomain.all('%' + domain + '%'); } catch { return []; } }, hitungWisdom() { try { return stmts.getWisdomCount.get().count; } catch { return 0; } }, getDomainStats() { try { return stmts.getDomainStats.all(); } catch { return []; } }, // Inject ke system prompt — prioritas wisdom dengan confidence tinggi // dan yang relevan dengan domain (deteksi dari kata kunci) injectMemory(contextHint = '') { try { let rows; // Jika ada hint domain, inject yang relevan + general if (contextHint) { const domain = contextHint.toLowerCase().replace(/[^a-z0-9_/]/g, '_').substring(0, 30); const domainRows = stmts.getWisdomByDomain.all('%' + domain + '%'); const generalRows = stmts.getWisdom.all(); // Gabung, deduplicate by rule text, ambil 50 teratas const seen = new Set(); rows = [...domainRows, ...generalRows].filter(r => { if (seen.has(r.rule)) return false; seen.add(r.rule); return true; }).slice(0, 50); } else { rows = stmts.getWisdom.all(); } if (rows.length === 0) return ''; let ctx = '\n\n[INGATAN TEKNIS — RULES YANG SUDAH TERBUKTI]:\n'; ctx += '(Gunakan jika relevan. Jangan disebut jika tidak relevan.)\n'; rows.forEach((row, i) => { const conf = row.confidence >= 90 ? '★' : row.confidence >= 80 ? '◆' : '·'; ctx += `${i + 1}. ${conf} [${row.domain}] ${row.rule}\n`; }); return ctx; } catch { return ''; } }, // ── TOPICS ─────────────────────────────────────────────────── createTopic(title) { const id = crypto.randomBytes(8).toString('hex'); try { stmts.createTopic.run(id, (title || 'Sesi').substring(0, 120)); } catch (e) { console.error('[DB] createTopic error:', e.message); } return id; }, topicExists(id) { try { const row = stmts.topicExists.get(id); return !!row; } catch { return false; } }, updateTopicTitle(id, title) { try { stmts.updateTitle.run((title || 'Sesi').substring(0, 120), id); } catch { /* tidak fatal */ } }, getTopics() { try { return stmts.getTopics.all(); } catch { return []; } }, // ── MESSAGES ───────────────────────────────────────────────── saveMessage(topicId, role, content) { try { stmts.insertMessage.run(topicId, role, (content || '').substring(0, 32000)); } catch (e) { console.error('[DB] saveMessage error:', e.message); } }, getChatHistory(topicId) { try { return stmts.getMessages.all(topicId); } catch { return []; } }, countMessages(topicId) { try { return stmts.countMessages.get(topicId).count; } catch { return 0; } } };