diff --git a/backend/scripts/index-textbooks.js b/backend/scripts/index-textbooks.js new file mode 100644 index 0000000..8d5db27 --- /dev/null +++ b/backend/scripts/index-textbooks.js @@ -0,0 +1,72 @@ +'use strict'; +/* index-textbooks.js — наполняет textbook_chunks текстом учебников для RAG + * «Спроси Квантика». Парсит HTML учебников (frontend/textbooks/) по + * параграфам (.sec-h + тело секции), снимает теги, режет на куски. + * + * Запуск: node backend/scripts/index-textbooks.js (полная переиндексация) + * Также вызывается из админки (POST /api/admin/assistant/reindex) через reindex(). + * + * Ограничение: учебники, рендерящие контент через JS-виджеты (напр. physics-9), + * в статическом HTML текста почти не содержат — они покрываются контекстом + * текущей страницы (getPageContext на клиенте), а не этим индексом. */ +const path = require('path'); +const fs = require('fs'); +const db = require('../src/db/db'); + +const TEXTBOOKS_DIR = path.join(__dirname, '..', '..', 'frontend', 'textbooks'); + +function stripTags(html) { + return String(html || '') + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/&[a-z]+;/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function chunksFromHtml(html) { + const body = String(html || '').replace(//gi, ' ').replace(//gi, ' '); + const out = []; + const re = /]*class="[^"]*sec-h[^"]*"[^>]*>([\s\S]*?)<\/h2>([\s\S]*?)(?=]*class="[^"]*sec-h[^"]*"|<\/body|$)/gi; + let m; + while ((m = re.exec(body))) { + const title = stripTags(m[1]).slice(0, 160); + const text = stripTags(m[2]); + if (text.length >= 80) out.push({ section: title, text: text.slice(0, 2000) }); + } + if (!out.length) { + const all = stripTags(body); + for (let i = 0; i < all.length && out.length < 6; i += 1500) out.push({ section: '', text: all.slice(i, i + 1500) }); + if (out.length && out[0].text.length < 80) out.length = 0; + } + return out; +} + +function reindex() { + let books; + try { books = db.prepare('SELECT slug, title, html_path FROM textbooks WHERE is_active = 1').all(); } + catch (e) { return { error: 'textbooks table missing', chunks: 0 }; } + const delAll = db.prepare('DELETE FROM textbook_chunks'); + const del = db.prepare('DELETE FROM textbook_chunks WHERE slug = ?'); + const ins = db.prepare('INSERT INTO textbook_chunks (slug, textbook_title, section_title, text) VALUES (?, ?, ?, ?)'); + let total = 0, files = 0; + delAll.run(); + for (const b of books) { + const fp = path.join(TEXTBOOKS_DIR, b.html_path || ''); + let html; + try { html = fs.readFileSync(fp, 'utf8'); } catch (e) { continue; } + files++; + const chunks = chunksFromHtml(html); + for (const c of chunks) { ins.run(b.slug, b.title || b.slug, c.section || '', c.text); total++; } + } + return { books: books.length, files, chunks: total }; +} + +module.exports = { reindex }; + +if (require.main === module) { + const r = reindex(); + console.log('[index-textbooks]', JSON.stringify(r)); +} diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index f3539ec..4725e62 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -892,7 +892,18 @@ function getAssistant(_req, res) { const dbKey = _aset('assistant_llm_key'); const hasKey = !!(dbKey || process.env.ASSISTANT_LLM_KEY); const local = /\/\/(localhost|127\.0\.0\.1)/.test(url); - res.json({ url, model, hasKey, keyFromEnv: !dbKey && !!process.env.ASSISTANT_LLM_KEY, active: !!(hasKey || local), presets: ASSISTANT_PRESETS }); + let chunks = 0, usage = { model_calls: 0, cache_hits: 0, faq: 0 }, usage30 = { model_calls: 0, cache_hits: 0, faq: 0 }; + try { chunks = db.prepare('SELECT COUNT(*) n FROM textbook_chunks').get().n; } catch (e) {} + try { + const t = db.prepare('SELECT model_calls, cache_hits, faq FROM assistant_usage WHERE day = ?').get(new Date().toISOString().slice(0, 10)); + if (t) usage = t; + const s = db.prepare("SELECT COALESCE(SUM(model_calls),0) model_calls, COALESCE(SUM(cache_hits),0) cache_hits, COALESCE(SUM(faq),0) faq FROM assistant_usage WHERE day > date('now','-30 days')").get(); + if (s) usage30 = s; + } catch (e) {} + res.json({ + url, model, hasKey, keyFromEnv: !dbKey && !!process.env.ASSISTANT_LLM_KEY, active: !!(hasKey || local), + rag: _aset('assistant_rag') !== '0', chunks, usage, usage30, presets: ASSISTANT_PRESETS, + }); } function saveAssistant(req, res) { @@ -901,12 +912,23 @@ function saveAssistant(req, res) { const b = req.body || {}; if (typeof b.url === 'string') set('assistant_llm_url', b.url.trim().slice(0, 300)); if (typeof b.model === 'string') set('assistant_llm_model', b.model.trim().slice(0, 120)); + if (typeof b.rag === 'boolean') set('assistant_rag', b.rag ? '1' : '0'); if (b.clearKey) del('assistant_llm_key'); else if (typeof b.key === 'string' && b.key.trim()) set('assistant_llm_key', b.key.trim().slice(0, 400)); audit(req, 'assistant.config', 'assistant', b.clearKey ? 'ключ очищен' : 'обновлено'); res.json({ ok: true }); } +/* POST /api/admin/assistant/reindex — переиндексировать учебники для RAG */ +function reindexTextbooks(req, res) { + try { + const { reindex } = require('../../scripts/index-textbooks'); + const r = reindex(); + audit(req, 'assistant.reindex', 'assistant', `chunks:${r.chunks || 0}`); + res.json(r); + } catch (e) { res.status(500).json({ error: e.message || 'reindex failed' }); } +} + async function testAssistant(req, res) { const a = require('./assistantController'); const cfg = a.llmConfig(); @@ -931,5 +953,5 @@ module.exports = { getSecurityLog, clearSecurityLog, getTopics, createTopic, updateTopic, deleteTopic, broadcast, - getAssistant, saveAssistant, testAssistant, + getAssistant, saveAssistant, testAssistant, reindexTextbooks, }; diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js index a529a9e..3b4c852 100644 --- a/backend/src/controllers/assistantController.js +++ b/backend/src/controllers/assistantController.js @@ -165,6 +165,7 @@ function getContext(req, res) { res.json({ enabled: u ? u.assistant_enabled !== 0 : true, + role: req.user.role, seen, dueCards: dueCardsCount(uid), homework: pendingHomework(uid), @@ -236,6 +237,30 @@ function llmConfig() { return { url, key, model, local, on: !!(key || local) }; } +/* RAG: релевантные куски учебников (textbook_chunks) под вопрос. */ +function ragContext(q) { + try { + if (_setting('assistant_rag') === '0') return ''; + const words = q.toLowerCase().split(/[^a-zа-яё0-9]+/i).filter(w => w.length >= 4).slice(0, 8); + if (!words.length) return ''; + const args = words.map(w => '%' + w + '%'); + const rows = db.prepare(`SELECT textbook_title, section_title, text FROM textbook_chunks WHERE ${words.map(() => 'text LIKE ?').join(' OR ')} LIMIT 60`).all(...args); + if (!rows.length) return ''; + rows.forEach(r => { const t = r.text.toLowerCase(); r._s = words.reduce((s, w) => s + (t.indexOf(w) >= 0 ? 1 : 0), 0); }); + rows.sort((a, b) => b._s - a._s); + const need = Math.min(2, words.length); + return rows.filter(r => r._s >= need).slice(0, 2) + .map(r => `«${r.textbook_title}»${r.section_title ? ' — ' + r.section_title : ''}:\n${r.text.slice(0, 1200)}`).join('\n\n'); + } catch (e) { return ''; } +} + +/* Суточный счётчик использования (для админки). */ +const USAGE_FIELDS = { model_calls: 1, cache_hits: 1, faq: 1 }; +function bumpUsage(field) { + if (!USAGE_FIELDS[field]) return; + try { db.prepare(`INSERT INTO assistant_usage (day, ${field}) VALUES (?, 1) ON CONFLICT(day) DO UPDATE SET ${field} = ${field} + 1`).run(new Date().toISOString().slice(0, 10)); } catch (e) {} +} + /* Низкоуровневый вызов OpenAI-совместимого chat/completions. */ async function callLLM(messages, maxTokens, override) { const cfg = override || llmConfig(); @@ -290,11 +315,16 @@ const ASSISTANT_SYS = 'Ты — Квантик, дружелюбный помо 'Если это учебный или общий вопрос (математика, физика, объяснить понятие, решить пример) — отвечай по существу и помоги разобраться. ' + 'Формулы и математику оформляй в LaTeX между знаками доллара, например $a^2+b^2=c^2$. Не используй эмодзи.'; -async function askModel(q, hits, context, history) { +async function askModel(q, hits, context, history, role) { const ref = hits.map((h, i) => `${i + 1}. ${h.q}\n${h.a}${h.url ? ` (раздел: ${h.url})` : ''}`).join('\n') || '(пусто)'; - const user = (context ? `Контекст со страницы (на него опирайся, если вопрос про него):\n${context}\n\n` : '') + + const user = (context ? `Контекст (опирайся на него, если относится к вопросу):\n${context}\n\n` : '') + `Справка по платформе:\n${ref}\n\nВопрос: ${q}`; - const msgs = [{ role: 'system', content: ASSISTANT_SYS }]; + let sys = ASSISTANT_SYS; + if (role === 'teacher' || role === 'admin') { + sys += ' Пользователь — учитель: помогай и с преподаванием — составить задание или вопросы, план урока, ' + + 'объяснить, как пользоваться учительскими инструментами (классы, журнал, аналитика, онлайн-урок, банк вопросов).'; + } + const msgs = [{ role: 'system', content: sys }]; (history || []).forEach(m => { if (m && (m.role === 'user' || m.role === 'assistant') && m.content) msgs.push({ role: m.role, content: String(m.content).slice(0, 1500) }); }); msgs.push({ role: 'user', content: user }); return callLLM(msgs, 420); @@ -306,17 +336,37 @@ async function askModel(q, hits, context, history) { async function ask(req, res) { const q = String((req.body && req.body.q) || '').trim().slice(0, 500); if (!q || q.length < 2) return res.json({ source: 'faq', answer: null, answers: [] }); - const context = String((req.body && req.body.context) || '').slice(0, 4000); + const pageCtx = String((req.body && req.body.context) || '').slice(0, 4000); let history = (req.body && req.body.history); history = Array.isArray(history) ? history.slice(-6) : []; const hits = searchFaq(q, 3); + const faqJson = hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null })); + + if (!llmConfig().on) { bumpUsage('faq'); return res.json({ source: 'faq', answer: null, answers: faqJson }); } + + // Кэш — только для «чистых» вопросов (без контекста страницы и без истории диалога) + const cacheable = !pageCtx && !history.length; + const qhash = q.toLowerCase().replace(/\s+/g, ' ').trim(); + if (cacheable) { + try { + const c = db.prepare("SELECT answer FROM assistant_cache WHERE qhash = ? AND created_at > datetime('now','-7 days')").get(qhash); + if (c) { bumpUsage('cache_hits'); return res.json({ source: 'model', answer: c.answer, answers: faqJson, cached: true }); } + } catch (e) {} + } + + const rag = ragContext(q); + let context = pageCtx; + if (rag) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag; + let answer = null; - if (llmConfig().on) { try { answer = await askModel(q, hits, context, history); } catch (e) { answer = null; } } - res.json({ - source: answer ? 'model' : 'faq', - answer: answer || null, - answers: hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null })), - }); + try { answer = await askModel(q, hits, context, history, req.user && req.user.role); } catch (e) { answer = null; } + + if (answer) { + bumpUsage('model_calls'); + if (cacheable) { try { db.prepare("INSERT OR REPLACE INTO assistant_cache (qhash, answer, created_at) VALUES (?, ?, datetime('now'))").run(qhash, answer); } catch (e) {} } + } else { bumpUsage('faq'); } + + res.json({ source: answer ? 'model' : 'faq', answer: answer || null, answers: faqJson }); } /* ── POST /api/assistant/flashcards { text, title? } ───────────────────── diff --git a/backend/src/db/migrations/063_assistant_rag.sql b/backend/src/db/migrations/063_assistant_rag.sql new file mode 100644 index 0000000..5900125 --- /dev/null +++ b/backend/src/db/migrations/063_assistant_rag.sql @@ -0,0 +1,30 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 063: Ассистент — RAG по учебникам, кэш ответов, счётчик использования +-- +-- textbook_chunks — куски текста учебников (по параграфам) для грунтовки +-- ответов «Спроси Квантика». Наполняется индексатором scripts/index-textbooks.js. +-- assistant_cache — кэш ответов модели по нормализованному вопросу (экономия квоты). +-- assistant_usage — суточный счётчик: вызовы модели / попадания в кэш / FAQ-ответы. +-- ═══════════════════════════════════════════════════════════════ + +CREATE TABLE textbook_chunks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL, + textbook_title TEXT NOT NULL DEFAULT '', + section_title TEXT NOT NULL DEFAULT '', + text TEXT NOT NULL +); +CREATE INDEX idx_textbook_chunks_slug ON textbook_chunks(slug); + +CREATE TABLE assistant_cache ( + qhash TEXT PRIMARY KEY, + answer TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE assistant_usage ( + day TEXT PRIMARY KEY, + model_calls INTEGER NOT NULL DEFAULT 0, + cache_hits INTEGER NOT NULL DEFAULT 0, + faq INTEGER NOT NULL DEFAULT 0 +); diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 27b597e..19d6f8b 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -16,6 +16,7 @@ router.use(requireRole('admin')); router.get('/assistant', ctrl.getAssistant); router.put('/assistant', ctrl.saveAssistant); router.post('/assistant/test', ctrl.testAssistant); +router.post('/assistant/reindex', ctrl.reindexTextbooks); router.get('/stats', ctrl.getStats); router.get('/overview', ctrl.getOverview); router.get('/search', ctrl.globalSearch); diff --git a/frontend/js/admin/sections/games.js b/frontend/js/admin/sections/games.js index 568094f..0f2ea6e 100644 --- a/frontend/js/admin/sections/games.js +++ b/frontend/js/admin/sections/games.js @@ -56,7 +56,14 @@ '' + '' + '' + - '
'; + '
' + + '
' + + '' + + '
' + + '' + + '' + + '
' + + '
'; grid.parentNode.insertBefore(wrap, grid); if (window.lucide) lucide.createIcons(); @@ -72,8 +79,22 @@ q('#asst-llm-status').innerHTML = cfg.active ? '● Подключено — «Спроси» отвечает через ИИ' : '○ Ключ не задан — работает обычный FAQ-режим'; + q('#asst-rag').checked = cfg.rag !== false; + q('#asst-chunks').textContent = (cfg.chunks || 0) + ' фрагментов учебников в индексе'; + var u = cfg.usage || {}, u30 = cfg.usage30 || {}; + q('#asst-usage').innerHTML = 'Сегодня: ' + (u.model_calls || 0) + ' к ИИ, ' + (u.cache_hits || 0) + ' из кэша, ' + (u.faq || 0) + ' FAQ. ' + + 'За 30 дней: ' + (u30.model_calls || 0) + ' / ' + (u30.cache_hits || 0) + ' / ' + (u30.faq || 0) + '.'; } setStatus(); + q('#asst-rag').addEventListener('change', function () { + LS.adminSaveAssistant({ rag: q('#asst-rag').checked }).then(function () { LS.toast('Сохранено', 'success'); }).catch(function () {}); + }); + q('#asst-reindex').addEventListener('click', async function () { + var btn = q('#asst-reindex'); btn.disabled = true; btn.textContent = 'Индексирую…'; + try { var r = await LS.adminReindexTextbooks(); cfg.chunks = (r && r.chunks) || 0; setStatus(); LS.toast('Готово: ' + cfg.chunks + ' фрагментов', 'success'); } + catch (e) { LS.toast('Ошибка индексации', 'error'); } + finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; } + }); presetSel.addEventListener('change', function () { var p = (cfg.presets || [])[Number(presetSel.value)]; if (p) { q('#asst-url').value = p.url; q('#asst-model').value = p.model; } diff --git a/frontend/js/assistant.js b/frontend/js/assistant.js index 16a14b2..8a6ece4 100644 --- a/frontend/js/assistant.js +++ b/frontend/js/assistant.js @@ -424,6 +424,7 @@ /* ── контекст: выделенный текст / текущий параграф ───────────────────── */ var _lastSel = ''; + var _role = 'student'; function getPageContext() { try { if (PAGE === 'textbook') { @@ -441,6 +442,7 @@ /* ── «Спроси Квантика» ───────────────────────────────────────────────── */ var SUGGESTIONS = ['Как вырезать кусок учебника?', 'Как создать карточки?', 'Как начать тест?', 'Объясни теорему Пифагора', 'Где мои домашние задания?', 'Как включить тёмную тему?']; + var TEACHER_SUGGESTIONS = ['Как создать класс и выдать задание?', 'Идеи заданий по теме…', 'Составь план урока по теме…', 'Как работает журнал и аналитика?', 'Как провести онлайн-урок?']; var _chat = []; // многоходовой диалог: [{role:'user'|'assistant', content}] function msgEl(role) { var d = document.createElement('div'); d.className = 'asst-msg asst-msg-' + role; return d; } function renderChat(chatEl) { @@ -460,8 +462,9 @@ if (pc) ctxBtns += '' + '' + ''; + var sug = (_role === 'teacher' || _role === 'admin') ? TEACHER_SUGGESTIONS : SUGGESTIONS; var chips = '
' + ctxBtns + - SUGGESTIONS.map(function (q) { return ''; }).join('') + '
'; + sug.map(function (q) { return ''; }).join('') + ''; openBubble( '
Спроси Квантика' + (_chat.length ? '' : '') + '
' + '
' + chips + @@ -697,6 +700,7 @@ if (!document.body) { return setTimeout(boot, 200); } LS.assistantContext().then(function (ctx) { SRV = ctx || {}; + _role = (SRV && SRV.role) || 'student'; if (SRV.enabled === false) return; // выключено пользователем return (LS.api ? LS.api('/api/pet') : Promise.resolve(null)).then(function (pet) { PET = pet || null; diff --git a/js/api.js b/js/api.js index 94afa68..e25f917 100644 --- a/js/api.js +++ b/js/api.js @@ -1051,7 +1051,7 @@ window.LS = { listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity, createMaterialCollection, updateMaterialCollection, deleteMaterialCollection, assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, - adminGetAssistant, adminSaveAssistant, adminTestAssistant, + adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, fcListDecks, fcCreateDeck, fcAddCard, escapeHtml, esc, parseDate, fmtRelTime, safeHref, @@ -1278,6 +1278,7 @@ async function assistantFlashcards(text, title) { return req('POST', '/assistant async function adminGetAssistant() { return req('GET', '/admin/assistant'); } async function adminSaveAssistant(d) { return req('PUT', '/admin/assistant', d); } async function adminTestAssistant(d) { return req('POST', '/admin/assistant/test', d || {}); } +async function adminReindexTextbooks() { return req('POST', '/admin/assistant/reindex', {}); } async function fcListDecks() { return req('GET', '/flashcards/decks'); } async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); } async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }