479c621e2e
- Ответы модели рендерятся как markdown + формулы KaTeX (ленивая загрузка), модель просим оформлять формулы в LaTeX $...$. - «Объясни это»: ask принимает context; кнопки «Объяснить выделенное» (запоминаем выделение) и «Объяснить/Конспект параграфа» на учебнике. - Репетитор на экзамене: кнопка «Спросить Квантика» на карточке задания → Assistant.ask с условием/ответом/решением как контекстом. - Быстрые действия: «Флешкарты из параграфа» → POST /api/assistant/flashcards (модель → JSON, починка обрезанного) → колода через существующий API флешкарт. - Экспорт Assistant.ask(q,context) / explainSelection(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
393 lines
19 KiB
JavaScript
393 lines
19 KiB
JavaScript
'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 => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[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 solBlock = `<div class="tc-sol-wrap"><div class="tc-sol-row">${solToggle}${refLink}${saveMatBtn}${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'));
|
||
});
|
||
}
|
||
|
||
// ── 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 };
|
||
})();
|