Files
Learn_System/frontend/js/exam-prep/task-card.js
T
Maxim Dolgolyov 4224a22092 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>
2026-06-04 19:38:47 +03:00

402 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ──────────────────────────────────────────────────────────────────
TaskCard component — reusable across variants / practice / topic /
mock views. Renders ONE task with answer input, check button, and
solution toggle.
Public API:
EP.TaskCard.render(container, task, opts)
container : HTMLElement
task : { id, idx, type, text, figure, opts, answer, solution }
opts : {
mode : 'variant'|'practice'|'topic'|'mock'
sessionId : number | null (groups attempts in a session)
autoCheck : true (whether check button + verdict UI is shown)
showSolution : true (whether solution toggle exists)
readonly : false (inputs disabled; for finished mock review)
prefillAnswer : string | null (initial user_answer; for mock review)
forceVerdict : {isCorrect:0|1} | null (show verdict badge as if checked)
onAnswerChange : (taskId, value) => void (per-keystroke, debounced; mock auto-save)
onAttempt : (result) => void (notify parent after check or solution-view)
numbering : number | null (override task number badge)
}
────────────────────────────────────────────────────────────────── */
(function () {
const ICONS = {
chev: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>',
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>',
cross: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
rotate: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><polyline points="3 3 3 8 8 8"/></svg>',
};
function escapeHtml(s) {
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;', "'":'&#39;' }[c]));
}
/* HTML → readable plain text (keeps $…$ math source) for saving to materials. */
function stripHtml(s) {
return String(s || '').replace(/<svg[\s\S]*?<\/svg>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
/* topic_ref → "Учить тему" deep-link to the textbook chapter/paragraph.
ref = { slug, paragraph, title }. Paragraph is null for hub links. */
function buildRefLink(ref) {
if (!ref || !ref.slug) return '';
const href = ref.paragraph
? `/textbook/${encodeURIComponent(ref.slug)}#sec-p${ref.paragraph}`
: `/textbook/${encodeURIComponent(ref.slug)}`;
const label = ref.paragraph ? ${ref.paragraph}` : 'учебник';
const title = ref.title ? `Учить тему: ${escapeHtml(ref.title)}` : 'Перейти к материалу';
return `<a class="tc-ref-btn" href="${href}" target="_blank" rel="noopener" title="${title}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
</svg>
<span>Учить тему · ${escapeHtml(label)}</span>
</a>`;
}
function buildOptsBlock(taskId, opts) {
const isLong = opts.some(([, t]) => t.length > 40 && !t.startsWith('$'));
const cls = isLong ? 'tc-opts tc-opts-vertical' : 'tc-opts';
const name = `tc-opt-${taskId}`;
return `<div class="${cls}" data-tc-mc>` + opts.map(([l, t]) => `
<label class="tc-opt">
<input type="radio" name="${name}" value="${escapeHtml(l)}" />
<span class="tc-opt-lbl">${escapeHtml(l)})</span>
<span class="tc-opt-text">${t}</span>
</label>
`).join('') + `</div>`;
}
/* Render one task into `container`. Returns a controller object with .destroy(). */
function render(container, task, opts = {}) {
const mode = opts.mode || 'variant';
const autoCheck = opts.autoCheck !== false;
const showSol = opts.showSolution !== false;
const readonly = !!opts.readonly;
const numbering = (opts.numbering != null) ? opts.numbering : task.idx;
const sessionId = opts.sessionId || null;
const onAttempt = opts.onAttempt || (() => {});
const onAnswerChange = opts.onAnswerChange || null;
const card = document.createElement('div');
card.className = 'tc-card';
card.dataset.taskId = String(task.id);
card.dataset.taskType = task.type;
// ── Build input area by task type × autoCheck combo
let inputBlock = '';
const verdictSlot = `<div class="tc-verdict" data-tc-verdict hidden></div>`;
const checkBtnRow = autoCheck
? `<div class="tc-action-row">
<button class="tc-check-btn" data-tc-check disabled>Проверить</button>
${verdictSlot}
</div>`
: verdictSlot;
if (task.type === 'mc' && task.opts) {
inputBlock = buildOptsBlock(task.id, task.opts) + checkBtnRow;
} else if (task.type === 'open') {
inputBlock = `
<div class="tc-input-row">
<label class="tc-ans-label">Ответ:</label>
<input class="tc-ans-input" type="text" autocomplete="off" inputmode="text"
placeholder="например, 9/4 или -2" data-tc-text />
</div>` + checkBtnRow;
} else if (task.type === 'long' && autoCheck) {
inputBlock = `
<div class="tc-self-mark">
<span class="tc-self-mark-label">Развёрнутый ответ — проверьте себя по решению, затем отметьте:</span>
<div class="tc-self-mark-btns">
<button class="tc-self-btn tc-self-yes" data-tc-self="1">${ICONS.check} Я решил</button>
<button class="tc-self-btn tc-self-no" data-tc-self="0">${ICONS.cross} Не решил</button>
</div>
</div>`;
} else if (task.type === 'long' && !autoCheck) {
// Mock + long: short free-text answer
inputBlock = `
<div class="tc-input-row">
<label class="tc-ans-label">Ответ:</label>
<input class="tc-ans-input" type="text" autocomplete="off" inputmode="text"
placeholder="кратко ваш ответ" data-tc-text />
</div>` + verdictSlot;
}
const refLink = buildRefLink(task.topic_ref);
const allowSave = mode !== 'mock';
const saveMatBtn = allowSave
? `<button class="tc-savemat" data-tc-savemat title="Сохранить задание в «Мои материалы»" style="margin-left:auto;background:none;border:1px solid var(--border,#e2e8f0);border-radius:8px;height:30px;padding:0 10px;cursor:pointer;color:var(--text-3,#64748b);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="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>В мои материалы</button>`
: '';
const solToggle = (showSol && task.solution)
? `<button class="tc-sol-btn" data-tc-sol>${ICONS.chev}<span>Показать решение</span></button>` : '';
const solPanelHtml = (showSol && task.solution)
? `<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 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">
<div class="tc-num">${numbering}</div>
<div class="tc-num-label">Задание ${numbering}</div>
<span class="tc-type-badge tc-type-${task.type}">${typeLabel(task.type)}</span>
</div>
<div class="tc-body">
<div class="tc-text">${task.text}</div>
${task.figure ? `<div class="tc-figure">${task.figure}</div>` : ''}
${inputBlock}
</div>
${solBlock}
`;
container.appendChild(card);
// ── KaTeX render
EP.katex?.run(card);
// ── Save task to «Мои материалы» (universal buffer)
const saveMatEl = card.querySelector('[data-tc-savemat]');
if (saveMatEl) {
saveMatEl.addEventListener('click', () => {
if (!window.MaterialSave) return;
const examTitle = (window.EP && EP.info && EP.info.track && EP.info.track.title) || 'Экзамен';
const loc = (task.variant != null ? 'Вариант ' + task.variant + ', ' : '') + '№' + (task.idx != null ? task.idx : numbering);
const parts = [stripHtml(task.text)];
if (task.answer) parts.push('Ответ: ' + task.answer);
if (task.solution) parts.push('Решение: ' + stripHtml(task.solution));
MaterialSave.note({ title: examTitle + ' · ' + loc, body: parts.join('\n\n'), sourceTitle: examTitle }, saveMatEl);
});
}
// ── Спросить Квантика по этой задаче (репетитор)
const askEl = card.querySelector('[data-tc-ask]');
if (askEl) {
askEl.addEventListener('click', () => {
if (!window.Assistant || !window.Assistant.ask) { if (window.LS && LS.toast) LS.toast('Помощник недоступен на этой странице', 'warn'); return; }
const parts = ['Задание: ' + stripHtml(task.text)];
if (task.answer) parts.push('Правильный ответ: ' + task.answer);
if (task.solution) parts.push('Решение: ' + stripHtml(task.solution));
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();
let solutionLogged = false;
let solutionRendered = false;
let attemptCount = 0; // how many CHECK attempts made
let firstAttemptCorrect = null; // we report this in onAttempt
// ── Prefill answer (mock review or resumed mock session)
if (opts.prefillAnswer != null) {
const text = card.querySelector('[data-tc-text]');
if (text) text.value = String(opts.prefillAnswer);
const radios = card.querySelectorAll('input[type="radio"]');
radios.forEach(r => { if (r.value === String(opts.prefillAnswer)) r.checked = true; });
}
// ── Force verdict (mock review: server has graded; show result without check button)
if (opts.forceVerdict && opts.forceVerdict.isCorrect != null) {
applyVerdictReadonly(opts.forceVerdict.isCorrect);
}
// ── Readonly: disable all inputs (mock review)
if (readonly) {
card.querySelectorAll('input').forEach(el => el.disabled = true);
card.dataset.tcLocked = '1';
}
// ── Input enable on first interaction + auto-save (mock)
const inputs = card.querySelectorAll('input[type="radio"], [data-tc-text]');
const checkBtn = card.querySelector('[data-tc-check]');
let saveDebounce = null;
inputs.forEach(inp => {
inp.addEventListener('input', () => { updateCheckEnabled(); maybeAutoSave(); });
inp.addEventListener('change', () => { updateCheckEnabled(); maybeAutoSave(); });
});
function updateCheckEnabled() {
if (!checkBtn) return;
const has = readUserAnswer() !== null;
checkBtn.disabled = !has || card.dataset.tcLocked === '1';
}
function maybeAutoSave() {
if (!onAnswerChange || readonly) return;
const v = readUserAnswer();
// debounce per-keystroke text input; radio changes save immediately
clearTimeout(saveDebounce);
const delay = card.querySelector('input[type="radio"]:checked') ? 0 : 450;
saveDebounce = setTimeout(() => onAnswerChange(task.id, v), delay);
}
function applyVerdictReadonly(isCorrect) {
card.classList.add(isCorrect ? 'tc-correct' : 'tc-wrong');
const verdict = card.querySelector('[data-tc-verdict]');
if (verdict) {
verdict.hidden = false;
verdict.innerHTML = isCorrect
? `<span class="tc-verdict-ok">${ICONS.check} Правильно</span>`
: `<span class="tc-verdict-bad">${ICONS.cross} Неправильно</span>`;
}
}
function readUserAnswer() {
const mcGroup = card.querySelector('[data-tc-mc]');
if (mcGroup) {
const picked = mcGroup.querySelector('input[type="radio"]:checked');
return picked ? picked.value : null;
}
const text = card.querySelector('[data-tc-text]');
if (text) {
const v = text.value.trim();
return v || null;
}
return null;
}
// ── Check action
if (checkBtn) {
checkBtn.addEventListener('click', () => {
const userAnswer = readUserAnswer();
if (userAnswer == null) return;
const isCorrect = EP.answer.check(userAnswer, task.answer) ? 1 : 0;
attemptCount++;
if (firstAttemptCorrect === null) firstAttemptCorrect = isCorrect;
applyVerdict(isCorrect, userAnswer);
sendAttempt({ user_answer: userAnswer, is_correct: isCorrect });
onAttempt({ taskId: task.id, isCorrect, userAnswer, attempt: attemptCount });
});
}
function applyVerdict(isCorrect, userAnswer) {
const verdict = card.querySelector('[data-tc-verdict]');
card.classList.remove('tc-correct', 'tc-wrong');
card.classList.add(isCorrect ? 'tc-correct' : 'tc-wrong');
if (verdict) {
verdict.hidden = false;
verdict.innerHTML = isCorrect
? `<span class="tc-verdict-ok">${ICONS.check} Правильно</span>`
: `<span class="tc-verdict-bad">${ICONS.cross} Неправильно</span>
<button class="tc-retry-btn" data-tc-retry>${ICONS.rotate} Попробовать ещё</button>`;
}
if (isCorrect) {
// Lock inputs + auto-open solution
card.querySelectorAll('input').forEach(el => el.disabled = true);
checkBtn.disabled = true;
card.dataset.tcLocked = '1';
autoOpenSolution();
} else {
// Allow retry
const retry = card.querySelector('[data-tc-retry]');
if (retry) retry.addEventListener('click', () => resetForRetry());
}
}
function resetForRetry() {
const verdict = card.querySelector('[data-tc-verdict]');
card.classList.remove('tc-wrong');
if (verdict) { verdict.hidden = true; verdict.innerHTML = ''; }
startedAt = Date.now();
}
function autoOpenSolution() {
const btn = card.querySelector('[data-tc-sol]');
const panel = card.querySelector('[data-tc-sol-panel]');
if (btn && panel && !panel.classList.contains('visible')) {
toggleSolution(btn, panel);
}
}
// ── Solution toggle (manual)
const solBtn = card.querySelector('[data-tc-sol]');
const solPanel = card.querySelector('[data-tc-sol-panel]');
if (solBtn && solPanel) {
solBtn.addEventListener('click', () => toggleSolution(solBtn, solPanel));
}
function toggleSolution(btn, panel) {
const open = panel.classList.contains('visible');
panel.classList.toggle('visible', !open);
btn.classList.toggle('open', !open);
btn.querySelector('span').textContent = open ? 'Показать решение' : 'Скрыть решение';
if (!open) {
if (!solutionRendered) {
EP.katex?.run(panel);
solutionRendered = true;
}
if (!solutionLogged) {
solutionLogged = true;
sendAttempt({ solution_viewed: 1 });
onAttempt({ taskId: task.id, solutionViewed: true });
}
}
}
// ── Long: self-mark buttons
card.querySelectorAll('[data-tc-self]').forEach(btn => {
btn.addEventListener('click', () => {
const val = btn.dataset.tcSelf === '1' ? 1 : 0;
attemptCount++;
if (firstAttemptCorrect === null) firstAttemptCorrect = val;
card.classList.remove('tc-correct', 'tc-wrong');
card.classList.add(val ? 'tc-correct' : 'tc-wrong');
card.querySelectorAll('[data-tc-self]').forEach(b => b.disabled = true);
sendAttempt({ is_correct: val });
onAttempt({ taskId: task.id, isCorrect: val, attempt: attemptCount });
});
});
function sendAttempt(partial) {
const body = Object.assign({
exam_task_id: task.id,
mode,
session_id: sessionId,
time_ms: Math.min(Date.now() - startedAt, 24 * 3600 * 1000),
user_answer: null,
is_correct: null,
hint_used: 0,
solution_viewed:0,
}, partial);
// Best-effort — UI doesn't block on this
EP.api.saveAttempt(body).catch(() => {});
}
// ── Review mode: auto-open the solution panel for instant context
if (opts.forceVerdict && showSol) {
const sb = card.querySelector('[data-tc-sol]');
const sp = card.querySelector('[data-tc-sol-panel]');
if (sb && sp && !sp.classList.contains('visible')) {
sp.classList.add('visible');
sb.classList.add('open');
sb.querySelector('span').textContent = 'Скрыть решение';
EP.katex?.run(sp);
}
}
return {
el: card,
destroy: () => card.remove(),
};
}
function typeLabel(type) {
if (type === 'mc') return 'выбор';
if (type === 'open') return 'кр. ответ';
return 'развёрнут.';
}
window.EP = window.EP || {};
window.EP.TaskCard = { render };
})();