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:
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
+69
-13
@@ -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 = '<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) {
|
||||
var sel = _lastSel, pc = getPageContext();
|
||||
var ctxBtns = '';
|
||||
@@ -465,56 +487,90 @@
|
||||
var sug = (_role === 'teacher' || _role === 'admin') ? TEACHER_SUGGESTIONS : SUGGESTIONS;
|
||||
var chips = '<div class="asst-chips">' + ctxBtns +
|
||||
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(
|
||||
'<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 +
|
||||
'<input class="asst-ask-in" type="text" placeholder="Спроси что угодно: «объясни…», «как…»" maxlength="300" />', {});
|
||||
'<div class="asst-chat"></div>' + chips + modes +
|
||||
'<input class="asst-ask-in" type="text" placeholder="' + MODE_PH.answer + '" maxlength="500" />', {});
|
||||
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 = '<div class="asst-rich"></div>'; 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 '<a href="' + esc(srcUrl(s)) + '">' + esc(s.title) + (s.section ? ', ' + esc(s.section) : '') + '</a>'; }).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 '<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 (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;
|
||||
}).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); },
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -135,7 +135,9 @@
|
||||
? `<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">
|
||||
<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 = `
|
||||
<div class="tc-head">
|
||||
@@ -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();
|
||||
|
||||
@@ -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 || {}); }
|
||||
|
||||
Reference in New Issue
Block a user