feat(assistant): индексация системы из админки — Квантик знает актуальные модули

Кнопка «Сохранить и проиндексировать систему» в /admin#assistant собирает снимок:
- статус модулей по фича-флагам (что ВКЛЮЧЕНО/ВЫКЛЮЧЕНО сейчас) + каталог разделов;
- редактируемое «Описание системы» админа.
Снимок кладётся в app_settings.assistant_system_kb и подмешивается в ответы:
systemContext(q) ищет по знаниям (стем-префикс под русскую морфологию) и
добавляет в контекст — Квантик опирается на актуальное состояние и не предлагает
отключённое.

Бэкенд: MODULE_CATALOG + buildSystemKb + indexSystem (POST /admin/assistant/index-system),
saveAssistant(+systemDoc), getAssistant(+systemDoc/Count/At), systemContext в ask и askStream.
Клиент: LS.adminAssistantIndexSystem. Без миграции (хранение в app_settings).
Проверено: логика снимка/поиска 5/5, node --check всех файлов.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-24 21:27:53 +03:00
parent 64ea552cf8
commit 08da26afca
5 changed files with 108 additions and 2 deletions
+63 -1
View File
@@ -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_<key>_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,
};
@@ -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);
+1
View File
@@ -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);