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
+24 -2
View File
@@ -892,7 +892,18 @@ function getAssistant(_req, res) {
const dbKey = _aset('assistant_llm_key');
const hasKey = !!(dbKey || process.env.ASSISTANT_LLM_KEY);
const local = /\/\/(localhost|127\.0\.0\.1)/.test(url);
res.json({ url, model, hasKey, keyFromEnv: !dbKey && !!process.env.ASSISTANT_LLM_KEY, active: !!(hasKey || local), presets: ASSISTANT_PRESETS });
let chunks = 0, usage = { model_calls: 0, cache_hits: 0, faq: 0 }, usage30 = { model_calls: 0, cache_hits: 0, faq: 0 };
try { chunks = db.prepare('SELECT COUNT(*) n FROM textbook_chunks').get().n; } catch (e) {}
try {
const t = db.prepare('SELECT model_calls, cache_hits, faq FROM assistant_usage WHERE day = ?').get(new Date().toISOString().slice(0, 10));
if (t) usage = t;
const s = db.prepare("SELECT COALESCE(SUM(model_calls),0) model_calls, COALESCE(SUM(cache_hits),0) cache_hits, COALESCE(SUM(faq),0) faq FROM assistant_usage WHERE day > date('now','-30 days')").get();
if (s) usage30 = s;
} catch (e) {}
res.json({
url, model, hasKey, keyFromEnv: !dbKey && !!process.env.ASSISTANT_LLM_KEY, active: !!(hasKey || local),
rag: _aset('assistant_rag') !== '0', chunks, usage, usage30, presets: ASSISTANT_PRESETS,
});
}
function saveAssistant(req, res) {
@@ -901,12 +912,23 @@ function saveAssistant(req, res) {
const b = req.body || {};
if (typeof b.url === 'string') set('assistant_llm_url', b.url.trim().slice(0, 300));
if (typeof b.model === 'string') set('assistant_llm_model', b.model.trim().slice(0, 120));
if (typeof b.rag === 'boolean') set('assistant_rag', b.rag ? '1' : '0');
if (b.clearKey) del('assistant_llm_key');
else if (typeof b.key === 'string' && b.key.trim()) set('assistant_llm_key', b.key.trim().slice(0, 400));
audit(req, 'assistant.config', 'assistant', b.clearKey ? 'ключ очищен' : 'обновлено');
res.json({ ok: true });
}
/* POST /api/admin/assistant/reindex — переиндексировать учебники для RAG */
function reindexTextbooks(req, res) {
try {
const { reindex } = require('../../scripts/index-textbooks');
const r = reindex();
audit(req, 'assistant.reindex', 'assistant', `chunks:${r.chunks || 0}`);
res.json(r);
} catch (e) { res.status(500).json({ error: e.message || 'reindex failed' }); }
}
async function testAssistant(req, res) {
const a = require('./assistantController');
const cfg = a.llmConfig();
@@ -931,5 +953,5 @@ module.exports = {
getSecurityLog, clearSecurityLog,
getTopics, createTopic, updateTopic, deleteTopic,
broadcast,
getAssistant, saveAssistant, testAssistant,
getAssistant, saveAssistant, testAssistant, reindexTextbooks,
};
+60 -10
View File
@@ -165,6 +165,7 @@ function getContext(req, res) {
res.json({
enabled: u ? u.assistant_enabled !== 0 : true,
role: req.user.role,
seen,
dueCards: dueCardsCount(uid),
homework: pendingHomework(uid),
@@ -236,6 +237,30 @@ function llmConfig() {
return { url, key, model, local, on: !!(key || local) };
}
/* RAG: релевантные куски учебников (textbook_chunks) под вопрос. */
function ragContext(q) {
try {
if (_setting('assistant_rag') === '0') return '';
const words = q.toLowerCase().split(/[^a-zа-яё0-9]+/i).filter(w => w.length >= 4).slice(0, 8);
if (!words.length) return '';
const args = words.map(w => '%' + w + '%');
const rows = db.prepare(`SELECT textbook_title, section_title, text FROM textbook_chunks WHERE ${words.map(() => 'text LIKE ?').join(' OR ')} LIMIT 60`).all(...args);
if (!rows.length) return '';
rows.forEach(r => { const t = r.text.toLowerCase(); r._s = words.reduce((s, w) => s + (t.indexOf(w) >= 0 ? 1 : 0), 0); });
rows.sort((a, b) => b._s - a._s);
const need = Math.min(2, words.length);
return rows.filter(r => r._s >= need).slice(0, 2)
.map(r => `«${r.textbook_title}»${r.section_title ? ' — ' + r.section_title : ''}:\n${r.text.slice(0, 1200)}`).join('\n\n');
} catch (e) { return ''; }
}
/* Суточный счётчик использования (для админки). */
const USAGE_FIELDS = { model_calls: 1, cache_hits: 1, faq: 1 };
function bumpUsage(field) {
if (!USAGE_FIELDS[field]) return;
try { db.prepare(`INSERT INTO assistant_usage (day, ${field}) VALUES (?, 1) ON CONFLICT(day) DO UPDATE SET ${field} = ${field} + 1`).run(new Date().toISOString().slice(0, 10)); } catch (e) {}
}
/* Низкоуровневый вызов OpenAI-совместимого chat/completions. */
async function callLLM(messages, maxTokens, override) {
const cfg = override || llmConfig();
@@ -290,11 +315,16 @@ const ASSISTANT_SYS = 'Ты — Квантик, дружелюбный помо
'Если это учебный или общий вопрос (математика, физика, объяснить понятие, решить пример) — отвечай по существу и помоги разобраться. ' +
'Формулы и математику оформляй в LaTeX между знаками доллара, например $a^2+b^2=c^2$. Не используй эмодзи.';
async function askModel(q, hits, context, history) {
async function askModel(q, hits, context, history, role) {
const ref = hits.map((h, i) => `${i + 1}. ${h.q}\n${h.a}${h.url ? ` (раздел: ${h.url})` : ''}`).join('\n') || '(пусто)';
const user = (context ? `Контекст со страницы (на него опирайся, если вопрос про него):\n${context}\n\n` : '') +
const user = (context ? `Контекст (опирайся на него, если относится к вопросу):\n${context}\n\n` : '') +
`Справка по платформе:\n${ref}\n\nВопрос: ${q}`;
const msgs = [{ role: 'system', content: ASSISTANT_SYS }];
let sys = ASSISTANT_SYS;
if (role === 'teacher' || role === 'admin') {
sys += ' Пользователь — учитель: помогай и с преподаванием — составить задание или вопросы, план урока, ' +
'объяснить, как пользоваться учительскими инструментами (классы, журнал, аналитика, онлайн-урок, банк вопросов).';
}
const msgs = [{ role: 'system', content: sys }];
(history || []).forEach(m => { if (m && (m.role === 'user' || m.role === 'assistant') && m.content) msgs.push({ role: m.role, content: String(m.content).slice(0, 1500) }); });
msgs.push({ role: 'user', content: user });
return callLLM(msgs, 420);
@@ -306,17 +336,37 @@ async function askModel(q, hits, context, history) {
async function ask(req, res) {
const q = String((req.body && req.body.q) || '').trim().slice(0, 500);
if (!q || q.length < 2) return res.json({ source: 'faq', answer: null, answers: [] });
const context = String((req.body && req.body.context) || '').slice(0, 4000);
const pageCtx = String((req.body && req.body.context) || '').slice(0, 4000);
let history = (req.body && req.body.history);
history = Array.isArray(history) ? history.slice(-6) : [];
const hits = searchFaq(q, 3);
const faqJson = hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null }));
if (!llmConfig().on) { bumpUsage('faq'); return res.json({ source: 'faq', answer: null, answers: faqJson }); }
// Кэш — только для «чистых» вопросов (без контекста страницы и без истории диалога)
const cacheable = !pageCtx && !history.length;
const qhash = q.toLowerCase().replace(/\s+/g, ' ').trim();
if (cacheable) {
try {
const c = db.prepare("SELECT answer FROM assistant_cache WHERE qhash = ? AND created_at > datetime('now','-7 days')").get(qhash);
if (c) { bumpUsage('cache_hits'); return res.json({ source: 'model', answer: c.answer, answers: faqJson, cached: true }); }
} catch (e) {}
}
const rag = ragContext(q);
let context = pageCtx;
if (rag) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag;
let answer = null;
if (llmConfig().on) { try { answer = await askModel(q, hits, context, history); } catch (e) { answer = null; } }
res.json({
source: answer ? 'model' : 'faq',
answer: answer || null,
answers: hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null })),
});
try { answer = await askModel(q, hits, context, history, req.user && req.user.role); } catch (e) { answer = null; }
if (answer) {
bumpUsage('model_calls');
if (cacheable) { try { db.prepare("INSERT OR REPLACE INTO assistant_cache (qhash, answer, created_at) VALUES (?, ?, datetime('now'))").run(qhash, answer); } catch (e) {} }
} else { bumpUsage('faq'); }
res.json({ source: answer ? 'model' : 'faq', answer: answer || null, answers: faqJson });
}
/* ── POST /api/assistant/flashcards { text, title? } ─────────────────────
@@ -0,0 +1,30 @@
-- ═══════════════════════════════════════════════════════════════
-- 063: Ассистент — RAG по учебникам, кэш ответов, счётчик использования
--
-- textbook_chunks — куски текста учебников (по параграфам) для грунтовки
-- ответов «Спроси Квантика». Наполняется индексатором scripts/index-textbooks.js.
-- assistant_cache — кэш ответов модели по нормализованному вопросу (экономия квоты).
-- assistant_usage — суточный счётчик: вызовы модели / попадания в кэш / FAQ-ответы.
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE textbook_chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
textbook_title TEXT NOT NULL DEFAULT '',
section_title TEXT NOT NULL DEFAULT '',
text TEXT NOT NULL
);
CREATE INDEX idx_textbook_chunks_slug ON textbook_chunks(slug);
CREATE TABLE assistant_cache (
qhash TEXT PRIMARY KEY,
answer TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE assistant_usage (
day TEXT PRIMARY KEY,
model_calls INTEGER NOT NULL DEFAULT 0,
cache_hits INTEGER NOT NULL DEFAULT 0,
faq INTEGER NOT NULL DEFAULT 0
);
+1
View File
@@ -16,6 +16,7 @@ router.use(requireRole('admin'));
router.get('/assistant', ctrl.getAssistant);
router.put('/assistant', ctrl.saveAssistant);
router.post('/assistant/test', ctrl.testAssistant);
router.post('/assistant/reindex', ctrl.reindexTextbooks);
router.get('/stats', ctrl.getStats);
router.get('/overview', ctrl.getOverview);
router.get('/search', ctrl.globalSearch);