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
+69 -13
View File
@@ -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); },
};
})();
+10 -1
View File
@@ -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();