feat(assistant): источники в ответах, режим-наставник, оценки, утренний бриф

- Источники: RAG возвращает sources (slug/§/ref), под ответом ссылка «по учебнику
  X, §N» на параграф (миграция 064: section_ref в textbook_chunks; headless-индексатор
  его заполняет). Статический индексатор теперь не затирает headless-данные.
- Режим-наставник: переключатель Ответ/Подсказка/Проверить решение в «Спроси»
  (mode в ask + промпт); на карточке экзамена кнопка «Подсказка» (mode hint).
- Оценка ответов: лайк/дизлайк под ответом (assistant_feedback) + сводка в админке.
- Утренний бриф на дашборде: «занимался N из 5 дн + план на сегодня».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-04 19:38:47 +03:00
parent 0119ea0f15
commit 4224a22092
9 changed files with 155 additions and 40 deletions
+3 -3
View File
@@ -36,7 +36,7 @@ async function run() {
if (!exe) { console.error('Браузер не найден (Chrome/Edge)'); process.exit(1); } if (!exe) { console.error('Браузер не найден (Chrome/Edge)'); process.exit(1); }
const books = db.prepare('SELECT slug, title FROM textbooks WHERE is_active = 1 ORDER BY slug').all(); const books = db.prepare('SELECT slug, title FROM textbooks WHERE is_active = 1 ORDER BY slug').all();
const del = db.prepare('DELETE FROM textbook_chunks WHERE slug = ?'); 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 (?, ?, ?, ?)'); const ins = db.prepare('INSERT INTO textbook_chunks (slug, textbook_title, section_title, text, section_ref) VALUES (?, ?, ?, ?, ?)');
const token = authToken(); const token = authToken();
if (!token) { console.error('Не удалось выпустить токен (нет пользователя или JWT_SECRET)'); process.exit(1); } if (!token) { console.error('Не удалось выпустить токен (нет пользователя или JWT_SECRET)'); process.exit(1); }
@@ -59,7 +59,7 @@ async function run() {
await page.evaluate(id => { const c = document.querySelector('.psel-card[data-id="' + id + '"]'); if (c) c.click(); }, s.id); await page.evaluate(id => { const c = document.querySelector('.psel-card[data-id="' + id + '"]'); if (c) c.click(); }, s.id);
await sleep(550); await sleep(550);
const text = await page.evaluate(() => { const a = document.querySelector('.sec.active'); return a ? a.innerText.replace(/\s+/g, ' ').trim() : ''; }); const text = await page.evaluate(() => { const a = document.querySelector('.sec.active'); return a ? a.innerText.replace(/\s+/g, ' ').trim() : ''; });
if (text && text.length >= 80) chunks.push({ section: s.name.slice(0, 160), text: text.slice(0, 2000) }); if (text && text.length >= 80) chunks.push({ section: s.name.slice(0, 160), text: text.slice(0, 2000), ref: s.id });
} catch (e) {} } catch (e) {}
} }
} else { } else {
@@ -70,7 +70,7 @@ async function run() {
if (chunks.length) { if (chunks.length) {
del.run(b.slug); del.run(b.slug);
for (const c of chunks) ins.run(b.slug, b.title || b.slug, c.section, c.text); for (const c of chunks) ins.run(b.slug, b.title || b.slug, c.section, c.text, c.ref || null);
okBooks++; totalChunks += chunks.length; okBooks++; totalChunks += chunks.length;
console.log(` ${b.slug}: ${chunks.length}`); console.log(` ${b.slug}: ${chunks.length}`);
} else { } else {
+4 -2
View File
@@ -48,17 +48,19 @@ function reindex() {
let books; let books;
try { books = db.prepare('SELECT slug, title, html_path FROM textbooks WHERE is_active = 1').all(); } 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 }; } catch (e) { return { error: 'textbooks table missing', chunks: 0 }; }
const delAll = db.prepare('DELETE FROM textbook_chunks'); // Замещаем чанки только тех книг, что реально распарсились — не трогаем
// данные, наполненные headless-индексатором (JS-рендеримые учебники).
const del = db.prepare('DELETE FROM textbook_chunks WHERE slug = ?'); 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 (?, ?, ?, ?)'); const ins = db.prepare('INSERT INTO textbook_chunks (slug, textbook_title, section_title, text) VALUES (?, ?, ?, ?)');
let total = 0, files = 0; let total = 0, files = 0;
delAll.run();
for (const b of books) { for (const b of books) {
const fp = path.join(TEXTBOOKS_DIR, b.html_path || ''); const fp = path.join(TEXTBOOKS_DIR, b.html_path || '');
let html; let html;
try { html = fs.readFileSync(fp, 'utf8'); } catch (e) { continue; } try { html = fs.readFileSync(fp, 'utf8'); } catch (e) { continue; }
files++; files++;
const chunks = chunksFromHtml(html); const chunks = chunksFromHtml(html);
if (!chunks.length) continue;
del.run(b.slug);
for (const c of chunks) { ins.run(b.slug, b.title || b.slug, c.section || '', c.text); total++; } 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 }; return { books: books.length, files, chunks: total };
+7 -1
View File
@@ -900,9 +900,15 @@ function getAssistant(_req, res) {
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(); 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; if (s) usage30 = s;
} catch (e) {} } catch (e) {}
let feedback = { up: 0, down: 0, recent: [] };
try {
const f = db.prepare("SELECT COALESCE(SUM(CASE WHEN rating=1 THEN 1 ELSE 0 END),0) up, COALESCE(SUM(CASE WHEN rating=-1 THEN 1 ELSE 0 END),0) down FROM assistant_feedback WHERE created_at > date('now','-30 days')").get();
if (f) { feedback.up = f.up; feedback.down = f.down; }
feedback.recent = db.prepare("SELECT q, created_at FROM assistant_feedback WHERE rating=-1 AND q IS NOT NULL AND q <> '' ORDER BY id DESC LIMIT 5").all();
} catch (e) {}
res.json({ res.json({
url, model, hasKey, keyFromEnv: !dbKey && !!process.env.ASSISTANT_LLM_KEY, active: !!(hasKey || local), url, model, hasKey, keyFromEnv: !dbKey && !!process.env.ASSISTANT_LLM_KEY, active: !!(hasKey || local),
rag: _aset('assistant_rag') !== '0', chunks, usage, usage30, presets: ASSISTANT_PRESETS, rag: _aset('assistant_rag') !== '0', chunks, usage, usage30, feedback, presets: ASSISTANT_PRESETS,
}); });
} }
+40 -18
View File
@@ -237,21 +237,27 @@ function llmConfig() {
return { url, key, model, local, on: !!(key || local) }; return { url, key, model, local, on: !!(key || local) };
} }
/* RAG: релевантные куски учебников (textbook_chunks) под вопрос. */ /* RAG: релевантные куски учебников (textbook_chunks) под вопрос.
* Возвращает { text, sources:[{slug,title,section,ref}] } для цитирования. */
function ragContext(q) { function ragContext(q) {
const empty = { text: '', sources: [] };
try { try {
if (_setting('assistant_rag') === '0') return ''; if (_setting('assistant_rag') === '0') return empty;
const words = q.toLowerCase().split(/[^a-zа-яё0-9]+/i).filter(w => w.length >= 4).slice(0, 8); const words = q.toLowerCase().split(/[^a-zа-яё0-9]+/i).filter(w => w.length >= 4).slice(0, 8);
if (!words.length) return ''; if (!words.length) return empty;
const args = words.map(w => '%' + w + '%'); 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); const rows = db.prepare(`SELECT slug, textbook_title, section_title, section_ref, text FROM textbook_chunks WHERE ${words.map(() => 'text LIKE ?').join(' OR ')} LIMIT 60`).all(...args);
if (!rows.length) return ''; if (!rows.length) return empty;
rows.forEach(r => { const t = r.text.toLowerCase(); r._s = words.reduce((s, w) => s + (t.indexOf(w) >= 0 ? 1 : 0), 0); }); 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); rows.sort((a, b) => b._s - a._s);
const need = Math.min(2, words.length); const need = Math.min(2, words.length);
return rows.filter(r => r._s >= need).slice(0, 2) const top = 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'); if (!top.length) return empty;
} catch (e) { return ''; } return {
text: top.map(r => `«${r.textbook_title}»${r.section_title ? ' — ' + r.section_title : ''}:\n${r.text.slice(0, 1200)}`).join('\n\n'),
sources: top.map(r => ({ slug: r.slug, title: r.textbook_title, section: r.section_title || '', ref: r.section_ref || null })),
};
} catch (e) { return empty; }
} }
/* Суточный счётчик использования (для админки). */ /* Суточный счётчик использования (для админки). */
@@ -315,7 +321,7 @@ const ASSISTANT_SYS = 'Ты — Квантик, дружелюбный помо
'Если это учебный или общий вопрос (математика, физика, объяснить понятие, решить пример) — отвечай по существу и помоги разобраться. ' + 'Если это учебный или общий вопрос (математика, физика, объяснить понятие, решить пример) — отвечай по существу и помоги разобраться. ' +
'Формулы и математику оформляй в LaTeX между знаками доллара, например $a^2+b^2=c^2$. Не используй эмодзи.'; 'Формулы и математику оформляй в LaTeX между знаками доллара, например $a^2+b^2=c^2$. Не используй эмодзи.';
async function askModel(q, hits, context, history, role) { async function askModel(q, hits, context, history, role, mode) {
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}`;
@@ -324,6 +330,11 @@ async function askModel(q, hits, context, history, role) {
sys += ' Пользователь — учитель: помогай и с преподаванием — составить задание или вопросы, план урока, ' + sys += ' Пользователь — учитель: помогай и с преподаванием — составить задание или вопросы, план урока, ' +
'объяснить, как пользоваться учительскими инструментами (классы, журнал, аналитика, онлайн-урок, банк вопросов).'; 'объяснить, как пользоваться учительскими инструментами (классы, журнал, аналитика, онлайн-урок, банк вопросов).';
} }
if (mode === 'hint') {
sys += ' РЕЖИМ ПОДСКАЗКИ: дай ТОЛЬКО наводящую подсказку или следующий шаг к решению. Не давай готовый ответ — пусть ученик додумает сам.';
} else if (mode === 'check') {
sys += ' РЕЖИМ ПРОВЕРКИ: ученик прислал своё решение. Скажи, верно оно или нет, и укажи КОНКРЕТНО, где ошибка (если есть). Не выдавай сразу полный правильный ответ — дай шанс исправить.';
}
const msgs = [{ role: 'system', content: 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 });
@@ -337,36 +348,47 @@ 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 pageCtx = String((req.body && req.body.context) || '').slice(0, 4000); const pageCtx = String((req.body && req.body.context) || '').slice(0, 4000);
const mode = ['hint', 'check'].includes(req.body && req.body.mode) ? req.body.mode : 'answer';
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 })); 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 }); } if (!llmConfig().on) { bumpUsage('faq'); return res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] }); }
// Кэш — только для «чистых» вопросов (без контекста страницы и без истории диалога) const rag = ragContext(q);
const cacheable = !pageCtx && !history.length;
// Кэш — только обычный режим без контекста страницы и без истории диалога
const cacheable = mode === 'answer' && !pageCtx && !history.length;
const qhash = q.toLowerCase().replace(/\s+/g, ' ').trim(); const qhash = q.toLowerCase().replace(/\s+/g, ' ').trim();
if (cacheable) { if (cacheable) {
try { try {
const c = db.prepare("SELECT answer FROM assistant_cache WHERE qhash = ? AND created_at > datetime('now','-7 days')").get(qhash); 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 }); } if (c) { bumpUsage('cache_hits'); return res.json({ source: 'model', answer: c.answer, answers: faqJson, sources: rag.sources, cached: true }); }
} catch (e) {} } catch (e) {}
} }
const rag = ragContext(q);
let context = pageCtx; let context = pageCtx;
if (rag) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag; if (rag.text) context = (context ? context + '\n\n' : '') + 'Из учебников:\n' + rag.text;
let answer = null; let answer = null;
try { answer = await askModel(q, hits, context, history, req.user && req.user.role); } catch (e) { answer = null; } try { answer = await askModel(q, hits, context, history, req.user && req.user.role, mode); } catch (e) { answer = null; }
if (answer) { if (answer) {
bumpUsage('model_calls'); 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) {} } 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'); } } else { bumpUsage('faq'); }
res.json({ source: answer ? 'model' : 'faq', answer: answer || null, answers: faqJson }); res.json({ source: answer ? 'model' : 'faq', answer: answer || null, answers: faqJson, sources: answer ? rag.sources : [] });
}
/* ── POST /api/assistant/feedback { rating, q? } ── лайк/дизлайк ответа ── */
function feedback(req, res) {
const rating = (req.body && req.body.rating) === 1 ? 1 : ((req.body && req.body.rating) === -1 ? -1 : 0);
if (!rating) return res.status(400).json({ error: 'rating must be 1 or -1' });
const q = String((req.body && req.body.q) || '').slice(0, 300);
try { db.prepare('INSERT INTO assistant_feedback (user_id, rating, q) VALUES (?, ?, ?)').run(req.user.id, rating, q || null); } catch (e) {}
res.json({ ok: true });
} }
/* ── POST /api/assistant/flashcards { text, title? } ───────────────────── /* ── POST /api/assistant/flashcards { text, title? } ─────────────────────
@@ -403,4 +425,4 @@ async function flashcardsFromText(req, res) {
res.json({ title, cards }); res.json({ title, cards });
} }
module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText, llmConfig, pingLLM }; module.exports = { getContext, markSeen, dismiss, setSettings, ask, flashcardsFromText, feedback, llmConfig, pingLLM };
@@ -0,0 +1,18 @@
-- ═══════════════════════════════════════════════════════════════
-- 064: Ассистент — источники в ответах + оценки качества
--
-- section_ref — id параграфа (sec-<id>) у куска учебника, чтобы под RAG-ответом
-- давать ссылку «по учебнику X, §N» прямо на параграф (/textbook/<slug>#sec-<ref>).
-- assistant_feedback — лайк/дизлайк на ответы (для оценки качества в админке).
-- ═══════════════════════════════════════════════════════════════
ALTER TABLE textbook_chunks ADD COLUMN section_ref TEXT;
CREATE TABLE assistant_feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
rating INTEGER NOT NULL, -- 1 = лайк, -1 = дизлайк
q TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_assistant_feedback_created ON assistant_feedback(created_at);
+1
View File
@@ -13,5 +13,6 @@ router.post('/dismiss', ctrl.dismiss);
router.patch('/settings', ctrl.setSettings); router.patch('/settings', ctrl.setSettings);
router.post('/ask', ctrl.ask); router.post('/ask', ctrl.ask);
router.post('/flashcards', ctrl.flashcardsFromText); router.post('/flashcards', ctrl.flashcardsFromText);
router.post('/feedback', ctrl.feedback);
module.exports = router; module.exports = router;
+69 -13
View File
@@ -101,8 +101,16 @@
when: function () { return !!(SRV && SRV.weakSubject); }, when: function () { return !!(SRV && SRV.weakSubject); },
text: function () { var w = SRV.weakSubject; return 'Слабее всего идёт ' + (w.name || 'предмет') + ' (' + w.avg + '%). Потренируемся?'; }, text: function () { var w = SRV.weakSubject; return 'Слабее всего идёт ' + (w.name || 'предмет') + ' (' + w.avg + '%). Потренируемся?'; },
action: function () { return { label: 'Потренироваться', url: '/exam-prep/math9' }; } }, action: function () { return { label: 'Потренироваться', url: '/exam-prep/math9' }; } },
{ id: 'brief', scope: 'proactive', cooldownDays: 1, maxShows: 300,
when: function () { return PAGE === 'dashboard' && new Date().getHours() < 12; },
text: function () {
var plan = dailyPlan(), days = activeDaysThisWeek();
var s = 'Доброе утро! ' + (days != null ? 'На этой неделе ты занимался ' + days + ' из 5 дн. ' : '');
return s + (plan.length ? 'Сегодня: ' + plan.join(', ') + '.' : 'Сегодня можно начать с короткого теста.');
},
action: function () { return dailyPlanAction(); } },
{ id: 'daily-plan', scope: 'proactive', cooldownDays: 1, maxShows: 120, { id: 'daily-plan', scope: 'proactive', cooldownDays: 1, maxShows: 120,
when: function () { return PAGE === 'dashboard' && dailyPlan().length > 0; }, when: function () { return PAGE === 'dashboard' && new Date().getHours() >= 12 && dailyPlan().length > 0; },
text: function () { return 'План на сегодня: ' + dailyPlan().join(', ') + '. Начнём?'; }, text: function () { return 'План на сегодня: ' + dailyPlan().join(', ') + '. Начнём?'; },
action: function () { var p = dailyPlanAction(); return p; } }, action: function () { var p = dailyPlanAction(); return p; } },
]; ];
@@ -119,6 +127,7 @@
if (SRV && SRV.dueCards > 0) return { label: 'Повторить карточки', url: '/flashcards' }; if (SRV && SRV.dueCards > 0) return { label: 'Повторить карточки', url: '/flashcards' };
return { label: 'К занятиям', url: '/exam-prep/math9' }; return { label: 'К занятиям', url: '/exam-prep/math9' };
} }
function activeDaysThisWeek() { try { var w = (PET && PET.weeklyXP) || []; return w.length ? w.filter(function (d) { return (d.xp || 0) > 0; }).length : null; } catch (e) { return null; } }
function plural(n, one, few, many) { function plural(n, one, few, many) {
var m10 = n % 10, m100 = n % 100; var m10 = n % 10, m100 = n % 100;
@@ -311,6 +320,16 @@
'.asst-msg-assistant .asst-rich{color:#28324a;}', '.asst-msg-assistant .asst-rich{color:#28324a;}',
'.asst-msg-ph{opacity:.6;}', '.asst-msg-ph{opacity:.6;}',
'.asst-msg-links{align-self:flex-start;font-size:.74rem;}', '.asst-msg-links{align-self:flex-start;font-size:.74rem;}',
'.asst-modes{display:flex;gap:6px;margin:2px 0 8px;}',
'.asst-mode{flex:1;border:1px solid #e2e8f0;background:#f8fafc;border-radius:8px;padding:5px 6px;font:700 .68rem Manrope,sans-serif;color:#475569;cursor:pointer;}',
'.asst-mode.on{background:#9B5DE5;border-color:#9B5DE5;color:#fff;}',
'.asst-src{align-self:flex-start;font-size:.72rem;color:#8a94a6;}',
'.asst-src a{color:#7e3eca;font-weight:700;text-decoration:none;}',
'.asst-fb{align-self:flex-start;display:flex;gap:6px;}',
'.asst-fb button{border:1px solid #e2e8f0;background:#fff;border-radius:7px;width:30px;height:24px;cursor:pointer;color:#8a94a6;display:inline-flex;align-items:center;justify-content:center;}',
'.asst-fb button:hover{border-color:#9B5DE5;color:#9B5DE5;}',
'.asst-fb button.on{border-color:#9B5DE5;color:#9B5DE5;background:rgba(155,93,229,.1);}',
'.asst-fb svg{width:13px;height:13px;}',
'.asst-empty{font-size:.82rem;color:#8a94a6;padding:6px 0;}', '.asst-empty{font-size:.82rem;color:#8a94a6;padding:6px 0;}',
// на мобиле сайдбар — выезжающая шторка, контент во всю ширину → к левому краю // на мобиле сайдбар — выезжающая шторка, контент во всю ширину → к левому краю
'@media(max-width:768px){.asst-root,.app-layout ~ .asst-root,.app-layout.sb-collapsed ~ .asst-root{left:12px;bottom:18px;}.asst-fab{width:48px;height:48px;}}', '@media(max-width:768px){.asst-root,.app-layout ~ .asst-root,.app-layout.sb-collapsed ~ .asst-root{left:12px;bottom:18px;}.asst-fab{width:48px;height:48px;}}',
@@ -455,6 +474,9 @@
}); });
chatEl.scrollTop = chatEl.scrollHeight; chatEl.scrollTop = chatEl.scrollHeight;
} }
var FB_UP = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v11"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>';
var FB_DOWN = '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform:rotate(180deg)"><path d="M7 10v11"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>';
var MODE_PH = { answer: 'Спроси что угодно: «объясни…», «как…»', hint: 'Задача, по которой нужна подсказка…', check: 'Вставь своё решение — проверю…' };
function openAsk(prefill) { function openAsk(prefill) {
var sel = _lastSel, pc = getPageContext(); var sel = _lastSel, pc = getPageContext();
var ctxBtns = ''; var ctxBtns = '';
@@ -465,56 +487,90 @@
var sug = (_role === 'teacher' || _role === 'admin') ? TEACHER_SUGGESTIONS : SUGGESTIONS; var sug = (_role === 'teacher' || _role === 'admin') ? TEACHER_SUGGESTIONS : SUGGESTIONS;
var chips = '<div class="asst-chips">' + ctxBtns + var chips = '<div class="asst-chips">' + ctxBtns +
sug.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>';
var modes = '<div class="asst-modes">' +
'<button class="asst-mode on" data-m="answer">Ответ</button>' +
'<button class="asst-mode" data-m="hint">Подсказка</button>' +
'<button class="asst-mode" data-m="check">Проверить решение</button></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 + modes +
'<input class="asst-ask-in" type="text" placeholder="Спроси что угодно: «объясни…», «как…»" maxlength="300" />', {}); '<input class="asst-ask-in" type="text" placeholder="' + MODE_PH.answer + '" maxlength="500" />', {});
var inp = bubble.querySelector('.asst-ask-in'); var inp = bubble.querySelector('.asst-ask-in');
var chatEl = bubble.querySelector('.asst-chat'); var chatEl = bubble.querySelector('.asst-chat');
var chipsEl = bubble.querySelector('.asst-chips'); var chipsEl = bubble.querySelector('.asst-chips');
var mode = 'answer';
renderChat(chatEl); renderChat(chatEl);
if (_chat.length) chipsEl.style.display = 'none'; if (_chat.length) chipsEl.style.display = 'none';
function go(q, context) { if (chipsEl) chipsEl.style.display = 'none'; inp.value = ''; send(q, context, chatEl); } function go(q, context, m) { if (chipsEl) chipsEl.style.display = 'none'; inp.value = ''; send(q, context, chatEl, m || mode); }
inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') go(inp.value); }); inp.addEventListener('keydown', function (e) { if (e.key === 'Enter') go(inp.value); });
bubble.querySelectorAll('.asst-mode').forEach(function (b) {
b.addEventListener('click', function () {
mode = b.getAttribute('data-m');
bubble.querySelectorAll('.asst-mode').forEach(function (x) { x.classList.toggle('on', x === b); });
inp.placeholder = MODE_PH[mode] || MODE_PH.answer; inp.focus();
});
});
bubble.querySelectorAll('.asst-chip').forEach(function (c) { bubble.querySelectorAll('.asst-chip').forEach(function (c) {
c.addEventListener('click', function () { c.addEventListener('click', function () {
var ctx = c.getAttribute('data-ctx'); var ctx = c.getAttribute('data-ctx');
if (ctx === 'sel') return go('Объясни простыми словами и приведи пример.', sel); if (ctx === 'sel') return go('Объясни простыми словами и приведи пример.', sel, 'answer');
if (ctx === 'sec') return go('Объясни простыми словами, о чём этот параграф, и выдели главное.', pc && pc.text); if (ctx === 'sec') return go('Объясни простыми словами, о чём этот параграф, и выдели главное.', pc && pc.text, 'answer');
if (ctx === 'sum') return go('Сделай краткий конспект этого материала: 4–6 главных пунктов.', pc && pc.text); if (ctx === 'sum') return go('Сделай краткий конспект этого материала: 4–6 главных пунктов.', pc && pc.text, 'answer');
if (ctx === 'cards') { if (chipsEl) chipsEl.style.display = 'none'; return makeFlashcards(pc, chatEl); } if (ctx === 'cards') { if (chipsEl) chipsEl.style.display = 'none'; return makeFlashcards(pc, chatEl); }
go(c.textContent); go(c.textContent, null, 'answer');
}); });
}); });
var clr = bubble.querySelector('[data-a="clear"]'); var clr = bubble.querySelector('[data-a="clear"]');
if (clr) clr.onclick = function () { _chat = []; openAsk(); }; if (clr) clr.onclick = function () { _chat = []; openAsk(); };
if (prefill && prefill.q) go(prefill.q, prefill.context); if (prefill && prefill.q) go(prefill.q, prefill.context, prefill.mode);
else inp.focus(); else inp.focus();
} }
function send(q, context, chatEl) { function srcUrl(s) { return '/textbook/' + s.slug + (s.ref ? '#sec-' + s.ref : ''); }
function send(q, context, chatEl, mode) {
q = (q || '').trim(); q = (q || '').trim();
if (q.length < 2) return; if (q.length < 2) return;
var history = _chat.slice(-6); var history = _chat.slice(-6);
_chat.push({ role: 'user', content: q }); _chat.push({ role: 'user', content: q });
var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u); var u = msgEl('user'); u.textContent = q; chatEl.appendChild(u);
var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.textContent = 'Думаю…'; chatEl.appendChild(ph); var ph = msgEl('assistant'); ph.className += ' asst-msg-ph'; ph.textContent = mode === 'check' ? 'Проверяю…' : 'Думаю…'; chatEl.appendChild(ph);
chatEl.scrollTop = chatEl.scrollHeight; chatEl.scrollTop = chatEl.scrollHeight;
Promise.all([ Promise.all([
LS.assistantAsk(q, context, history).catch(function () { return { answers: [] }; }), LS.assistantAsk(q, context, history, mode).catch(function () { return { answers: [] }; }),
(LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }), (LS.globalSearch ? LS.globalSearch(q, 'all', 3) : Promise.resolve({ results: [] })).catch(function () { return { results: [] }; }),
]).then(function (res) { ]).then(function (res) {
ph.remove(); ph.remove();
var model = res[0] && res[0].answer; var model = res[0] && res[0].answer;
var ans = (res[0] && res[0].answers) || []; var ans = (res[0] && res[0].answers) || [];
var sources = (res[0] && res[0].sources) || [];
var found = (res[1] && res[1].results) || []; var found = (res[1] && res[1].results) || [];
var content = model || (ans[0] && (ans[0].q + '\n' + ans[0].a)) || 'Не нашёл точного ответа. Попробуй переформулировать или поищи (Ctrl+K).'; var content = model || (ans[0] && (ans[0].q + '\n' + ans[0].a)) || 'Не нашёл точного ответа. Попробуй переформулировать или поищи (Ctrl+K).';
_chat.push({ role: 'assistant', content: content }); _chat.push({ role: 'assistant', content: content });
var d = msgEl('assistant'); d.innerHTML = '<div class="asst-rich"></div>'; chatEl.appendChild(d); var d = msgEl('assistant'); d.innerHTML = '<div class="asst-rich"></div>'; chatEl.appendChild(d);
renderRich(d.querySelector('.asst-rich'), content); renderRich(d.querySelector('.asst-rich'), content);
// источники (RAG)
if (model && sources.length) {
var sc = document.createElement('div'); sc.className = 'asst-src';
sc.innerHTML = 'Источник: ' + sources.map(function (s) { return '<a href="' + esc(srcUrl(s)) + '">' + esc(s.title) + (s.section ? ', ' + esc(s.section) : '') + '</a>'; }).join('; ');
chatEl.appendChild(sc);
}
// ссылки FAQ/платформа
var links = ''; var links = '';
if (!model && ans.length) links += ans.slice(0, 2).filter(function (a) { return a.url; }).map(function (a) { return '<a class="asst-ans-link" href="' + esc(a.url) + '">' + esc(a.q) + '</a>'; }).join(' · '); if (!model && ans.length) links += ans.slice(0, 2).filter(function (a) { return a.url; }).map(function (a) { return '<a class="asst-ans-link" href="' + esc(a.url) + '">' + esc(a.q) + '</a>'; }).join(' · ');
if (found.length) links += (links ? '<br>' : '') + '<span style="color:#8a94a6">На платформе: </span>' + found.slice(0, 3).map(function (f) { return '<a class="asst-ans-link" href="' + esc(f.url || '#') + '">' + esc(f.title || '…') + '</a>'; }).join(' · '); if (found.length) links += (links ? '<br>' : '') + '<span style="color:#8a94a6">На платформе: </span>' + found.slice(0, 3).map(function (f) { return '<a class="asst-ans-link" href="' + esc(f.url || '#') + '">' + esc(f.title || '…') + '</a>'; }).join(' · ');
if (links) { var l = document.createElement('div'); l.className = 'asst-msg-links'; l.innerHTML = links; chatEl.appendChild(l); } if (links) { var l = document.createElement('div'); l.className = 'asst-msg-links'; l.innerHTML = links; chatEl.appendChild(l); }
// оценка ответа
if (model) {
var fb = document.createElement('div'); fb.className = 'asst-fb';
fb.innerHTML = '<button data-r="1" title="Полезно">' + FB_UP + '</button><button data-r="-1" title="Не помогло">' + FB_DOWN + '</button>';
fb.querySelectorAll('button').forEach(function (b) {
b.addEventListener('click', function () {
if (fb.dataset.done) return; fb.dataset.done = '1';
b.classList.add('on');
try { LS.assistantFeedback(Number(b.getAttribute('data-r')), q); } catch (e) {}
});
});
chatEl.appendChild(fb);
}
chatEl.scrollTop = chatEl.scrollHeight; chatEl.scrollTop = chatEl.scrollHeight;
}).catch(function () { ph.textContent = 'Не удалось получить ответ.'; }); }).catch(function () { ph.textContent = 'Не удалось получить ответ.'; });
} }
@@ -716,7 +772,7 @@
open: function () { if (root) root.querySelector('.asst-fab').click(); }, open: function () { if (root) root.querySelector('.asst-fab').click(); },
tour: function () { startTour(); }, tour: function () { startTour(); },
// открыть «Спроси» с готовым вопросом и контекстом (для интеграций, напр. экзамен) // открыть «Спроси» с готовым вопросом и контекстом (для интеграций, напр. экзамен)
ask: function (q, context) { if (root && bubble) openAsk({ q: q, context: context }); }, ask: function (q, context, opts) { if (root && bubble) openAsk({ q: q, context: context, mode: opts && opts.mode }); },
explainSelection: function () { if (_lastSel) window.Assistant.ask('Объясни простыми словами и приведи пример.', _lastSel); }, explainSelection: function () { if (_lastSel) window.Assistant.ask('Объясни простыми словами и приведи пример.', _lastSel); },
}; };
})(); })();
+10 -1
View File
@@ -135,7 +135,9 @@
? `<div class="tc-sol-panel" data-tc-sol-panel>${task.solution}</div>` : ''; ? `<div class="tc-sol-panel" data-tc-sol-panel>${task.solution}</div>` : '';
const askBtn = `<button class="tc-ask-btn" data-tc-ask title="Спросить Квантика по этой задаче" style="background:none;border:1px solid rgba(155,93,229,.35);border-radius:8px;height:30px;padding:0 10px;cursor:pointer;color:#7e3eca;display:inline-flex;align-items:center;gap:5px;font:inherit;font-size:.78rem;font-weight:600"> const askBtn = `<button class="tc-ask-btn" data-tc-ask title="Спросить Квантика по этой задаче" style="background:none;border:1px solid rgba(155,93,229,.35);border-radius:8px;height:30px;padding:0 10px;cursor:pointer;color:#7e3eca;display:inline-flex;align-items:center;gap:5px;font:inherit;font-size:.78rem;font-weight:600">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 2.5-3 4"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>Спросить Квантика</button>`; <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 2.5-3 4"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>Спросить Квантика</button>`;
const solBlock = `<div class="tc-sol-wrap"><div class="tc-sol-row">${solToggle}${refLink}${saveMatBtn}${askBtn}</div>${solPanelHtml}</div>`; const hintBtn = `<button class="tc-hint-btn" data-tc-hint title="Подсказка от Квантика (не готовый ответ)" style="background:none;border:1px solid rgba(245,158,11,.4);border-radius:8px;height:30px;padding:0 10px;cursor:pointer;color:#b45309;display:inline-flex;align-items:center;gap:5px;font:inherit;font-size:.78rem;font-weight:600">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>Подсказка</button>`;
const solBlock = `<div class="tc-sol-wrap"><div class="tc-sol-row">${solToggle}${refLink}${saveMatBtn}${hintBtn}${askBtn}</div>${solPanelHtml}</div>`;
card.innerHTML = ` card.innerHTML = `
<div class="tc-head"> <div class="tc-head">
@@ -180,6 +182,13 @@
window.Assistant.ask('Объясни решение этой задачи по шагам, понятным языком. Если решение дано — опирайся на него, но изложи понятно.', parts.join('\n')); window.Assistant.ask('Объясни решение этой задачи по шагам, понятным языком. Если решение дано — опирайся на него, но изложи понятно.', parts.join('\n'));
}); });
} }
const hintEl = card.querySelector('[data-tc-hint]');
if (hintEl) {
hintEl.addEventListener('click', () => {
if (!window.Assistant || !window.Assistant.ask) { if (window.LS && LS.toast) LS.toast('Помощник недоступен на этой странице', 'warn'); return; }
window.Assistant.ask('Дай подсказку к этой задаче — наводящий шаг, но НЕ готовый ответ.', 'Задание: ' + stripHtml(task.text), { mode: 'hint' });
});
}
// ── State // ── State
let startedAt = Date.now(); let startedAt = Date.now();
+3 -2
View File
@@ -1050,7 +1050,7 @@ window.LS = {
crAdminGetAllHistory, crAdminGetTeachersList, crAdminGetAllHistory, crAdminGetTeachersList,
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, assistantFeedback,
adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks,
fcListDecks, fcCreateDeck, fcAddCard, fcListDecks, fcCreateDeck, fcAddCard,
escapeHtml, esc, escapeHtml, esc,
@@ -1273,8 +1273,9 @@ async function assistantContext() { return req('GET', '/assistant/context'
async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); } async function assistantSeen(ruleId) { return req('POST', '/assistant/seen', { ruleId }); }
async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); } async function assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); }
async function assistantSettings(d) { return req('PATCH', '/assistant/settings', d); } async function assistantSettings(d) { return req('PATCH', '/assistant/settings', d); }
async function assistantAsk(q, context, history) { return req('POST', '/assistant/ask', { q, context: context || undefined, history: history || undefined }); } async function assistantAsk(q, context, history, mode) { return req('POST', '/assistant/ask', { q, context: context || undefined, history: history || undefined, mode: mode || undefined }); }
async function assistantFlashcards(text, title) { return req('POST', '/assistant/flashcards', { text, title }); } async function assistantFlashcards(text, title) { return req('POST', '/assistant/flashcards', { text, title }); }
async function assistantFeedback(rating, q) { return req('POST', '/assistant/feedback', { rating, q: q || undefined }); }
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 || {}); }