feat(trainer): P7 пошаговое решение (репетитор) + P8 мат-клавиатура

- движок checkStep(problem, line): шаг = равносильное уравнение (держится во всех корнях И не выполняется в не-корнях) → ловит арифметику, потерю корня, тождество; статусы equivalent/solved/wrong/identity/parse
- страница: тумблер «Решить по шагам» (kind solve), ввод и проверка каждого шага, список принятых шагов (KaTeX + галочка), подсказка следующего шага, завершение по solved-форме; общий onSolved; stepPref между задачами
- P8: экранная мат-клавиатура (( ) x / ^ √ ; ⌫, вставка в курсор, без либ) + live-превью KaTeX; для поля ответа и поля шага
- ROADMAP_V2: P7+P8 → DONE; смоук движка 300/300 (T14 checkStep), страница 33/33 (шаг-сценарии)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-25 15:06:46 +03:00
parent 10c9b007d8
commit 277bddf1fd
3 changed files with 315 additions and 10 deletions
+59
View File
@@ -413,11 +413,70 @@
return { ok: se.ok, reason: se.ok ? null : (se.reason || 'wrong'), value: raw, message: se.ok ? 'Верно!' : 'Пока неверно.' };
}
/* ── Пошаговое решение (репетитор): проверка одного шага-равенства ──
Шаг = равносильное уравнение (то же множество корней). Идея без решения
уравнений: уравнение L=R равносильно исходному ⟺ выполняется во ВСЕХ корнях
и НЕ выполняется в точках-не-корнях (то есть сужает x именно до корней).
Ловит арифметику (не держится в корне), потерю корня и тождество «0=0». */
function _splitEq(s) {
var i = String(s).indexOf('=');
if (i <= 0 || i >= s.length - 1) return null;
if (s.indexOf('=', i + 1) !== -1) return null; // нет цепочек a=b=c и составных ==,<=,>=
return [s.slice(0, i).trim(), s.slice(i + 1).trim()];
}
function _isConst(c, v) {
var e1 = {}, e2 = {}; e1[v] = 1.3; e2[v] = 2.7;
return Math.abs(c.fn(e1) - c.fn(e2)) < 1e-9;
}
function _isVarOnly(s, v) { return String(s).replace(/\s+/g, '') === v; }
function _isSolvedForm(lhs, rhs, v, roots) {
var cl = SE().compile(lhs), cr = SE().compile(rhs);
if (cl.error || cr.error) return false;
var lv = _isVarOnly(lhs, v), rv = _isVarOnly(rhs, v);
if (lv && _isConst(cr, v)) { var a = cr.fn({}); return roots.some(function (r) { return Math.abs(a - r) <= 1e-6; }); }
if (rv && _isConst(cl, v)) { var b = cl.fn({}); return roots.some(function (r) { return Math.abs(b - r) <= 1e-6; }); }
return false;
}
function checkStep(problem, line) {
var raw = String(line == null ? '' : line).trim();
if (!raw) return { ok: false, status: 'empty', message: 'Введите шаг — равенство со знаком «=».' };
var parts = _splitEq(raw);
if (!parts) return { ok: false, status: 'parse', message: 'Шаг — это одно равенство со знаком «=».' };
var cl = SE().compile(parts[0]), cr = SE().compile(parts[1]);
if (cl.error || cr.error) return { ok: false, status: 'parse', message: 'Не понял выражение в шаге.' };
var v = problem.answerVar || 'x';
var roots = (problem.answers && problem.answers.length) ? problem.answers : [problem.answer];
// держится во всех корнях?
for (var i = 0; i < roots.length; i++) {
var env = {}; env[v] = roots[i];
var L = cl.fn(env), R = cr.fn(env);
if (Math.abs(L - R) > 1e-7 * Math.max(1, Math.abs(L), Math.abs(R)))
return { ok: false, status: 'wrong', message: 'Не равносильно: при ' + v + ' = ' + fmtNum(roots[i]) + ' равенство не выполняется.' };
}
// сужает x до корней? (в не-корнях должно НЕ выполняться)
var total = 0, holds = 0;
for (var j = 0; j < _EQUIV_PTS.length; j++) {
var x = _EQUIV_PTS[j];
if (roots.some(function (r) { return Math.abs(x - r) < 1e-6; })) continue;
total++; var e2 = {}; e2[v] = x;
var L2 = cl.fn(e2), R2 = cr.fn(e2);
if (Math.abs(L2 - R2) <= 1e-7 * Math.max(1, Math.abs(L2), Math.abs(R2))) holds++;
}
if (total > 0 && holds === total)
return { ok: false, status: 'identity', message: 'Это тождество — верно при любом ' + v + ' и не приближает к ответу.' };
var done = _isSolvedForm(parts[0], parts[1], v, roots);
return { ok: true, status: done ? 'solved' : 'equivalent', message: done ? 'Готово!' : 'Верный шаг.' };
}
global.TrainerEngine = {
instantiate: instantiate,
generateBatch: generateBatch,
verifyRoot: verifyRoot,
checkStudentAnswer: checkStudentAnswer,
checkStep: checkStep,
makeRng: makeRng,
// мелочи наружу для билдера/тестов
render: render,
+167 -10
View File
@@ -120,7 +120,25 @@
.tr-feedback.bad { color: #b91c1c; background: var(--bad-soft); }
.tr-feedback.warn { color: var(--warn); background: #fef3c7; font-weight: 600; }
.tr-actions { display: flex; flex-wrap: wrap; gap: 9px; justify-content: center; margin-top: 14px; }
.tr-actions { display: flex; flex-wrap: wrap; gap: 9px; justify-content: center; margin-top: 16px; }
/* ── мат-клавиатура + live-превью (P8) ── */
.tr-keypad { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; max-width: 440px; margin: 10px auto 0; }
.tr-key { font: inherit; font-size: .95rem; font-weight: 700; font-family: 'Cambria Math', serif; cursor: pointer; min-width: 40px; padding: 7px 10px; border-radius: 10px; border: 1px solid rgba(99,102,241,.18); background: rgba(255,255,255,.8); color: var(--accent-ink); transition: .14s var(--ease); }
.tr-key:hover { border-color: var(--g1); background: var(--accent-soft); transform: translateY(-1px); }
.tr-key:active { transform: translateY(0); }
.tr-key .ic { width: 16px; height: 16px; }
.tr-preview { text-align: center; margin: 12px auto 0; color: var(--ink-soft); }
.tr-preview:empty { display: none; }
.tr-preview .katex { font-size: 1.12em; }
/* ── пошаговое решение / репетитор (P7) ── */
.tr-steps { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.tr-steps:empty { display: none; }
.tr-step-line { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-radius: 12px; background: linear-gradient(180deg, #f4fbf7, #ecf9f1); border: 1px solid rgba(16,185,129,.22); animation: trUp .25s var(--ease) both; }
.tr-step-ic { flex-shrink: 0; width: 22px; height: 22px; border-radius: 50%; background: var(--ok); color: #fff; display: inline-flex; align-items: center; justify-content: center; }
.tr-step-ic .ic { width: 14px; height: 14px; }
.tr-step-tex { font-family: 'Cambria Math', serif; font-size: 1.12rem; color: var(--ink); }
.tr-solution {
margin-top: 22px; padding: 18px 20px; border-radius: 16px;
@@ -293,16 +311,35 @@
<div class="tr-skill" id="tr-skill"></div>
<div class="tr-eq" id="tr-eq"></div>
<div class="tr-inrow">
<span class="tr-eqx" id="tr-eqx">x =</span>
<input class="tr-input" id="tr-input" type="text" inputmode="text" autocomplete="off"
placeholder="ответ" aria-label="Ваш ответ"/>
<button class="tr-btn tr-primary" id="tr-check" type="button">Проверить</button>
<div id="tr-answerbox">
<div class="tr-inrow">
<span class="tr-eqx" id="tr-eqx">x =</span>
<input class="tr-input" id="tr-input" type="text" inputmode="text" autocomplete="off"
placeholder="ответ" aria-label="Ваш ответ"/>
<button class="tr-btn tr-primary" id="tr-check" type="button">Проверить</button>
</div>
<div class="tr-keypad" id="tr-keypad"></div>
<div class="tr-preview" id="tr-preview"></div>
<div class="tr-feedback" id="tr-feedback"></div>
</div>
<div class="tr-feedback" id="tr-feedback"></div>
<div id="tr-stepbox" style="display:none">
<div class="tr-steps" id="tr-steps"></div>
<div class="tr-inrow">
<input class="tr-input" id="tr-stepin" type="text" autocomplete="off"
placeholder="следующий шаг, напр. 3x = 15" aria-label="Следующий шаг"/>
<button class="tr-btn tr-primary" id="tr-stepcheck" type="button">Шаг</button>
</div>
<div class="tr-keypad" id="tr-keypad2"></div>
<div class="tr-preview" id="tr-prev2"></div>
<div class="tr-feedback" id="tr-stepfb"></div>
</div>
<div class="tr-actions">
<button class="tr-btn tr-ghost" id="tr-step-toggle" type="button" style="display:none">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
Решить по шагам
</button>
<button class="tr-btn tr-ghost" id="tr-hint" type="button">
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1h6c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2Z"/></svg>
Подсказка
@@ -435,7 +472,9 @@
applyInputMode();
var inp = $('tr-input'); inp.value = ''; inp.disabled = false;
var card = $('tr-card'); if (card) { card.classList.remove('tr-correct'); card.classList.remove('tr-wrong'); }
var pv = $('tr-preview'); if (pv) pv.innerHTML = '';
setMode(false); inp.focus();
setStepMode(false); // текстовые задачи — без пошагового режима
}
function genWordProblem() {
var gb = $('tr-gen-btn'); if (gb) { gb.disabled = true; gb.textContent = 'Генерирую…'; }
@@ -462,6 +501,8 @@
// адаптивная сессия
var smart = true, GOAL = 10;
var sessAnswered = 0, sessEvents = [], reviewQ = [], summaryShown = false;
// пошаговый режим (P7)
var stepMode = false, stepPref = false, stepList = [];
function topicMastered(topicKey) {
var ss = skillsOf(topicKey);
@@ -502,6 +543,102 @@
function setMode(done) {
answered = done;
$('tr-check').textContent = done ? 'Дальше' : 'Проверить';
var sc = $('tr-stepcheck'); if (sc) sc.textContent = done ? 'Дальше' : 'Шаг';
}
// общие эффекты «задача решена» (из обычного ответа и из пошагового режима)
function onSolved() {
solved++; streak++;
var card = $('tr-card'); if (card) card.classList.add('tr-correct');
recordAnswer(true); submitAttempt(true);
setMode(true); updateStats();
}
// ── мат-клавиатура + live-превью (P8) ──
var KEYS = [
{ t: '(', ins: '(' }, { t: ')', ins: ')' }, { t: 'x', ins: 'x' },
{ t: '/', ins: '/' }, { t: '^', ins: '^' }, { t: '√', ins: 'sqrt(' }, { t: ';', ins: '; ' },
{ bksp: true }
];
function buildKeypad(container, inputId, previewId) {
if (!container) return;
container.innerHTML = KEYS.map(function (k, i) {
if (k.bksp) return '<button class="tr-key" type="button" data-k="' + i + '" aria-label="Стереть"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 4H8L2 12l6 8h13a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"/><path d="m18 9-6 6M12 9l6 6"/></svg></button>';
return '<button class="tr-key" type="button" data-k="' + i + '">' + esc(k.t) + '</button>';
}).join('');
container.addEventListener('click', function (e) {
var b = e.target.closest('.tr-key'); if (!b) return;
var inp = $(inputId); if (!inp || inp.disabled) return;
var k = KEYS[+b.getAttribute('data-k')];
if (k.bksp) backspaceAt(inp); else insertAt(inp, k.ins);
renderPreview(inp, $(previewId));
});
}
function insertAt(inp, text) {
var s = inp.selectionStart, e = inp.selectionEnd, v = inp.value;
if (s == null || e == null) { inp.value = v + text; inp.focus(); return; }
inp.value = v.slice(0, s) + text + v.slice(e);
var pos = s + text.length; inp.focus();
try { inp.setSelectionRange(pos, pos); } catch (err) {}
}
function backspaceAt(inp) {
var s = inp.selectionStart, e = inp.selectionEnd, v = inp.value, pos;
if (s == null) { inp.value = v.slice(0, -1); inp.focus(); return; }
if (s !== e) { inp.value = v.slice(0, s) + v.slice(e); pos = s; }
else if (s > 0) { inp.value = v.slice(0, s - 1) + v.slice(s); pos = s - 1; }
else { inp.focus(); return; }
inp.focus(); try { inp.setSelectionRange(pos, pos); } catch (err) {}
}
function renderPreview(inp, prev) {
if (!prev) return;
var raw = (inp.value || '').trim();
if (!raw) { prev.innerHTML = ''; return; }
var latex = TE.exprToLatex(raw);
prev.innerHTML = latex ? (kat(latex, false) || '') : '';
}
// ── пошаговое решение / репетитор (P7) ──
function canStep() { return !!(cur && cur.kind === 'solve'); }
function setStepMode(on) {
stepMode = !!(on && canStep());
var ab = $('tr-answerbox'), sb = $('tr-stepbox');
if (ab) ab.style.display = stepMode ? 'none' : '';
if (sb) sb.style.display = stepMode ? '' : 'none';
var tog = $('tr-step-toggle'); if (tog) tog.classList.toggle('on', stepMode);
if (stepMode) {
stepList = []; renderSteps();
var fb = $('tr-stepfb'); fb.className = 'tr-feedback'; fb.textContent = '';
var si = $('tr-stepin'); si.value = ''; si.disabled = false;
$('tr-prev2').innerHTML = '';
setMode(false);
si.focus();
}
}
function renderSteps() {
$('tr-steps').innerHTML = stepList.map(function (s) {
var latex = TE.exprToLatex(s);
var math = latex ? (kat(latex, false) || esc(TE.prettyMath(s))) : esc(TE.prettyMath(s));
return '<div class="tr-step-line"><span class="tr-step-ic">' + ICON.ok + '</span><span class="tr-step-tex">' + math + '</span></div>';
}).join('');
}
function checkStepNow() {
if (answered) { advance(); return; }
var inp = $('tr-stepin'), fb = $('tr-stepfb');
var r = TE.checkStep(cur, inp.value);
if (!r.ok) {
fb.className = 'tr-feedback ' + (r.status === 'wrong' ? 'bad' : 'warn');
fb.innerHTML = (r.status === 'wrong' ? ICON.bad + ' ' : '') + esc(r.message);
return;
}
stepList.push(inp.value.trim());
renderSteps();
inp.value = ''; $('tr-prev2').innerHTML = '';
if (r.status === 'solved') {
fb.className = 'tr-feedback ok'; fb.innerHTML = ICON.ok + ' <span>Готово!</span>';
inp.disabled = true; onSolved();
} else {
fb.className = 'tr-feedback ok'; fb.innerHTML = ICON.ok + ' Верный шаг — продолжай.';
inp.focus();
}
}
// Префикс «x =» и подсказка ввода зависят от типа задачи.
function applyInputMode() {
@@ -509,6 +646,7 @@
var multi = (k === 'roots' || k === 'simplify');
var eqx = $('tr-eqx'); if (eqx) eqx.style.display = multi ? 'none' : '';
$('tr-input').placeholder = (k === 'roots') ? 'корни через ;' : (k === 'simplify') ? 'упрощённое выражение' : 'ответ';
var tog = $('tr-step-toggle'); if (tog) tog.style.display = canStep() ? '' : 'none';
}
// Текст ответа в фидбеке/раскрытии — по типу задачи.
function answerLabel() {
@@ -546,8 +684,10 @@
var fb = $('tr-feedback'); fb.className = 'tr-feedback'; fb.textContent = '';
$('tr-solution').style.display = 'none'; $('tr-solution').innerHTML = '';
var card = $('tr-card'); if (card) { card.classList.remove('tr-correct'); card.classList.remove('tr-wrong'); }
var pv = $('tr-preview'); if (pv) pv.innerHTML = '';
setMode(false);
inp.focus();
setStepMode(stepPref); // сохраняем выбор «по шагам» между задачами (для kind solve)
}
// фоновая отправка попытки на сервер (прогресс/мастерство)
@@ -622,6 +762,7 @@
if (giveUp && !answered) {
streak = 0;
$('tr-input').disabled = true;
var si = $('tr-stepin'); if (si) si.disabled = true;
var fb = $('tr-feedback'); fb.className = 'tr-feedback';
if (cur.kind === 'roots' || cur.kind === 'simplify') fb.textContent = 'Ответ: ' + answerLabel();
else setMath(fb, 'x = ' + cur.answer, 'Ответ: x = ' + fmt(cur.answer), false);
@@ -641,13 +782,12 @@
$('tr-input').disabled = true;
setMode(true);
if (r.ok) {
solved++; streak++;
fb.className = 'tr-feedback ok';
$('tr-card').classList.add('tr-correct');
var lbl = (cur.kind === 'roots' || cur.kind === 'simplify') ? esc(answerLabel())
: (kat('x = ' + cur.answer, false) || esc('x = ' + fmt(cur.answer)));
fb.innerHTML = ICON.ok + ' <span>Верно!</span>&nbsp;' + lbl;
recordAnswer(true); submitAttempt(true);
$('tr-input').disabled = true;
onSolved();
} else {
streak = 0;
fb.className = 'tr-feedback bad'; fb.innerHTML = ICON.bad + ' Неверно. Разбери решение и реши похожую.';
@@ -814,12 +954,29 @@
$('tr-skip').addEventListener('click', newProblem);
$('tr-hint').addEventListener('click', function () {
if (!cur) return;
if (stepMode) {
var sol = cur.solution || [];
var idx = Math.min(stepList.length, Math.max(0, sol.length - 1));
var st = sol[idx];
var fb = $('tr-stepfb'); fb.className = 'tr-feedback warn';
fb.innerHTML = 'Подсказка: ' + (st && st.latex ? (kat(st.latex, false) || esc(st.tex || '')) : esc((st && (st.tex || st.note)) || ('x = ' + fmt(cur.answer))));
return;
}
var s = $('tr-solution');
s.innerHTML = '<h4>Подсказка</h4>' + stepHtml((cur.solution || [])[0] || { note: '', tex: 'x = ' + fmt(cur.answer), latex: null }, 1);
s.style.display = 'block';
});
$('tr-solve').addEventListener('click', function () { if (cur) revealAnswer(true); });
$('tr-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); check(); } });
// P8 — мат-клавиатуры + live-превью
buildKeypad($('tr-keypad'), 'tr-input', 'tr-preview');
buildKeypad($('tr-keypad2'), 'tr-stepin', 'tr-prev2');
$('tr-input').addEventListener('input', function () { renderPreview($('tr-input'), $('tr-preview')); });
$('tr-stepin').addEventListener('input', function () { renderPreview($('tr-stepin'), $('tr-prev2')); });
// P7 — пошаговый режим
$('tr-step-toggle').addEventListener('click', function () { stepPref = !stepMode; setStepMode(!stepMode); });
$('tr-stepcheck').addEventListener('click', checkStepNow);
$('tr-stepin').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); checkStepNow(); } });
$('tr-note').textContent = gens.length + ' навыков в ' + topics.length + ' темах · умная тренировка ведёт от простого к сложному и возвращает ошибки · прогресс сохраняется.';
+89
View File
@@ -0,0 +1,89 @@
# ИИ-Тренажёр — Roadmap v2 (Phase 7+)
**Контекст.** P0P6 готовы (`PLAN.md`): движок параметрических генераторов + SimExpr-
верификатор (+несколько корней, эквивалентность сэмплингом), 17 генераторов / 5 тем,
умная тренировка с интервальным повторением, LLM-задачи с серверной проверкой, пул,
авторинг/раздача учителем, аналитика-тепловая карта, визуальный редизайн.
**Цель v2.** Превратить «проверяльщик ответов» в **репетитора** (ведёт по шагам,
объясняет ошибки) и расширить **охват** (классы 5–9, ЦТ) и **вовлечение**.
**Инвариант (не нарушать).** Выражения — только SimExpr (⛔ без eval). Любая задача
проходит проверку подстановкой/сэмплингом; неверная ученику не попадает. Тексты
экранируются. Каждая фаза — со смоуками/тестами и коммитом.
---
## P7 — Пошаговое решение (репетитор) ⭐ — DONE
**Сделано:** движок `TE.checkStep(problem, line)` — шаг = равносильное уравнение
(держится во всех корнях И не выполняется в не-корнях → ловит арифметику, потерю
корня, тождество `0=0`); статусы equivalent/solved/wrong/identity/parse. Страница:
тумблер «Решить по шагам» (для kind solve), ввод шагов с проверкой каждого, список
принятых шагов (KaTeX + зелёная галочка), подсказка следующего шага, завершение по
solved-форме `x=c` → общий `onSolved` (засчитывается как решение). stepPref хранит выбор
между задачами. Смоук движка T14 + страницы шаг-сценарий.
Ученик решает по шагам, движок проверяет КАЖДЫЙ шаг.
- Режим «по шагам»: ввод следующей строки преобразования → проверка эквивалентности
предыдущему (reuse `_sampleEquiv`; для уравнений — сохранение множества корней) +
прогресс к ответу.
- Подсказка следующего шага; «застрял» → раскрыть шаг. Guided-вариант: заполнить
пропуски в данных шагах.
- **Ценность:** глубочайшая педагогика, уникальное отличие от «answer-checker».
## P8 — Математический ввод ⭐ — DONE
**Сделано:** лёгкая экранная мат-клавиатура (`( ) x / ^ √ ; ⌫`, вставка в позицию
курсора, без библиотек) под полем ответа И под полем шага; live-превью KaTeX введённого
(показывается только при валидном разборе через `exprToLatex`). Переиспользуется и для
ответа, и для пошагового ввода.
Удобный ввод дробей/степеней/корней (моб. + выражения).
- Лёгкая экранная мат-клавиатура (свои кнопки `/ ^ √ ( ) ± x`), live-превью KaTeX
введённого. Без тяжёлых библиотек.
- Синергия с P7 (ввод шагов) и с multi-root / simplify.
## P9 — Разбор ошибок + сократические подсказки (LLM)
«Почему неверно» и подсказки, не выдавая ответ сразу.
- Правиловая детекция типовых ошибок (потерян знак, забыл поделить, арифметика) для
linear/quadratic — по разнице ответа ученика с «ответом при типичной ошибке».
- LLM-фолбэк «объясни мою ошибку» / «подскажи» через Квантик-ассистента
(`callLLMFailover`) — только ОБЪЯСНЕНИЯ (безопасно, не генерация задач).
- 3 уровня подсказок (намёк → шаг → решение).
## P10 — Контент 5–9 классов + ЦТ
Расширить охват и связать с подготовкой к ЦТ/ЦЭ.
- Новые темы: арифметика/дроби/десятичные (5–6), степени, формулы сокр. умножения,
разложение на множители, **линейные неравенства** (новый тип ответа: парсинг и
нормализация отношения `x>3`/`x≤−2`), системы 2 лин. уравнений, линейная функция (k,b),
текстовые семьи (движение/работа/смеси) параметрически.
- Дерево навыков по таксономии exam-prep ЦТ (связь с готовым модулем экзамена).
## P11 — Геймификация + карта навыков
Вовлечение через существующую инфраструктуру.
- XP/монеты/достижения (Квантик-геймификация) за решения/серии/мастерство; учёт
kill-switch геймификации.
- Карта-дерево навыков (визуализация прогресса) на странице/дашборде.
- Дневная цель + календарь серий.
## P12 — Задания и журнал
Учительский рабочий процесс поверх раздачи.
- Задание: темы/навыки + цель (N решено / мастерство) + дедлайн → ученики видят,
прогресс трекается; учитель видит выполнение и результаты; интеграция с journal/homework.
- Апгрейд текущего `assign` (уведомление) до отслеживаемого задания (таблица).
## P13 — Конструктор генераторов + управление пулом
Учитель создаёт ПАРАМЕТРИЧЕСКИЕ генераторы (не только одиночные задачи).
- Визуальный билдер: диапазоны `pick`, формулы `derive`, шаблоны `lhs/rhs`, ответ,
шаги решения + live-превью + валидация (отложенный «полный P4»).
- Управление пулом (ревью/правка/удаление), генерация по теме урока/§ учебника.
---
## Сквозное
Тесты/смоуки на каждую фазу; доступность (ARIA, клавиатура, озвучка формул);
офлайн-режим (PWA) для параметрики; производительность.
## Рекомендация
Начать с **P7 + P8** (репетитор + мат-ввод — сильная синергия, наибольший скачок
качества обучения), затем **P9** (разбор ошибок) — вместе дают эффект «личный
репетитор». Параллельный быстрый выигрыш по охвату — **P10** (неравенства/системы/ЦТ).