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,