diff --git a/frontend/js/trainer/_trainer_engine.js b/frontend/js/trainer/_trainer_engine.js
index 8487af5..4128db4 100644
--- a/frontend/js/trainer/_trainer_engine.js
+++ b/frontend/js/trainer/_trainer_engine.js
@@ -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,
diff --git a/frontend/trainer.html b/frontend/trainer.html
index a4a6759..3ca215f 100644
--- a/frontend/trainer.html
+++ b/frontend/trainer.html
@@ -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 @@
—
-
-
x =
-
-
+
+
+ x =
+
+
+
+
+
+
-
+
+