From 7cc2a9d526f9c5ee1fa19c8c381e57a4dddcb8dd Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 25 Jun 2026 14:15:21 +0300 Subject: [PATCH] =?UTF-8?q?feat(trainer):=20P5=20=E2=80=94=20=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D0=BD=D0=B5=D0=B9,=20=D1=8D=D0=BA=D0=B2=D0=B8=D0=B2=D0=B0?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=82=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B2?= =?UTF-8?q?=D1=8B=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B9,=20=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=82=D0=B5=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - движок: gen.answers → несколько корней (_checkMultiRoot, ввод через «;», сверка мультимножеством) - kind simplify: эквивалентность выражений численным сэмплингом (_sampleEquiv, _checkEquiv), фикс. точки без Math.random - exprToLatex: знаковые коэффициенты — -5x, x²−5x+6, a−(−b)→a+b (вынос ведущего минуса, схлопывание) - темы: Упрощение (подобные, скобки) + Квадратные (Виета x²+bx+c=0, разность квадратов) → 17 генераторов, 5 тем - страница: префикс «x=»/подсказка ввода и ответ-лейбл по типу задачи - смоук движка 291/291 (T11 roots, T12 simplify, T13 latex), страница 26/26, adaptive 12/12; план P5 → DONE Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/trainer/_trainer_engine.js | 125 +++++++++++++++++++++---- frontend/js/trainer/generators.js | 65 ++++++++++++- frontend/trainer.html | 28 +++++- plans/ai-trainer/PLAN.md | 14 ++- 4 files changed, 204 insertions(+), 28 deletions(-) diff --git a/frontend/js/trainer/_trainer_engine.js b/frontend/js/trainer/_trainer_engine.js index 5bdcae4..8487af5 100644 --- a/frontend/js/trainer/_trainer_engine.js +++ b/frontend/js/trainer/_trainer_engine.js @@ -128,8 +128,16 @@ if (n.k === 'un' || n.k === 'not') return 3; return 5; } - function _isNeg(n) { return (n.k === 'num' && n.v < 0) || (n.k === 'un' && n.op === '-'); } - function _negate(n) { return n.k === 'num' ? { k: 'num', v: -n.v } : n.a; } + function _isNeg(n) { + return (n.k === 'num' && n.v < 0) || (n.k === 'un' && n.op === '-') || + (n.k === 'bin' && n.op === '*' && _isNeg(n.a)); // (-5)*x — отрицательное слагаемое + } + function _negate(n) { + if (n.k === 'num') return { k: 'num', v: -n.v }; + if (n.k === 'un' && n.op === '-') return n.a; + if (n.k === 'bin' && n.op === '*') return { k: 'bin', op: '*', a: _negate(n.a), b: n.b }; + return { k: 'un', op: '-', a: n }; + } function _wrapL(node, minPrec) { var s = _latex(node); return _prec(node) < minPrec ? '\\left(' + s + '\\right)' : s; @@ -174,13 +182,15 @@ return base + '^{' + _latex(node.b) + '}'; } if (op === '*') { + if (_isNeg(node.a)) return '-' + _latex({ k: 'bin', op: '*', a: _negate(node.a), b: node.b }); // -5*x -> «-5x» var sep = (node.b.k === 'num' && node.b.v >= 0) ? ' \\cdot ' : ''; // знак · между числами; иначе соседство return _mulOperand(node.a) + sep + _mulOperand(node.b); } if (op === '%') return _wrapL(node.a, 2) + ' \\bmod ' + _wrapL(node.b, 3); - // + или - (схлопываем a + (-b) -> a - b) + // + или - (схлопываем a + (-b) -> a - b и a - (-b) -> a + b) var right = node.b, rop = op; if (op === '+' && _isNeg(right)) { rop = '-'; right = _negate(right); } + else if (op === '-' && _isNeg(right)) { rop = '+'; right = _negate(right); } return _wrapL(node.a, 1) + ' ' + rop + ' ' + _wrapL(right, rop === '-' ? 2 : 1); } } @@ -204,6 +214,25 @@ return { ok: residual <= EPS * scale, residual: residual, lhs: L, rhs: R }; } + /* ── Эквивалентность выражений численным сэмплингом ── + Истинно, если exprA и exprB совпадают в нескольких точках по переменным vars + (для проверки упрощения/раскрытия: 3x+5x ≡ 8x, a(x+b) ≡ ax+ab). Точки + фиксированы → детерминированно (без Math.random). */ + var _EQUIV_PTS = [-3.7, -1.3, 0.5, 2.1, 4.9, -0.9, 3.3, 1.7]; + function _sampleEquiv(exprA, exprB, vars) { + var ca = SE().compile(String(exprA)), cb = SE().compile(String(exprB)); + if (ca.error || cb.error) return { ok: false, reason: 'parse' }; + vars = (vars && vars.length) ? vars : ['x']; + for (var i = 0; i < _EQUIV_PTS.length; i++) { + var env = {}; + for (var v = 0; v < vars.length; v++) env[vars[v]] = _EQUIV_PTS[(i + v * 3) % _EQUIV_PTS.length]; + var a = ca.fn(env), b = cb.fn(env); + var scale = Math.max(1, Math.abs(a), Math.abs(b)); + if (Math.abs(a - b) > 1e-6 * scale) return { ok: false }; + } + return { ok: true }; + } + /* ── Материализация одного экземпляра ── Возвращает problem или null, если за maxTries не удалось выполнить ограничения / целочисленность / самопроверку. */ @@ -230,32 +259,44 @@ if (gen.require && !truthy(evalExpr(gen.require, env))) continue; - var answer = evalExpr(gen.answer, env); - if (gen.integerAnswer) { + var kind = gen.kind || 'solve'; + + // корни: одиночный (answer) или множественный (answers — массив выражений) + var answers = null; + if (Array.isArray(gen.answers)) { + answers = gen.answers.map(function (a) { return evalExpr(a, env); }); + if (gen.integerAnswer) answers = answers.map(function (x) { return Math.round(x); }); + } + var answer = gen.answer ? evalExpr(gen.answer, env) : (answers ? answers[0] : 0); + if (gen.answer && gen.integerAnswer) { if (!isIntApprox(answer)) continue; answer = Math.round(answer); } - var lhsExpr = render(gen.lhs, env); - var rhsExpr = render(gen.rhs, env); + var lhsExpr = render(gen.lhs || 'x', env); + var rhsExpr = render(gen.rhs || 'x', env); var sEnv = assign(env, { ans: answer }); - // compute-задача (проценты): показываем текстовый prompt из display, а - // уравнение lhs=rhs служит лишь для проверки → latex уравнения не строим. - var isCompute = gen.kind === 'compute'; - var ll = isCompute ? null : exprToLatex(lhsExpr); - var rl = isCompute ? null : exprToLatex(rhsExpr); + // latex уравнения строим только для уравнений (solve/roots); compute/simplify — + // текстовый prompt из display. + var showEq = (kind === 'solve' || kind === 'roots'); + var ll = showEq ? exprToLatex(lhsExpr) : null; + var rl = showEq ? exprToLatex(rhsExpr) : null; + var answerExpr = gen.answerExpr ? render(gen.answerExpr, env) : null; var problem = { genId: gen.id, skill: gen.skill || gen.id, // ключ прогресса = id генератора, если skill не задан title: gen.title, - kind: gen.kind || 'solve', + kind: kind, lhsExpr: lhsExpr, rhsExpr: rhsExpr, display: prettyMath(render(gen.display || (gen.lhs + ' = ' + gen.rhs), env)), latex: (ll != null && rl != null) ? (ll + ' = ' + rl) : null, answerVar: answerVar, answer: answer, + answers: answers, // массив корней (kind roots) + answerExpr: answerExpr, // канон. выражение (kind simplify) + answerVars: gen.answerVars || [answerVar], params: env, // шаг решения -> { note(текст), tex(подпись), latex(для KaTeX, null если не разобрался) } // строковый шаг (легаси) трактуется как чистая заметка без формулы. @@ -270,13 +311,20 @@ }) }; - // Самопроверка: эталонный корень ОБЯЗАН удовлетворять уравнению. - var v = verifyRoot(problem, answer); - if (!v.ok) { - if (opts.strict) { - throw new Error('Генератор «' + gen.id + '»: корень ' + fmtNum(answer) + - ' не удовлетворяет уравнению (невязка ' + v.residual + ').'); - } + // Самопроверка по типу: simplify → эквивалентность; roots → все корни; иначе → корень. + var okSelf, why; + if (kind === 'simplify') { + okSelf = _sampleEquiv(render(gen.srcExpr || gen.lhs || 'x', env), answerExpr, problem.answerVars).ok; + why = 'упрощение не эквивалентно ответу'; + } else if (answers) { + okSelf = answers.every(function (r) { return verifyRoot(problem, r).ok; }); + why = 'не все корни удовлетворяют уравнению'; + } else { + var v = verifyRoot(problem, answer); + okSelf = v.ok; why = 'корень ' + fmtNum(answer) + ' не удовлетворяет (невязка ' + v.residual + ')'; + } + if (!okSelf) { + if (opts.strict) throw new Error('Генератор «' + gen.id + '»: ' + why + '.'); continue; } return problem; @@ -309,6 +357,9 @@ var raw = String(input == null ? '' : input).trim(); if (!raw) return { ok: false, reason: 'empty', value: null, residual: null, message: 'Введите ответ.' }; + if (problem.kind === 'simplify') return _checkEquiv(problem, raw); + if (problem.kind === 'roots') return _checkMultiRoot(problem, raw); + var c = SE().compile(raw); if (c.error) { return { ok: false, reason: 'parse', value: null, residual: null, @@ -328,6 +379,40 @@ }; } + /* Несколько корней: ученик вводит все через «;»/«,»/пробел; сверяем как мультимножество. */ + function _checkMultiRoot(problem, raw) { + var parts = raw.split(/[;,\s]+/).filter(Boolean); + if (!parts.length) return { ok: false, reason: 'empty', message: 'Введите ответ.' }; + var vals = []; + for (var i = 0; i < parts.length; i++) { + var c = SE().compile(parts[i]); + if (c.error) return { ok: false, reason: 'parse', message: 'Не понял ответ.' }; + var x = c.fn({}); + if (!isFinite(x)) return { ok: false, reason: 'nan', message: 'Это не число.' }; + vals.push(x); + } + var want = (problem.answers || []).slice(); + if (vals.length !== want.length) return { ok: false, reason: 'count', message: 'Укажите все корни через «;».' }; + var used = want.map(function () { return false; }); + for (var j = 0; j < vals.length; j++) { + var f = -1; + for (var w = 0; w < want.length; w++) { + if (!used[w] && Math.abs(vals[j] - want[w]) <= 1e-6 * Math.max(1, Math.abs(want[w]))) { f = w; break; } + } + if (f < 0) return { ok: false, reason: 'wrong', message: 'Пока неверно.' }; + used[f] = true; + } + return { ok: true, reason: null, message: 'Верно!' }; + } + + /* Упрощение: ответ-выражение проверяем на эквивалентность сэмплингом. */ + function _checkEquiv(problem, raw) { + var c = SE().compile(raw); + if (c.error) return { ok: false, reason: 'parse', message: 'Не понял выражение: ' + c.error }; + var se = _sampleEquiv(raw, problem.answerExpr, problem.answerVars || ['x']); + return { ok: se.ok, reason: se.ok ? null : (se.reason || 'wrong'), value: raw, message: se.ok ? 'Верно!' : 'Пока неверно.' }; + } + global.TrainerEngine = { instantiate: instantiate, generateBatch: generateBatch, diff --git a/frontend/js/trainer/generators.js b/frontend/js/trainer/generators.js index e8be9a1..8f2a76b 100644 --- a/frontend/js/trainer/generators.js +++ b/frontend/js/trainer/generators.js @@ -23,7 +23,9 @@ var TOPICS = [ { key: 'linear-eq', label: 'Уравнения', subject: 'algebra', grade: 7, order: 1 }, { key: 'proportions', label: 'Пропорции', subject: 'algebra', grade: 7, order: 2 }, - { key: 'percents', label: 'Проценты', subject: 'algebra', grade: 7, order: 3 } + { key: 'percents', label: 'Проценты', subject: 'algebra', grade: 7, order: 3 }, + { key: 'simplify', label: 'Упрощение', subject: 'algebra', grade: 7, order: 4 }, + { key: 'quadratic', label: 'Квадратные', subject: 'algebra', grade: 8, order: 5 } ]; var GENERATORS = [ @@ -249,6 +251,67 @@ { note: 'Известно, что {p}% некоторого числа равны {a}. Значит само число во столько раз больше: умножаем {a} на 100 и делим на {p}.', tex: 'x = {a}*100/{p}' }, { note: 'Считаем — получаем искомое число.', tex: 'x = {ans}' } ] + }, + + /* ═══ Тема: Упрощение выражений (проверка эквивалентностью) ═══ */ + + /* a·x + b·x → (a+b)x */ + { + id: 'simp-like', topic: 'simplify', order: 1, subject: 'algebra', grade: 7, kind: 'simplify', + title: 'Привести подобные', + pick: { a: [2, 9], b: [2, 9] }, + derive: { s: 'a + b' }, + srcExpr: '{a}*x + {b}*x', answerExpr: '{s}*x', answerVars: ['x'], + display: 'Упростите: {a}x + {b}x', + solution: [ + { note: 'Оба слагаемых содержат x — это подобные слагаемые. Складываем их коэффициенты: {a} + {b} = {s}.', tex: '{a}x + {b}x = {s}x' } + ] + }, + + /* a(x + b) → ax + ab */ + { + id: 'simp-expand', topic: 'simplify', order: 2, subject: 'algebra', grade: 7, kind: 'simplify', + title: 'Раскрыть скобки', + pick: { a: [2, 9], b: [1, 9] }, + derive: { ab: 'a*b' }, + srcExpr: '{a}*(x + {b})', answerExpr: '{a}*x + {ab}', answerVars: ['x'], + display: 'Раскройте скобки: {a}(x + {b})', + solution: [ + { note: 'Умножаем множитель {a} на каждое слагаемое внутри скобки.', tex: '{a}(x + {b}) = {a}x + {ab}' } + ] + }, + + /* ═══ Тема: Квадратные уравнения (несколько корней) ═══ */ + + /* x² + bx + c = 0 — разложение по Виета (два корня r1, r2) */ + { + id: 'quad-factored', topic: 'quadratic', order: 1, subject: 'algebra', grade: 8, kind: 'roots', + title: 'x² + bx + c = 0', + pick: { r1: [-7, 7], r2: [-7, 7] }, + constraint: 'r1 != r2', + derive: { b: '-(r1 + r2)', c: 'r1*r2' }, + lhs: 'x^2 + {b}*x + {c}', rhs: '0', + answerVar: 'x', answers: ['r1', 'r2'], integerAnswer: true, + solution: [ + { note: 'Квадратное уравнение приравнено к нулю. По теореме Виета ищем два числа: их сумма равна {r1}+{r2}, произведение — {c}. Это и есть корни. Раскладываем на множители:', tex: '(x - {r1})(x - {r2}) = 0' }, + { note: 'Произведение равно нулю, когда обнуляется множитель. Первый корень:', tex: 'x = {r1}' }, + { note: 'Второй корень:', tex: 'x = {r2}' } + ] + }, + + /* x² − a² = 0 — разность квадратов (корни ±a) */ + { + id: 'quad-diff', topic: 'quadratic', order: 2, subject: 'algebra', grade: 8, kind: 'roots', + title: 'x² − a² = 0', + pick: { a: [2, 9] }, + derive: { a2: 'a*a' }, + lhs: 'x^2 - {a2}', rhs: '0', + answerVar: 'x', answers: ['a', '-a'], integerAnswer: true, + solution: [ + { note: 'Слева — разность квадратов: x² − {a2} = (x − {a})(x + {a}). Раскладываем:', tex: '(x - {a})(x + {a}) = 0' }, + { note: 'Первый корень:', tex: 'x = {a}' }, + { note: 'Второй корень:', tex: 'x = -{a}' } + ] } ]; diff --git a/frontend/trainer.html b/frontend/trainer.html index 2f21191..aab3e0a 100644 --- a/frontend/trainer.html +++ b/frontend/trainer.html @@ -141,7 +141,7 @@
-

ТренажёрАлгебра · 7 класс

+

ТренажёрАлгебра · 7–8 класс

Задачи генерируются автоматически и проверяются мгновенно. Решай по одной — бесконечно.
@@ -161,7 +161,7 @@
- x = + x = @@ -299,6 +299,7 @@ cur = wordPool[wordIdx % wordPool.length]; wordIdx++; $('tr-skill').textContent = cur.title; setMath(eq, null, cur.display, true); // условие как текст + applyInputMode(); var inp = $('tr-input'); inp.value = ''; inp.disabled = false; setMode(false); inp.focus(); } @@ -362,6 +363,19 @@ answered = done; $('tr-check').textContent = done ? 'Дальше' : 'Проверить'; } + // Префикс «x =» и подсказка ввода зависят от типа задачи. + function applyInputMode() { + var k = cur && cur.kind; + var multi = (k === 'roots' || k === 'simplify'); + var eqx = $('tr-eqx'); if (eqx) eqx.style.display = multi ? 'none' : ''; + $('tr-input').placeholder = (k === 'roots') ? 'корни через ;' : (k === 'simplify') ? 'упрощённое выражение' : 'ответ'; + } + // Текст ответа в фидбеке/раскрытии — по типу задачи. + function answerLabel() { + if (cur.kind === 'roots' && cur.answers) return 'Корни: ' + cur.answers.map(fmt).join('; '); + if (cur.kind === 'simplify') return '= ' + (cur.answerExpr ? fmt(cur.answerExpr) : ''); + return 'x = ' + fmt(cur.answer); + } function updateStats() { $('tr-solved').textContent = solved; $('tr-streak').textContent = streak; } function stepHtml(st, n) { @@ -384,8 +398,9 @@ $('tr-skill').textContent = curGen.title; var eq = $('tr-eq'); - eq.classList.toggle('tr-eq-text', !cur.latex); // текстовый prompt (проценты) — другим шрифтом + eq.classList.toggle('tr-eq-text', !cur.latex); // текстовый prompt (проценты/упрощение) — другим шрифтом setMath(eq, cur.latex, cur.display, true); + applyInputMode(); var inp = $('tr-input'); inp.value = ''; inp.disabled = false; var fb = $('tr-feedback'); fb.className = 'tr-feedback'; fb.textContent = ''; @@ -467,7 +482,8 @@ streak = 0; $('tr-input').disabled = true; var fb = $('tr-feedback'); fb.className = 'tr-feedback'; - setMath(fb, 'x = ' + cur.answer, 'Ответ: x = ' + fmt(cur.answer), false); + if (cur.kind === 'roots' || cur.kind === 'simplify') fb.textContent = 'Ответ: ' + answerLabel(); + else setMath(fb, 'x = ' + cur.answer, 'Ответ: x = ' + fmt(cur.answer), false); setMode(true); recordAnswer(false); submitAttempt(false); updateStats(); @@ -486,7 +502,9 @@ if (r.ok) { solved++; streak++; fb.className = 'tr-feedback ok'; - fb.innerHTML = ICON.ok + ' Верно! ' + (kat('x = ' + cur.answer, false) || esc('x = ' + fmt(cur.answer))); + var lbl = (cur.kind === 'roots' || cur.kind === 'simplify') ? esc(answerLabel()) + : (kat('x = ' + cur.answer, false) || esc('x = ' + fmt(cur.answer))); + fb.innerHTML = ICON.ok + ' Верно! ' + lbl; recordAnswer(true); submitAttempt(true); } else { streak = 0; diff --git a/plans/ai-trainer/PLAN.md b/plans/ai-trainer/PLAN.md index a0a52c0..cc1d782 100644 --- a/plans/ai-trainer/PLAN.md +++ b/plans/ai-trainer/PLAN.md @@ -108,9 +108,19 @@ practice.test.js 11/11 (+SR box/due). - **Acceptance:** учитель собирает рабочий генератор без кода; ученик решает; права/видимость как у custom-sim (own + раздано). -## Phase 5 — Типы ответов и проверки +## Phase 5 — Типы ответов и проверки — DONE (частично) -**Цель:** не только «корень-число». +**Сделано:** движок получил **несколько корней** (`gen.answers` → `problem.answers`; +`_checkMultiRoot` — ввод всех корней через «;», сверка мультимножеством) и +**эквивалентность выражений** (`kind:'simplify'`, `gen.srcExpr`/`answerExpr`; +`_sampleEquiv` — численный сэмплинг в фикс. точках, без Math.random; `_checkEquiv`). +`exprToLatex` чинит знаковые коэффициенты (`-5x`, `x²−5x+6`, `a−(−b)→a+b`). Новые +темы: **Упрощение** (привести подобные, раскрыть скобки) и **Квадратные** (Виета +`x²+bx+c=0`, разность квадратов — 2 корня). Страница: префикс «x=» и подсказка ввода +по типу, ответ-лейбл (корни/выражение). Смоук движка 291/291 (T11 roots, T12 simplify, +T13 latex). **Осталось (стретч):** неравенства (нужен парсер отношений) — не вошло. + +**Цель (исходная):** не только «корень-число». - Множество корней (квадратные/факторизация), интервалы (неравенства), упрощение выражений (эквивалентность через численный сэмплинг по диапазону, а не строковое равенство).