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,
};