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:
@@ -0,0 +1,72 @@
|
|||||||
|
'use strict';
|
||||||
|
/* index-textbooks.js — наполняет textbook_chunks текстом учебников для RAG
|
||||||
|
* «Спроси Квантика». Парсит HTML учебников (frontend/textbooks/<html_path>) по
|
||||||
|
* параграфам (.sec-h + тело секции), снимает теги, режет на куски.
|
||||||
|
*
|
||||||
|
* Запуск: node backend/scripts/index-textbooks.js (полная переиндексация)
|
||||||
|
* Также вызывается из админки (POST /api/admin/assistant/reindex) через reindex().
|
||||||
|
*
|
||||||
|
* Ограничение: учебники, рендерящие контент через JS-виджеты (напр. physics-9),
|
||||||
|
* в статическом HTML текста почти не содержат — они покрываются контекстом
|
||||||
|
* текущей страницы (getPageContext на клиенте), а не этим индексом. */
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const db = require('../src/db/db');
|
||||||
|
|
||||||
|
const TEXTBOOKS_DIR = path.join(__dirname, '..', '..', 'frontend', 'textbooks');
|
||||||
|
|
||||||
|
function stripTags(html) {
|
||||||
|
return String(html || '')
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||||||
|
.replace(/<svg[\s\S]*?<\/svg>/gi, ' ')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/&[a-z]+;/gi, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function chunksFromHtml(html) {
|
||||||
|
const body = String(html || '').replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<style[\s\S]*?<\/style>/gi, ' ');
|
||||||
|
const out = [];
|
||||||
|
const re = /<h2[^>]*class="[^"]*sec-h[^"]*"[^>]*>([\s\S]*?)<\/h2>([\s\S]*?)(?=<h2[^>]*class="[^"]*sec-h[^"]*"|<\/body|$)/gi;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(body))) {
|
||||||
|
const title = stripTags(m[1]).slice(0, 160);
|
||||||
|
const text = stripTags(m[2]);
|
||||||
|
if (text.length >= 80) out.push({ section: title, text: text.slice(0, 2000) });
|
||||||
|
}
|
||||||
|
if (!out.length) {
|
||||||
|
const all = stripTags(body);
|
||||||
|
for (let i = 0; i < all.length && out.length < 6; i += 1500) out.push({ section: '', text: all.slice(i, i + 1500) });
|
||||||
|
if (out.length && out[0].text.length < 80) out.length = 0;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reindex() {
|
||||||
|
let books;
|
||||||
|
try { books = db.prepare('SELECT slug, title, html_path FROM textbooks WHERE is_active = 1').all(); }
|
||||||
|
catch (e) { return { error: 'textbooks table missing', chunks: 0 }; }
|
||||||
|
const delAll = db.prepare('DELETE FROM textbook_chunks');
|
||||||
|
const del = db.prepare('DELETE FROM textbook_chunks WHERE slug = ?');
|
||||||
|
const ins = db.prepare('INSERT INTO textbook_chunks (slug, textbook_title, section_title, text) VALUES (?, ?, ?, ?)');
|
||||||
|
let total = 0, files = 0;
|
||||||
|
delAll.run();
|
||||||
|
for (const b of books) {
|
||||||
|
const fp = path.join(TEXTBOOKS_DIR, b.html_path || '');
|
||||||
|
let html;
|
||||||
|
try { html = fs.readFileSync(fp, 'utf8'); } catch (e) { continue; }
|
||||||
|
files++;
|
||||||
|
const chunks = chunksFromHtml(html);
|
||||||
|
for (const c of chunks) { ins.run(b.slug, b.title || b.slug, c.section || '', c.text); total++; }
|
||||||
|
}
|
||||||
|
return { books: books.length, files, chunks: total };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { reindex };
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
const r = reindex();
|
||||||
|
console.log('[index-textbooks]', JSON.stringify(r));
|
||||||
|
}
|
||||||
@@ -892,7 +892,18 @@ function getAssistant(_req, res) {
|
|||||||
const dbKey = _aset('assistant_llm_key');
|
const dbKey = _aset('assistant_llm_key');
|
||||||
const hasKey = !!(dbKey || process.env.ASSISTANT_LLM_KEY);
|
const hasKey = !!(dbKey || process.env.ASSISTANT_LLM_KEY);
|
||||||
const local = /\/\/(localhost|127\.0\.0\.1)/.test(url);
|
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) {
|
function saveAssistant(req, res) {
|
||||||
@@ -901,12 +912,23 @@ function saveAssistant(req, res) {
|
|||||||
const b = req.body || {};
|
const b = req.body || {};
|
||||||
if (typeof b.url === 'string') set('assistant_llm_url', b.url.trim().slice(0, 300));
|
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.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');
|
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));
|
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 ? 'ключ очищен' : 'обновлено');
|
audit(req, 'assistant.config', 'assistant', b.clearKey ? 'ключ очищен' : 'обновлено');
|
||||||
res.json({ ok: true });
|
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) {
|
async function testAssistant(req, res) {
|
||||||
const a = require('./assistantController');
|
const a = require('./assistantController');
|
||||||
const cfg = a.llmConfig();
|
const cfg = a.llmConfig();
|
||||||
@@ -931,5 +953,5 @@ module.exports = {
|
|||||||
getSecurityLog, clearSecurityLog,
|
getSecurityLog, clearSecurityLog,
|
||||||
getTopics, createTopic, updateTopic, deleteTopic,
|
getTopics, createTopic, updateTopic, deleteTopic,
|
||||||
broadcast,
|
broadcast,
|
||||||
getAssistant, saveAssistant, testAssistant,
|
getAssistant, saveAssistant, testAssistant, reindexTextbooks,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ function getContext(req, res) {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
enabled: u ? u.assistant_enabled !== 0 : true,
|
enabled: u ? u.assistant_enabled !== 0 : true,
|
||||||
|
role: req.user.role,
|
||||||
seen,
|
seen,
|
||||||
dueCards: dueCardsCount(uid),
|
dueCards: dueCardsCount(uid),
|
||||||
homework: pendingHomework(uid),
|
homework: pendingHomework(uid),
|
||||||
@@ -236,6 +237,30 @@ function llmConfig() {
|
|||||||
return { url, key, model, local, on: !!(key || local) };
|
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. */
|
/* Низкоуровневый вызов OpenAI-совместимого chat/completions. */
|
||||||
async function callLLM(messages, maxTokens, override) {
|
async function callLLM(messages, maxTokens, override) {
|
||||||
const cfg = override || llmConfig();
|
const cfg = override || llmConfig();
|
||||||
@@ -290,11 +315,16 @@ const ASSISTANT_SYS = 'Ты — Квантик, дружелюбный помо
|
|||||||
'Если это учебный или общий вопрос (математика, физика, объяснить понятие, решить пример) — отвечай по существу и помоги разобраться. ' +
|
'Если это учебный или общий вопрос (математика, физика, объяснить понятие, решить пример) — отвечай по существу и помоги разобраться. ' +
|
||||||
'Формулы и математику оформляй в LaTeX между знаками доллара, например $a^2+b^2=c^2$. Не используй эмодзи.';
|
'Формулы и математику оформляй в 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 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}`;
|
`Справка по платформе:\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) }); });
|
(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 });
|
msgs.push({ role: 'user', content: user });
|
||||||
return callLLM(msgs, 420);
|
return callLLM(msgs, 420);
|
||||||
@@ -306,17 +336,37 @@ async function askModel(q, hits, context, history) {
|
|||||||
async function ask(req, res) {
|
async function ask(req, res) {
|
||||||
const q = String((req.body && req.body.q) || '').trim().slice(0, 500);
|
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: [] });
|
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);
|
let history = (req.body && req.body.history);
|
||||||
history = Array.isArray(history) ? history.slice(-6) : [];
|
history = Array.isArray(history) ? history.slice(-6) : [];
|
||||||
const hits = searchFaq(q, 3);
|
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;
|
let answer = null;
|
||||||
if (llmConfig().on) { try { answer = await askModel(q, hits, context, history); } catch (e) { answer = null; } }
|
try { answer = await askModel(q, hits, context, history, req.user && req.user.role); } catch (e) { answer = null; }
|
||||||
res.json({
|
|
||||||
source: answer ? 'model' : 'faq',
|
if (answer) {
|
||||||
answer: answer || null,
|
bumpUsage('model_calls');
|
||||||
answers: hits.map(h => ({ id: h.id, q: h.q, a: h.a, url: h.url || null })),
|
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? } ─────────────────────
|
/* ── 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
|
||||||
|
);
|
||||||
@@ -16,6 +16,7 @@ router.use(requireRole('admin'));
|
|||||||
router.get('/assistant', ctrl.getAssistant);
|
router.get('/assistant', ctrl.getAssistant);
|
||||||
router.put('/assistant', ctrl.saveAssistant);
|
router.put('/assistant', ctrl.saveAssistant);
|
||||||
router.post('/assistant/test', ctrl.testAssistant);
|
router.post('/assistant/test', ctrl.testAssistant);
|
||||||
|
router.post('/assistant/reindex', ctrl.reindexTextbooks);
|
||||||
router.get('/stats', ctrl.getStats);
|
router.get('/stats', ctrl.getStats);
|
||||||
router.get('/overview', ctrl.getOverview);
|
router.get('/overview', ctrl.getOverview);
|
||||||
router.get('/search', ctrl.globalSearch);
|
router.get('/search', ctrl.globalSearch);
|
||||||
|
|||||||
@@ -56,7 +56,14 @@
|
|||||||
'<button id="asst-test" style="' + BTN_STYLE + '">Проверить</button>' +
|
'<button id="asst-test" style="' + BTN_STYLE + '">Проверить</button>' +
|
||||||
'<button id="asst-clearkey" style="' + BTN_STYLE + ';color:#e0335e">Очистить ключ</button>' +
|
'<button id="asst-clearkey" style="' + BTN_STYLE + ';color:#e0335e">Очистить ключ</button>' +
|
||||||
'</div>' +
|
'</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);
|
grid.parentNode.insertBefore(wrap, grid);
|
||||||
if (window.lucide) lucide.createIcons();
|
if (window.lucide) lucide.createIcons();
|
||||||
|
|
||||||
@@ -72,8 +79,22 @@
|
|||||||
q('#asst-llm-status').innerHTML = cfg.active
|
q('#asst-llm-status').innerHTML = cfg.active
|
||||||
? '<span style="color:#059652">● Подключено — «Спроси» отвечает через ИИ</span>'
|
? '<span style="color:#059652">● Подключено — «Спроси» отвечает через ИИ</span>'
|
||||||
: '<span style="color:#94a3b8">○ Ключ не задан — работает обычный FAQ-режим</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();
|
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 () {
|
presetSel.addEventListener('change', function () {
|
||||||
var p = (cfg.presets || [])[Number(presetSel.value)];
|
var p = (cfg.presets || [])[Number(presetSel.value)];
|
||||||
if (p) { q('#asst-url').value = p.url; q('#asst-model').value = p.model; }
|
if (p) { q('#asst-url').value = p.url; q('#asst-model').value = p.model; }
|
||||||
|
|||||||
@@ -424,6 +424,7 @@
|
|||||||
|
|
||||||
/* ── контекст: выделенный текст / текущий параграф ───────────────────── */
|
/* ── контекст: выделенный текст / текущий параграф ───────────────────── */
|
||||||
var _lastSel = '';
|
var _lastSel = '';
|
||||||
|
var _role = 'student';
|
||||||
function getPageContext() {
|
function getPageContext() {
|
||||||
try {
|
try {
|
||||||
if (PAGE === 'textbook') {
|
if (PAGE === 'textbook') {
|
||||||
@@ -441,6 +442,7 @@
|
|||||||
|
|
||||||
/* ── «Спроси Квантика» ───────────────────────────────────────────────── */
|
/* ── «Спроси Квантика» ───────────────────────────────────────────────── */
|
||||||
var SUGGESTIONS = ['Как вырезать кусок учебника?', 'Как создать карточки?', 'Как начать тест?', 'Объясни теорему Пифагора', 'Где мои домашние задания?', 'Как включить тёмную тему?'];
|
var SUGGESTIONS = ['Как вырезать кусок учебника?', 'Как создать карточки?', 'Как начать тест?', 'Объясни теорему Пифагора', 'Где мои домашние задания?', 'Как включить тёмную тему?'];
|
||||||
|
var TEACHER_SUGGESTIONS = ['Как создать класс и выдать задание?', 'Идеи заданий по теме…', 'Составь план урока по теме…', 'Как работает журнал и аналитика?', 'Как провести онлайн-урок?'];
|
||||||
var _chat = []; // многоходовой диалог: [{role:'user'|'assistant', content}]
|
var _chat = []; // многоходовой диалог: [{role:'user'|'assistant', content}]
|
||||||
function msgEl(role) { var d = document.createElement('div'); d.className = 'asst-msg asst-msg-' + role; return d; }
|
function msgEl(role) { var d = document.createElement('div'); d.className = 'asst-msg asst-msg-' + role; return d; }
|
||||||
function renderChat(chatEl) {
|
function renderChat(chatEl) {
|
||||||
@@ -460,8 +462,9 @@
|
|||||||
if (pc) ctxBtns += '<button class="asst-chip asst-chip-ctx" data-ctx="sec" type="button">Объяснить этот параграф</button>' +
|
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="sum" type="button">Конспект параграфа</button>' +
|
||||||
'<button class="asst-chip asst-chip-ctx" data-ctx="cards" 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 +
|
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(
|
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-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 +
|
'<div class="asst-chat"></div>' + chips +
|
||||||
@@ -697,6 +700,7 @@
|
|||||||
if (!document.body) { return setTimeout(boot, 200); }
|
if (!document.body) { return setTimeout(boot, 200); }
|
||||||
LS.assistantContext().then(function (ctx) {
|
LS.assistantContext().then(function (ctx) {
|
||||||
SRV = ctx || {};
|
SRV = ctx || {};
|
||||||
|
_role = (SRV && SRV.role) || 'student';
|
||||||
if (SRV.enabled === false) return; // выключено пользователем
|
if (SRV.enabled === false) return; // выключено пользователем
|
||||||
return (LS.api ? LS.api('/api/pet') : Promise.resolve(null)).then(function (pet) {
|
return (LS.api ? LS.api('/api/pet') : Promise.resolve(null)).then(function (pet) {
|
||||||
PET = pet || null;
|
PET = pet || null;
|
||||||
|
|||||||
@@ -1051,7 +1051,7 @@ window.LS = {
|
|||||||
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
|
listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity,
|
||||||
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
|
createMaterialCollection, updateMaterialCollection, deleteMaterialCollection,
|
||||||
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards,
|
assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards,
|
||||||
adminGetAssistant, adminSaveAssistant, adminTestAssistant,
|
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
|
||||||
fcListDecks, fcCreateDeck, fcAddCard,
|
fcListDecks, fcCreateDeck, fcAddCard,
|
||||||
escapeHtml, esc,
|
escapeHtml, esc,
|
||||||
parseDate, fmtRelTime, safeHref,
|
parseDate, fmtRelTime, safeHref,
|
||||||
@@ -1278,6 +1278,7 @@ async function assistantFlashcards(text, title) { return req('POST', '/assistant
|
|||||||
async function adminGetAssistant() { return req('GET', '/admin/assistant'); }
|
async function adminGetAssistant() { return req('GET', '/admin/assistant'); }
|
||||||
async function adminSaveAssistant(d) { return req('PUT', '/admin/assistant', d); }
|
async function adminSaveAssistant(d) { return req('PUT', '/admin/assistant', d); }
|
||||||
async function adminTestAssistant(d) { return req('POST', '/admin/assistant/test', d || {}); }
|
async function adminTestAssistant(d) { return req('POST', '/admin/assistant/test', d || {}); }
|
||||||
|
async function adminReindexTextbooks() { return req('POST', '/admin/assistant/reindex', {}); }
|
||||||
async function fcListDecks() { return req('GET', '/flashcards/decks'); }
|
async function fcListDecks() { return req('GET', '/flashcards/decks'); }
|
||||||
async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); }
|
async function fcCreateDeck(d) { return req('POST', '/flashcards/decks', d); }
|
||||||
async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }
|
async function fcAddCard(deckId, d) { return req('POST', `/flashcards/decks/${deckId}/cards`, d); }
|
||||||
|
|||||||
Reference in New Issue
Block a user