feat(assistant): RAG по учебникам, кэш+счётчик, режим учителя

- RAG: индексатор scripts/index-textbooks.js → textbook_chunks (миграция 063);
  ask() подмешивает релевантные куски учебников (LIKE-скоринг). Покрывает
  учебники со статическим текстом; JS-рендеримые — через контекст страницы.
  Админка: тумблер RAG + кнопка «Переиндексировать» + число фрагментов.
- Кэш ответов (assistant_cache, 7 дней, только «чистые» вопросы без контекста/
  истории) + суточный счётчик (assistant_usage: ИИ/кэш/FAQ) в админке.
- Режим учителя: роль в /context, системный промпт для учителей (задания,
  план урока, учительские инструменты), подсказки-чипы для учителей.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 18:16:53 +03:00
parent dc073e2114
commit 2252bbd666
8 changed files with 216 additions and 15 deletions
+22 -1
View File
@@ -56,7 +56,14 @@
'<button id="asst-test" style="' + BTN_STYLE + '">Проверить</button>' +
'<button id="asst-clearkey" style="' + BTN_STYLE + ';color:#e0335e">Очистить ключ</button>' +
'</div>' +
'<div id="asst-test-res" style="font-size:.82rem;line-height:1.5"></div>';
'<div id="asst-test-res" style="font-size:.82rem;line-height:1.5"></div>' +
'<hr style="border:none;border-top:1px solid var(--border,#e2e8f0);margin:4px 0">' +
'<label style="display:flex;align-items:center;gap:8px;font-size:.84rem;cursor:pointer"><input type="checkbox" id="asst-rag"> Искать ответы по учебникам (RAG)</label>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
'<button id="asst-reindex" style="' + BTN_STYLE + '">Переиндексировать учебники</button>' +
'<span id="asst-chunks" style="font-size:.78rem;color:#8a94a6"></span>' +
'</div>' +
'<div id="asst-usage" style="font-size:.78rem;color:#8a94a6"></div>';
grid.parentNode.insertBefore(wrap, grid);
if (window.lucide) lucide.createIcons();
@@ -72,8 +79,22 @@
q('#asst-llm-status').innerHTML = cfg.active
? '<span style="color:#059652">● Подключено — «Спроси» отвечает через ИИ</span>'
: '<span style="color:#94a3b8">○ Ключ не задан — работает обычный FAQ-режим</span>';
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; }
+5 -1
View File
@@ -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 += '<button class="asst-chip asst-chip-ctx" data-ctx="sec" type="button">Объяснить этот параграф</button>' +
'<button class="asst-chip asst-chip-ctx" data-ctx="sum" type="button">Конспект параграфа</button>' +
'<button class="asst-chip asst-chip-ctx" data-ctx="cards" type="button">Флешкарты из параграфа</button>';
var sug = (_role === 'teacher' || _role === 'admin') ? TEACHER_SUGGESTIONS : SUGGESTIONS;
var chips = '<div class="asst-chips">' + ctxBtns +
SUGGESTIONS.map(function (q) { return '<button class="asst-chip" type="button">' + esc(q) + '</button>'; }).join('') + '</div>';
sug.map(function (q) { return '<button class="asst-chip" type="button">' + esc(q) + '</button>'; }).join('') + '</div>';
openBubble(
'<div class="asst-name">Спроси Квантика' + (_chat.length ? '<button class="asst-link" data-a="clear" style="float:right;font-weight:600;margin-right:24px">Очистить</button>' : '') + '</div>' +
'<div class="asst-chat"></div>' + 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;