From 4224a2209281c063dbf40b5903421493a1ef2e99 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 4 Jun 2026 19:38:47 +0300 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20=D0=B8=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B8=D0=BA=D0=B8=20=D0=B2=20=D0=BE=D1=82=D0=B2?= =?UTF-8?q?=D0=B5=D1=82=D0=B0=D1=85,=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC-?= =?UTF-8?q?=D0=BD=D0=B0=D1=81=D1=82=D0=B0=D0=B2=D0=BD=D0=B8=D0=BA,=20?= =?UTF-8?q?=D0=BE=D1=86=D0=B5=D0=BD=D0=BA=D0=B8,=20=D1=83=D1=82=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=B8=D0=B9=20=D0=B1=D1=80=D0=B8=D1=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Источники: 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) --- backend/scripts/index-textbooks-headless.js | 6 +- backend/scripts/index-textbooks.js | 6 +- backend/src/controllers/adminController.js | 8 +- .../src/controllers/assistantController.js | 58 +++++++++---- .../064_assistant_sources_feedback.sql | 18 ++++ backend/src/routes/assistant.js | 1 + frontend/js/assistant.js | 82 ++++++++++++++++--- frontend/js/exam-prep/task-card.js | 11 ++- js/api.js | 5 +- 9 files changed, 155 insertions(+), 40 deletions(-) create mode 100644 backend/src/db/migrations/064_assistant_sources_feedback.sql diff --git a/backend/scripts/index-textbooks-headless.js b/backend/scripts/index-textbooks-headless.js index d4247f8..d4e7d80 100644 --- a/backend/scripts/index-textbooks-headless.js +++ b/backend/scripts/index-textbooks-headless.js @@ -36,7 +36,7 @@ async function run() { 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 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(); 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 sleep(550); 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) {} } } else { @@ -70,7 +70,7 @@ async function run() { if (chunks.length) { 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; console.log(` ${b.slug}: ${chunks.length}`); } else { diff --git a/backend/scripts/index-textbooks.js b/backend/scripts/index-textbooks.js index 8d5db27..767933e 100644 --- a/backend/scripts/index-textbooks.js +++ b/backend/scripts/index-textbooks.js @@ -48,17 +48,19 @@ 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'); + // Замещаем чанки только тех книг, что реально распарсились — не трогаем + // данные, наполненные headless-индексатором (JS-рендеримые учебники). 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); + 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++; } } return { books: books.length, files, chunks: total }; diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index 4725e62..4c19e04 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -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(); if (s) usage30 = s; } 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({ 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, }); } diff --git a/backend/src/controllers/assistantController.js b/backend/src/controllers/assistantController.js index 3b4c852..f3046a5 100644 --- a/backend/src/controllers/assistantController.js +++ b/backend/src/controllers/assistantController.js @@ -237,21 +237,27 @@ function llmConfig() { return { url, key, model, local, on: !!(key || local) }; } -/* RAG: релевантные куски учебников (textbook_chunks) под вопрос. */ +/* RAG: релевантные куски учебников (textbook_chunks) под вопрос. + * Возвращает { text, sources:[{slug,title,section,ref}] } для цитирования. */ function ragContext(q) { + const empty = { text: '', sources: [] }; 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); - if (!words.length) return ''; + if (!words.length) return empty; 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 ''; + 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 empty; 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 top = rows.filter(r => r._s >= need).slice(0, 2); + if (!top.length) return empty; + 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$. Не используй эмодзи.'; -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 user = (context ? `Контекст (опирайся на него, если относится к вопросу):\n${context}\n\n` : '') + `Справка по платформе:\n${ref}\n\nВопрос: ${q}`; @@ -324,6 +330,11 @@ async function askModel(q, hits, context, history, role) { sys += ' Пользователь — учитель: помогай и с преподаванием — составить задание или вопросы, план урока, ' + 'объяснить, как пользоваться учительскими инструментами (классы, журнал, аналитика, онлайн-урок, банк вопросов).'; } + if (mode === 'hint') { + sys += ' РЕЖИМ ПОДСКАЗКИ: дай ТОЛЬКО наводящую подсказку или следующий шаг к решению. Не давай готовый ответ — пусть ученик додумает сам.'; + } else if (mode === 'check') { + 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 }); @@ -337,36 +348,47 @@ 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 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); 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 }); } + if (!llmConfig().on) { bumpUsage('faq'); return res.json({ source: 'faq', answer: null, answers: faqJson, sources: [] }); } - // Кэш — только для «чистых» вопросов (без контекста страницы и без истории диалога) - const cacheable = !pageCtx && !history.length; + const rag = ragContext(q); + + // Кэш — только обычный режим без контекста страницы и без истории диалога + const cacheable = mode === 'answer' && !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 }); } + if (c) { bumpUsage('cache_hits'); return res.json({ source: 'model', answer: c.answer, answers: faqJson, sources: rag.sources, cached: true }); } } catch (e) {} } - const rag = ragContext(q); 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; - 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) { 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 }); + 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? } ───────────────────── @@ -403,4 +425,4 @@ async function flashcardsFromText(req, res) { 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 }; diff --git a/backend/src/db/migrations/064_assistant_sources_feedback.sql b/backend/src/db/migrations/064_assistant_sources_feedback.sql new file mode 100644 index 0000000..cdfc22f --- /dev/null +++ b/backend/src/db/migrations/064_assistant_sources_feedback.sql @@ -0,0 +1,18 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 064: Ассистент — источники в ответах + оценки качества +-- +-- section_ref — id параграфа (sec-) у куска учебника, чтобы под RAG-ответом +-- давать ссылку «по учебнику X, §N» прямо на параграф (/textbook/#sec-). +-- 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); diff --git a/backend/src/routes/assistant.js b/backend/src/routes/assistant.js index 302a437..cdd28b6 100644 --- a/backend/src/routes/assistant.js +++ b/backend/src/routes/assistant.js @@ -13,5 +13,6 @@ router.post('/dismiss', ctrl.dismiss); router.patch('/settings', ctrl.setSettings); router.post('/ask', ctrl.ask); router.post('/flashcards', ctrl.flashcardsFromText); +router.post('/feedback', ctrl.feedback); module.exports = router; diff --git a/frontend/js/assistant.js b/frontend/js/assistant.js index 8a6ece4..91a3d40 100644 --- a/frontend/js/assistant.js +++ b/frontend/js/assistant.js @@ -101,8 +101,16 @@ when: function () { return !!(SRV && SRV.weakSubject); }, text: function () { var w = SRV.weakSubject; return 'Слабее всего идёт ' + (w.name || 'предмет') + ' (' + w.avg + '%). Потренируемся?'; }, 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, - 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(', ') + '. Начнём?'; }, action: function () { var p = dailyPlanAction(); return p; } }, ]; @@ -119,6 +127,7 @@ if (SRV && SRV.dueCards > 0) return { label: 'Повторить карточки', url: '/flashcards' }; 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) { var m10 = n % 10, m100 = n % 100; @@ -311,6 +320,16 @@ '.asst-msg-assistant .asst-rich{color:#28324a;}', '.asst-msg-ph{opacity:.6;}', '.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;}', // на мобиле сайдбар — выезжающая шторка, контент во всю ширину → к левому краю '@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; } + var FB_UP = ''; + var FB_DOWN = ''; + var MODE_PH = { answer: 'Спроси что угодно: «объясни…», «как…»', hint: 'Задача, по которой нужна подсказка…', check: 'Вставь своё решение — проверю…' }; function openAsk(prefill) { var sel = _lastSel, pc = getPageContext(); var ctxBtns = ''; @@ -465,56 +487,90 @@ var sug = (_role === 'teacher' || _role === 'admin') ? TEACHER_SUGGESTIONS : SUGGESTIONS; var chips = '
' + ctxBtns + sug.map(function (q) { return ''; }).join('') + '
'; + var modes = '
' + + '' + + '' + + '
'; openBubble( '
Спроси Квантика' + (_chat.length ? '' : '') + '
' + - '
' + chips + - '', {}); + '
' + chips + modes + + '', {}); var inp = bubble.querySelector('.asst-ask-in'); var chatEl = bubble.querySelector('.asst-chat'); var chipsEl = bubble.querySelector('.asst-chips'); + var mode = 'answer'; renderChat(chatEl); 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); }); + 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) { c.addEventListener('click', function () { var ctx = c.getAttribute('data-ctx'); - if (ctx === 'sel') return go('Объясни простыми словами и приведи пример.', sel); - if (ctx === 'sec') return go('Объясни простыми словами, о чём этот параграф, и выдели главное.', pc && pc.text); - if (ctx === 'sum') return go('Сделай краткий конспект этого материала: 4–6 главных пунктов.', pc && pc.text); + if (ctx === 'sel') return go('Объясни простыми словами и приведи пример.', sel, 'answer'); + if (ctx === 'sec') return go('Объясни простыми словами, о чём этот параграф, и выдели главное.', pc && pc.text, 'answer'); + if (ctx === 'sum') return go('Сделай краткий конспект этого материала: 4–6 главных пунктов.', pc && pc.text, 'answer'); 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"]'); 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(); } - 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(); if (q.length < 2) return; var history = _chat.slice(-6); _chat.push({ role: 'user', content: q }); 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; 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: [] }; }), ]).then(function (res) { ph.remove(); var model = res[0] && res[0].answer; var ans = (res[0] && res[0].answers) || []; + var sources = (res[0] && res[0].sources) || []; var found = (res[1] && res[1].results) || []; var content = model || (ans[0] && (ans[0].q + '\n' + ans[0].a)) || 'Не нашёл точного ответа. Попробуй переформулировать или поищи (Ctrl+K).'; _chat.push({ role: 'assistant', content: content }); var d = msgEl('assistant'); d.innerHTML = '
'; chatEl.appendChild(d); 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 '' + esc(s.title) + (s.section ? ', ' + esc(s.section) : '') + ''; }).join('; '); + chatEl.appendChild(sc); + } + // ссылки FAQ/платформа var links = ''; if (!model && ans.length) links += ans.slice(0, 2).filter(function (a) { return a.url; }).map(function (a) { return '' + esc(a.q) + ''; }).join(' · '); if (found.length) links += (links ? '
' : '') + 'На платформе: ' + found.slice(0, 3).map(function (f) { return '' + esc(f.title || '…') + ''; }).join(' · '); 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 = ''; + 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; }).catch(function () { ph.textContent = 'Не удалось получить ответ.'; }); } @@ -716,7 +772,7 @@ open: function () { if (root) root.querySelector('.asst-fab').click(); }, 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); }, }; })(); diff --git a/frontend/js/exam-prep/task-card.js b/frontend/js/exam-prep/task-card.js index 36b5739..15415fc 100644 --- a/frontend/js/exam-prep/task-card.js +++ b/frontend/js/exam-prep/task-card.js @@ -135,7 +135,9 @@ ? `
${task.solution}
` : ''; const askBtn = ``; - const solBlock = `
${solToggle}${refLink}${saveMatBtn}${askBtn}
${solPanelHtml}
`; + const hintBtn = ``; + const solBlock = `
${solToggle}${refLink}${saveMatBtn}${hintBtn}${askBtn}
${solPanelHtml}
`; card.innerHTML = `
@@ -180,6 +182,13 @@ 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 let startedAt = Date.now(); diff --git a/js/api.js b/js/api.js index e25f917..eb44903 100644 --- a/js/api.js +++ b/js/api.js @@ -1050,7 +1050,7 @@ window.LS = { crAdminGetAllHistory, crAdminGetTeachersList, listMaterials, saveMaterial, updateMaterial, deleteMaterial, shareMaterial, getActivity, createMaterialCollection, updateMaterialCollection, deleteMaterialCollection, - assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, + assistantContext, assistantSeen, assistantDismiss, assistantSettings, assistantAsk, assistantFlashcards, assistantFeedback, adminGetAssistant, adminSaveAssistant, adminTestAssistant, adminReindexTextbooks, fcListDecks, fcCreateDeck, fcAddCard, 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 assistantDismiss(rid) { return req('POST', '/assistant/dismiss', { ruleId: rid }); } 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 assistantFeedback(rating, q) { return req('POST', '/assistant/feedback', { rating, q: q || undefined }); } async function adminGetAssistant() { return req('GET', '/admin/assistant'); } async function adminSaveAssistant(d) { return req('PUT', '/admin/assistant', d); } async function adminTestAssistant(d) { return req('POST', '/admin/assistant/test', d || {}); }