diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index f20638e..1a5197b 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -971,6 +971,64 @@ function _aIsLocal(u) { return /\/\/(localhost|127\.0\.0\.1)/.test(u || ''); } // Шлюзы с бесплатным инференсом без ключа (как localhost): ключ не обязателен. function _aNoKey(u) { return _aIsLocal(u) || /\/\/[^/]*\bpollinations\.ai\b/i.test(u || ''); } +/* ── Индексация системы: снимок модулей/флагов + описание → знания Квантика ── */ +// key — имя фича-флага (app_settings.feature__enabled, по умолч. ВКЛ); null = всегда доступно. +const MODULE_CATALOG = [ + { key: 'textbooks', name: 'Учебники', url: '/textbooks', desc: 'Главы и параграфы с теорией, формулами и задачами; прогресс чтения.' }, + { key: null, name: 'Подготовка к экзамену', url: '/exam-prep', desc: 'Тесты по темам, режимы экзамена/тренировки/случайный, разбор ошибок (ЦТ/ЦЭ).' }, + { key: 'flashcards', name: 'Флешкарты', url: '/flashcards', desc: 'Карточки с интервальным повторением, картинки и формулы KaTeX, общие колоды.' }, + { key: 'theory', name: 'Теория', url: '/theory', desc: 'Курсы и уроки с теорией и заданиями; быстрый одиночный урок.' }, + { key: 'lab', name: 'Лаборатория', url: '/lab', desc: 'Интерактивные 2D-симуляции по физике/математике/химии прямо в браузере.' }, + { key: 'board', name: 'Доска', url: '/board', desc: 'Интерактивная доска: рисование, фигуры, формулы, линейка/транспортир.' }, + { key: null, name: 'Онлайн-урок', url: '/classroom', desc: 'Живой урок с доской, чатом и видео; заметки сохраняются в «Мои материалы».' }, + { key: null, name: 'Мои материалы', url: '/my-materials', desc: 'Личное хранилище: вырезки учебника, страницы доски, заметки (с папками и тегами).' }, + { key: null, name: 'Домашние задания', url: '/homework', desc: 'Задания, дедлайны, загрузка выполненной работы.' }, + { key: 'pet', name: 'Питомец Квантик', url: '/pet', desc: 'Виртуальный питомец: растёт от активности, XP/монеты/серии.' }, + { key: 'gamification', name: 'Геймификация', url: '/profile', desc: 'XP, уровни, монеты, достижения, стрики, магазин, лидерборд.' }, + { key: 'collection', name: 'Коллекция', url: '/collection', desc: 'Коллекционирование предметов/карточек.' }, + { key: 'knowledge_map', name: 'Карта знаний', url: '/knowledge-map', desc: 'Граф знаний по темам.' }, + { key: 'red_book', name: 'Красная книга', url: '/red-book', desc: 'Виды, биомы, экосистемы и мини-игры.' }, + { key: 'biochem', name: 'Биохимия', url: '/biochem', desc: 'Интерактивные молекулы, реакции, пути.' }, + { key: 'games', name: 'Игры (кроссворд/виселица)', url: '/crossword', desc: 'Кроссворд и «Виселица» по терминам предметов, дают XP.' }, + { key: 'sim_builder', name: 'Конструктор симуляций', url: '/sim-builder', desc: 'Авторинг 2D-симуляций (учитель/админ).' }, + { key: null, name: 'Поиск', url: null, desc: 'Глобальный поиск по платформе (Ctrl+K): уроки, курсы, файлы, вопросы.' }, +]; + +function _featFlags() { + const map = {}; + try { db.prepare("SELECT key, value FROM app_settings WHERE key LIKE 'feature_%'").all() + .forEach(r => { map[r.key.replace('feature_', '').replace('_enabled', '')] = (r.value !== '0' && r.value !== 'false'); }); } catch (e) {} + return map; +} + +// Собрать фрагменты знаний о системе: статус модулей + сводка + описание админа. +function buildSystemKb() { + const flags = _featFlags(); + const en = (k) => (k ? (flags[k] !== false) : true); // нет флага → доступно по умолчанию + const chunks = []; + const on = [], off = []; + MODULE_CATALOG.forEach(m => { + const e = en(m.key); + (e ? on : off).push(m.name); + chunks.push({ title: m.name, text: `Модуль «${m.name}» — ${e ? 'ВКЛЮЧЁН и доступен' : 'ВЫКЛЮЧЕН (не предлагать ученику)'}. ${m.desc}${m.url ? ' Раздел: ' + m.url + '.' : ''}` }); + }); + chunks.push({ title: 'Доступные модули', text: `Сейчас на платформе ВКЛЮЧЕНЫ разделы: ${on.join(', ')}.` + (off.length ? ` ВЫКЛЮЧЕНЫ (о них не рассказывать и не предлагать): ${off.join(', ')}.` : '') }); + const doc = _aset('assistant_system_doc'); + if (doc && doc.trim()) doc.split(/\n{2,}/).map(s => s.trim()).filter(Boolean).forEach(p => chunks.push({ title: 'Описание системы', text: p.slice(0, 1500) })); + return chunks; +} + +/* POST /api/admin/assistant/index-system — пересобрать знания о системе */ +function indexSystem(req, res) { + try { + const chunks = buildSystemKb(); + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_system_kb', ?)").run(JSON.stringify(chunks)); + db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('assistant_system_kb_at', ?)").run(new Date().toISOString()); + audit(req, 'assistant.index_system', 'assistant', chunks.length + ' фрагментов'); + res.json({ ok: true, count: chunks.length }); + } catch (e) { res.status(500).json({ error: e.message || 'ошибка индексации' }); } +} + function getAssistant(_req, res) { // Миграция legacy-настроек в список провайдеров (один раз) if (!_aset('assistant_providers')) { @@ -1010,6 +1068,9 @@ function getAssistant(_req, res) { memory: _aset('assistant_memory') !== '0', socratic: _aset('assistant_socratic') === '1', healthEnabled: _aset('assistant_health_enabled') !== '0', health: (() => { try { return JSON.parse(_aset('assistant_health') || '{}') || {}; } catch (e) { return {}; } })(), + systemDoc: _aset('assistant_system_doc') || '', + systemKbCount: (() => { try { return (JSON.parse(_aset('assistant_system_kb') || '[]') || []).length; } catch (e) { return 0; } })(), + systemKbAt: _aset('assistant_system_kb_at') || null, chunks, usage, usage30, feedback, failover, presets: ASSISTANT_PRESETS, kiloModels: _kiloModels(), kiloModelsCustom: !!_aset('assistant_kilo_models'), }); @@ -1024,6 +1085,7 @@ function saveAssistant(req, res) { if (typeof b.memory === 'boolean') set('assistant_memory', b.memory ? '1' : '0'); if (typeof b.socratic === 'boolean') set('assistant_socratic', b.socratic ? '1' : '0'); if (typeof b.healthEnabled === 'boolean') set('assistant_health_enabled', b.healthEnabled ? '1' : '0'); + if (typeof b.systemDoc === 'string') set('assistant_system_doc', b.systemDoc.slice(0, 8000)); if (b.dismissFailover) { try { db.prepare("DELETE FROM app_settings WHERE key = 'assistant_failover'").run(); } catch (e) {} } audit(req, 'assistant.config', 'assistant', 'настройки'); res.json({ ok: true }); @@ -1335,5 +1397,5 @@ module.exports = { getTopics, createTopic, updateTopic, deleteTopic, broadcast, getAssistant, saveAssistant, testAssistant, reindexTextbooks, saveProvider, deleteProvider, setActiveProvider, getProviderModels, - scanModels, probeModel, applyModels, runHealth, + scanModels, probeModel, applyModels, runHealth, indexSystem, }; diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js index 5652b16..a8a4fd3 100644 --- a/backend/src/controllers/assistantController.js +++ b/backend/src/controllers/assistantController.js @@ -396,6 +396,22 @@ function ragContext(q) { } catch (e) { return empty; } } +/* Знания о системе (индексируются из админки): статус модулей + описание. + * Поиск по ключевым словам вопроса; добавляется в контекст ответа. */ +function _systemKb() { try { const r = _setting('assistant_system_kb'); return r ? (JSON.parse(r) || []) : []; } catch (e) { return []; } } +function systemContext(q) { + const kb = _systemKb(); if (!kb.length) return ''; + // стем-префикс (русская морфология): отбрасываем окончание, но не короче 4 симв. + // «флешкартами»→«флешкарт», «лабораторию»→«лаборато» ловят «флешкарты»/«лаборатория». + const stem = (w) => (w.length >= 7 ? w.slice(0, Math.max(4, w.length - 3)) + : w.length >= 5 ? w.slice(0, Math.max(4, w.length - 2)) : w); + const words = q.toLowerCase().split(/[^a-zа-яё0-9]+/i).filter(w => w.length >= 4).map(stem); + if (!words.length) return ''; + const scored = kb.map(c => { const t = ((c.title || '') + ' ' + (c.text || '')).toLowerCase(); return { c, s: words.reduce((a, w) => a + (t.indexOf(w) >= 0 ? 1 : 0), 0) }; }) + .filter(x => x.s > 0).sort((a, b) => b.s - a.s).slice(0, 4); + return scored.map(x => x.c.text).join('\n'); +} + /* Суточный счётчик использования (для админки). */ const USAGE_FIELDS = { model_calls: 1, cache_hits: 1, faq: 1 }; function bumpUsage(field) { @@ -633,6 +649,8 @@ async function ask(req, res) { let context = pageCtx; if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text; + const sysCtx = systemContext(q); + if (sysCtx) context = (context ? context + '\n\n' : '') + 'Состояние платформы (актуально, опирайся на это о модулях):\n' + sysCtx; const socratic = _socraticFor(req.user && req.user.role, mode, q); let r = { text: null, error: 'network' }; @@ -693,6 +711,8 @@ async function askStream(req, res) { let context = pageCtx; if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text; + const sysCtx = systemContext(q); + if (sysCtx) context = (context ? context + '\n\n' : '') + 'Состояние платформы (актуально, опирайся на это о модулях):\n' + sysCtx; const socratic = _socraticFor(req.user && req.user.role, mode, q); const { msgs, cap } = buildAskMessages(q, hits, context, history, req.user && req.user.role, mode, mem, socratic); diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 0b958ae..b027d1d 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -26,6 +26,7 @@ router.post('/assistant/scan', ctrl.scanModels); router.post('/assistant/probe', ctrl.probeModel); router.post('/assistant/models/apply', ctrl.applyModels); router.post('/assistant/health', ctrl.runHealth); +router.post('/assistant/index-system', ctrl.indexSystem); router.get('/imggen', ctrl.getImggen); router.put('/imggen', ctrl.saveImggen); router.post('/imggen/test', ctrl.testImggen); diff --git a/frontend/js/admin/sections/assistant.js b/frontend/js/admin/sections/assistant.js index ebcec50..565a004 100644 --- a/frontend/js/admin/sections/assistant.js +++ b/frontend/js/admin/sections/assistant.js @@ -139,6 +139,18 @@ '
Оценки (30 дн): ' + (f.up || 0) + ' лайков, ' + (f.down || 0) + ' дизлайков' + ((f.recent || []).length ? '. Не помогло: ' + f.recent.map(function (x) { return '«' + esc(String(x.q || '').slice(0, 40)) + '»'; }).join(', ') : '') + '
'; host.appendChild(sc); + // ── Знания о системе (индексация модулей/флагов + описание) ── + var skb = document.createElement('div'); + skb.className = 'perm-card'; skb.style.cssText = 'flex-direction:column;align-items:stretch;gap:9px;margin-top:14px'; + var _skAt = cfg.systemKbAt ? (function () { try { return new Date(cfg.systemKbAt).toLocaleString('ru'); } catch (e) { return ''; } })() : ''; + var _skInfo = cfg.systemKbCount ? (cfg.systemKbCount + ' фрагментов' + (_skAt ? ' · ' + _skAt : '')) : 'ещё не индексировалось'; + skb.innerHTML = + '
Знания о системе для Квантика
' + + '
Снимок включённых модулей + каталог разделов + ваше описание индексируются, чтобы Квантик знал актуальное состояние платформы и не предлагал отключённое. Запускайте после смены фича-флагов.
' + + '' + + '
' + esc(_skInfo) + '
'; + host.appendChild(skb); + if (window.lucide) lucide.createIcons(); var Q = function (s) { return host.querySelector(s); }; @@ -280,6 +292,16 @@ try { var r = await LS.adminReindexTextbooks(); Q('#asst-chunks').textContent = ((r && r.chunks) || 0) + ' фрагментов'; LS.toast('Готово', 'success'); } catch (e) { LS.toast('Ошибка индексации', 'error'); } finally { btn.disabled = false; btn.textContent = 'Переиндексировать учебники'; } }); + Q('#asst-index-sys').addEventListener('click', async function () { + var btn = Q('#asst-index-sys'), st = Q('#asst-sysidx-st'), old = btn.textContent; btn.disabled = true; btn.textContent = 'Индексирую…'; + try { + await LS.adminSaveAssistant({ systemDoc: Q('#asst-sysdoc').value }); + var r = await LS.adminAssistantIndexSystem(); + st.textContent = ((r && r.count) || 0) + ' фрагментов · только что'; + LS.toast('Система проиндексирована', 'success'); + } catch (e) { LS.toast('Ошибка индексации', 'error'); } + finally { btn.disabled = false; btn.textContent = old; } + }); // ── Сканер моделей ── var scanProvId = null; diff --git a/js/api.js b/js/api.js index 15c23f4..0676139 100644 --- a/js/api.js +++ b/js/api.js @@ -1186,7 +1186,7 @@ window.LS = { assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantAskStream, assistantFlashcards, assistantQuestions, assistantFeedback, assistantMemory, assistantMemoryClear, imageGen, imageGenStatus, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, adminSaveProvider, adminDeleteProvider, adminSetActiveProvider, adminAssistantModels, - adminAssistantScan, adminAssistantProbe, adminAssistantApplyModels, adminAssistantHealth, + adminAssistantScan, adminAssistantProbe, adminAssistantApplyModels, adminAssistantHealth, adminAssistantIndexSystem, fcListDecks, fcCreateDeck, fcAddCard, fcStudySession, fcReview, prepListTracks, prepMyTracks, prepStudentTracks, prepSetStudent, prepUnsetStudent, prepClassStatus, prepSetClass, escapeHtml, esc, @@ -1477,6 +1477,7 @@ async function adminAssistantScan(id) { return req('POST', '/admin/assista async function adminAssistantProbe(id, model) { return req('POST', '/admin/assistant/probe', { id, model }); } async function adminAssistantApplyModels(models, reset) { return req('POST', '/admin/assistant/models/apply', reset ? { reset: true } : { models }); } async function adminAssistantHealth() { return req('POST', '/admin/assistant/health', {}); } +async function adminAssistantIndexSystem() { return req('POST', '/admin/assistant/index-system', {}); } 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); }