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
@@ -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);