From 47d4f71eaccebce756ab0ebc72354f1817f84049 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 25 Jun 2026 15:20:45 +0300 Subject: [PATCH] =?UTF-8?q?feat(trainer):=20P10=20=E2=80=94=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D0=BD=D1=82=208=20=D0=BA=D0=BB=D0=B0=D1=81?= =?UTF-8?q?=D1=81=D0=B0=20(=D1=81=D1=82=D0=B5=D0=BF=D0=B5=D0=BD=D0=B8,=20?= =?UTF-8?q?=D1=84=D0=BE=D1=80=D0=BC=D1=83=D0=BB=D1=8B,=20=D0=BD=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=B5=D0=BD=D1=81=D1=82=D0=B2=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - новый тип kind:inequality: answerRel{op,bound}, парсер отношения (_parseRel/_checkInequality) — нормализация «x op c», приём обратной записи, сверка op+границы; self-check внутри/снаружи решения - темы: Степени (aⁿ, xᵃ·xᵇ, (xᵃ)ᵇ), Формулы сокр. умножения (квадрат суммы/разности, разность квадратов), Неравенства (вкл. смену знака при делении на отрицательное) → 26 генераторов, 8 тем - движок: simplify рендерит выражение в KaTeX (exprToLatex(srcExpr)); неравенство — в KaTeX с отношением; fallback-display учитывает op - страница: ввод/лейбл для неравенств, isLabelKind - смоук движка 397/397 (T15 неравенства, T16 степени/формулы; T3 ≥10 для малых пространств), страница 33/33; ROADMAP_V2 P10 → DONE Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/js/trainer/_trainer_engine.js | 68 +++++++++++-- frontend/js/trainer/generators.js | 132 ++++++++++++++++++++++++- frontend/trainer.html | 14 ++- plans/ai-trainer/ROADMAP_V2.md | 13 ++- 4 files changed, 212 insertions(+), 15 deletions(-) diff --git a/frontend/js/trainer/_trainer_engine.js b/frontend/js/trainer/_trainer_engine.js index 4128db4..62bd98a 100644 --- a/frontend/js/trainer/_trainer_engine.js +++ b/frontend/js/trainer/_trainer_engine.js @@ -276,12 +276,19 @@ var lhsExpr = render(gen.lhs || 'x', env); var rhsExpr = render(gen.rhs || 'x', env); var sEnv = assign(env, { ans: answer }); - // 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 answerRel = (kind === 'inequality') ? { op: gen.relOp || '<', bound: evalExpr(gen.bound, env) } : null; + // latex: уравнение (solve/roots) | выражение (simplify) | неравенство (inequality) + // | null (compute → текстовый prompt из display). + var latex = null; + if (kind === 'solve' || kind === 'roots') { + var ll = exprToLatex(lhsExpr), rl = exprToLatex(rhsExpr); + if (ll != null && rl != null) latex = ll + ' = ' + rl; + } else if (kind === 'simplify' && gen.srcExpr) { + latex = exprToLatex(render(gen.srcExpr, env)); + } else if (kind === 'inequality') { + latex = exprToLatex(lhsExpr + ' ' + (gen.dispOp || '<') + ' ' + rhsExpr); + } var problem = { genId: gen.id, @@ -290,12 +297,13 @@ kind: kind, lhsExpr: lhsExpr, rhsExpr: rhsExpr, - display: prettyMath(render(gen.display || (gen.lhs + ' = ' + gen.rhs), env)), - latex: (ll != null && rl != null) ? (ll + ' = ' + rl) : null, + display: prettyMath(render(gen.display || (gen.lhs + (kind === 'inequality' ? (' ' + (gen.dispOp || '<') + ' ') : ' = ') + gen.rhs), env)), + latex: latex, answerVar: answerVar, answer: answer, answers: answers, // массив корней (kind roots) answerExpr: answerExpr, // канон. выражение (kind simplify) + answerRel: answerRel, // { op, bound } (kind inequality) answerVars: gen.answerVars || [answerVar], params: env, // шаг решения -> { note(текст), tex(подпись), latex(для KaTeX, null если не разобрался) } @@ -316,6 +324,13 @@ if (kind === 'simplify') { okSelf = _sampleEquiv(render(gen.srcExpr || gen.lhs || 'x', env), answerExpr, problem.answerVars).ok; why = 'упрощение не эквивалентно ответу'; + } else if (kind === 'inequality') { + var bnd = answerRel.bound, iop = answerRel.op; + var inside = (iop === '<' || iop === '<=') ? bnd - 1 : bnd + 1; + var outside = (iop === '<' || iop === '<=') ? bnd + 1 : bnd - 1; + okSelf = _origIneqHolds(lhsExpr, rhsExpr, gen.dispOp || '<', answerVar, inside) && + !_origIneqHolds(lhsExpr, rhsExpr, gen.dispOp || '<', answerVar, outside); + why = 'неравенство не согласовано с ответом'; } else if (answers) { okSelf = answers.every(function (r) { return verifyRoot(problem, r).ok; }); why = 'не все корни удовлетворяют уравнению'; @@ -357,8 +372,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); + if (problem.kind === 'simplify') return _checkEquiv(problem, raw); + if (problem.kind === 'roots') return _checkMultiRoot(problem, raw); + if (problem.kind === 'inequality') return _checkInequality(problem, raw); var c = SE().compile(raw); if (c.error) { @@ -413,6 +429,40 @@ return { ok: se.ok, reason: se.ok ? null : (se.reason || 'wrong'), value: raw, message: se.ok ? 'Верно!' : 'Пока неверно.' }; } + /* ── Неравенства: проверка ответа-отношения «x < c» ── + Парсим отношение ученика, нормализуем к виду «x op c» (переменная слева; + если справа — отношение переворачивается), сравниваем op и границу. */ + function _origIneqHolds(lhsExpr, rhsExpr, op, v, xv) { + var env = {}; env[v] = xv; + var L = evalExpr(lhsExpr, env), R = evalExpr(rhsExpr, env); + switch (op) { + case '<': return L < R; case '>': return L > R; + case '<=': return L <= R; case '>=': return L >= R; + } + return false; + } + function _parseRel(raw, v) { + var s = String(raw).replace(/≤/g, '<=').replace(/≥/g, '>=').replace(/\s+/g, ''); + var m = s.match(/<=|>=|<|>/); + if (!m) return null; + var op = m[0], left = s.slice(0, m.index), right = s.slice(m.index + op.length); + if (!left || !right) return null; + var cl = SE().compile(left), cr = SE().compile(right); + if (cl.error || cr.error) return null; + var flip = { '<': '>', '>': '<', '<=': '>=', '>=': '<=' }; + if (left === v && right !== v && _isConst(cr, v)) { var b = cr.fn({}); return isFinite(b) ? { op: op, bound: b } : null; } + if (right === v && left !== v && _isConst(cl, v)) { var b2 = cl.fn({}); return isFinite(b2) ? { op: flip[op], bound: b2 } : null; } + return null; + } + function _checkInequality(problem, raw) { + var v = problem.answerVar || 'x'; + var rel = _parseRel(raw, v); + if (!rel) return { ok: false, reason: 'parse', message: 'Ответ — неравенство, напр. ' + v + ' < 3.' }; + var want = problem.answerRel || {}; + var ok = rel.op === want.op && Math.abs(rel.bound - want.bound) <= 1e-6 * Math.max(1, Math.abs(want.bound)); + return { ok: ok, reason: ok ? null : 'wrong', value: raw, message: ok ? 'Верно!' : 'Пока неверно.' }; + } + /* ── Пошаговое решение (репетитор): проверка одного шага-равенства ── Шаг = равносильное уравнение (то же множество корней). Идея без решения уравнений: уравнение L=R равносильно исходному ⟺ выполняется во ВСЕХ корнях diff --git a/frontend/js/trainer/generators.js b/frontend/js/trainer/generators.js index 8f2a76b..8897829 100644 --- a/frontend/js/trainer/generators.js +++ b/frontend/js/trainer/generators.js @@ -25,7 +25,10 @@ { key: 'proportions', label: 'Пропорции', subject: 'algebra', grade: 7, order: 2 }, { 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 } + { key: 'quadratic', label: 'Квадратные', subject: 'algebra', grade: 8, order: 5 }, + { key: 'powers', label: 'Степени', subject: 'algebra', grade: 8, order: 6 }, + { key: 'formulas', label: 'Формулы', subject: 'algebra', grade: 8, order: 7 }, + { key: 'inequalities', label: 'Неравенства', subject: 'algebra', grade: 8, order: 8 } ]; var GENERATORS = [ @@ -312,6 +315,133 @@ { note: 'Первый корень:', tex: 'x = {a}' }, { note: 'Второй корень:', tex: 'x = -{a}' } ] + }, + + /* ═══ Тема: Степени ═══ */ + + /* вычислить aⁿ */ + { + id: 'pow-eval', topic: 'powers', order: 1, subject: 'algebra', grade: 8, kind: 'compute', + title: 'Вычислить степень', + pick: { a: [2, 6], n: [2, 3] }, + derive: { val: 'a^n' }, + lhs: 'x', rhs: '{a}^{n}', display: 'Вычислите {a} в степени {n}', + answerVar: 'x', answer: 'val', integerAnswer: true, + solution: [ + { note: 'Степень — это повторное умножение основания на себя.', tex: 'x = {a}^{n}' }, + { note: 'Считаем:', tex: 'x = {ans}' } + ] + }, + + /* xᵃ · xᵇ = xᵃ⁺ᵇ */ + { + id: 'pow-mult', topic: 'powers', order: 2, subject: 'algebra', grade: 8, kind: 'simplify', + title: 'Произведение степеней', + pick: { a: [2, 5], b: [2, 5] }, + derive: { s: 'a + b' }, + srcExpr: 'x^{a}*x^{b}', answerExpr: 'x^{s}', answerVars: ['x'], + display: 'Упростите: x^{a}·x^{b}', + solution: [ + { note: 'При умножении степеней с одинаковым основанием показатели складываются: {a} + {b} = {s}.', tex: 'x^{a}*x^{b} = x^{s}' } + ] + }, + + /* (xᵃ)ᵇ = xᵃᵇ */ + { + id: 'pow-pow', topic: 'powers', order: 3, subject: 'algebra', grade: 8, kind: 'simplify', + title: 'Степень степени', + pick: { a: [2, 4], b: [2, 3] }, + derive: { ab: 'a*b' }, + srcExpr: '(x^{a})^{b}', answerExpr: 'x^{ab}', answerVars: ['x'], + display: 'Упростите: (x^{a})^{b}', + solution: [ + { note: 'При возведении степени в степень показатели перемножаются: {a}·{b} = {ab}.', tex: '(x^{a})^{b} = x^{ab}' } + ] + }, + + /* ═══ Тема: Формулы сокращённого умножения ═══ */ + + /* (x + a)² */ + { + id: 'sq-sum', topic: 'formulas', order: 1, subject: 'algebra', grade: 8, kind: 'simplify', + title: 'Квадрат суммы', + pick: { a: [1, 9] }, + derive: { a2: 'a*a', a2x: '2*a' }, + srcExpr: '(x + {a})^2', answerExpr: 'x^2 + {a2x}*x + {a2}', answerVars: ['x'], + display: 'Раскройте: (x + {a})²', + solution: [ + { note: 'Квадрат суммы: (x + {a})² = x² + 2·{a}·x + {a}².', tex: '(x + {a})^2 = x^2 + {a2x}*x + {a2}' } + ] + }, + + /* (x − a)² */ + { + id: 'sq-diff', topic: 'formulas', order: 2, subject: 'algebra', grade: 8, kind: 'simplify', + title: 'Квадрат разности', + pick: { a: [1, 9] }, + derive: { a2: 'a*a', a2x: '2*a' }, + srcExpr: '(x - {a})^2', answerExpr: 'x^2 - {a2x}*x + {a2}', answerVars: ['x'], + display: 'Раскройте: (x − {a})²', + solution: [ + { note: 'Квадрат разности: (x − {a})² = x² − 2·{a}·x + {a}².', tex: '(x - {a})^2 = x^2 - {a2x}*x + {a2}' } + ] + }, + + /* (x − a)(x + a) = x² − a² */ + { + id: 'diff-sq', topic: 'formulas', order: 3, subject: 'algebra', grade: 8, kind: 'simplify', + title: 'Разность квадратов', + pick: { a: [2, 9] }, + derive: { a2: 'a*a' }, + srcExpr: '(x - {a})*(x + {a})', answerExpr: 'x^2 - {a2}', answerVars: ['x'], + display: 'Раскройте: (x − {a})(x + {a})', + solution: [ + { note: 'Произведение разности и суммы даёт разность квадратов: (x − {a})(x + {a}) = x² − {a}².', tex: '(x - {a})*(x + {a}) = x^2 - {a2}' } + ] + }, + + /* ═══ Тема: Линейные неравенства ═══ */ + + /* ax + b < c (a>0, знак сохраняется) */ + { + id: 'ineq-lt', topic: 'inequalities', order: 1, subject: 'algebra', grade: 8, kind: 'inequality', + title: 'ax + b < c', + pick: { a: [2, 6], b: [1, 15], root: [-8, 8] }, + derive: { c: 'a*root + b', cmb: 'a*root' }, + lhs: '{a}*x + {b}', rhs: '{c}', dispOp: '<', relOp: '<', bound: 'root', + answerVar: 'x', + solution: [ + { note: 'Переносим свободный член {b} вправо:', tex: '{a}x < {cmb}' }, + { note: 'Делим обе части на {a} — число положительное, знак неравенства не меняется:', tex: 'x < {root}' } + ] + }, + + /* ax + b ≥ c (a>0) */ + { + id: 'ineq-ge', topic: 'inequalities', order: 2, subject: 'algebra', grade: 8, kind: 'inequality', + title: 'ax + b ≥ c', + pick: { a: [2, 6], b: [1, 15], root: [-8, 8] }, + derive: { c: 'a*root + b', cmb: 'a*root' }, + lhs: '{a}*x + {b}', rhs: '{c}', dispOp: '>=', relOp: '>=', bound: 'root', + answerVar: 'x', + solution: [ + { note: 'Переносим {b} вправо:', tex: '{a}x >= {cmb}' }, + { note: 'Делим на {a} (положительное) — знак сохраняется:', tex: 'x >= {root}' } + ] + }, + + /* −ax + b < c (коэффициент отрицательный → знак МЕНЯЕТСЯ) */ + { + id: 'ineq-flip', topic: 'inequalities', order: 3, subject: 'algebra', grade: 8, kind: 'inequality', + title: '−ax + b < c (смена знака)', + pick: { a: [2, 6], b: [1, 15], root: [-8, 8] }, + derive: { c: 'b - a*root', cmb: '-a*root' }, + lhs: '-{a}*x + {b}', rhs: '{c}', dispOp: '<', relOp: '>', bound: 'root', + answerVar: 'x', + solution: [ + { note: 'Переносим {b} вправо:', tex: '-{a}x < {cmb}' }, + { note: 'Делим на отрицательное число (−{a}) — знак неравенства МЕНЯЕТСЯ на противоположный:', tex: 'x > {root}' } + ] } ]; diff --git a/frontend/trainer.html b/frontend/trainer.html index 3ca215f..98b5147 100644 --- a/frontend/trainer.html +++ b/frontend/trainer.html @@ -643,17 +643,23 @@ // Префикс «x =» и подсказка ввода зависят от типа задачи. function applyInputMode() { var k = cur && cur.kind; - var multi = (k === 'roots' || k === 'simplify'); + var multi = (k === 'roots' || k === 'simplify' || k === 'inequality'); var eqx = $('tr-eqx'); if (eqx) eqx.style.display = multi ? 'none' : ''; - $('tr-input').placeholder = (k === 'roots') ? 'корни через ;' : (k === 'simplify') ? 'упрощённое выражение' : 'ответ'; + $('tr-input').placeholder = (k === 'roots') ? 'корни через ;' + : (k === 'simplify') ? 'упрощённое выражение' + : (k === 'inequality') ? ('напр. ' + (cur.answerVar || 'x') + ' < 3') + : 'ответ'; var tog = $('tr-step-toggle'); if (tog) tog.style.display = canStep() ? '' : 'none'; } // Текст ответа в фидбеке/раскрытии — по типу задачи. + var REL_SYM = { '<': '<', '>': '>', '<=': '≤', '>=': '≥' }; function answerLabel() { if (cur.kind === 'roots' && cur.answers) return 'Корни: ' + cur.answers.map(fmt).join('; '); if (cur.kind === 'simplify') return '= ' + (cur.answerExpr ? fmt(cur.answerExpr) : ''); + if (cur.kind === 'inequality' && cur.answerRel) return (cur.answerVar || 'x') + ' ' + (REL_SYM[cur.answerRel.op] || cur.answerRel.op) + ' ' + fmt(cur.answerRel.bound); return 'x = ' + fmt(cur.answer); } + function isLabelKind() { return cur.kind === 'roots' || cur.kind === 'simplify' || cur.kind === 'inequality'; } function updateStats() { $('tr-solved').textContent = solved; $('tr-streak').textContent = streak; } function stepHtml(st, n) { @@ -764,7 +770,7 @@ $('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(); + if (isLabelKind()) fb.textContent = 'Ответ: ' + answerLabel(); else setMath(fb, 'x = ' + cur.answer, 'Ответ: x = ' + fmt(cur.answer), false); setMode(true); recordAnswer(false); submitAttempt(false); @@ -783,7 +789,7 @@ setMode(true); if (r.ok) { fb.className = 'tr-feedback ok'; - var lbl = (cur.kind === 'roots' || cur.kind === 'simplify') ? esc(answerLabel()) + var lbl = isLabelKind() ? esc(answerLabel()) : (kat('x = ' + cur.answer, false) || esc('x = ' + fmt(cur.answer))); fb.innerHTML = ICON.ok + ' Верно! ' + lbl; $('tr-input').disabled = true; diff --git a/plans/ai-trainer/ROADMAP_V2.md b/plans/ai-trainer/ROADMAP_V2.md index 38ef027..4623cd7 100644 --- a/plans/ai-trainer/ROADMAP_V2.md +++ b/plans/ai-trainer/ROADMAP_V2.md @@ -50,7 +50,18 @@ solved-форме `x=c` → общий `onSolved` (засчитывается к (`callLLMFailover`) — только ОБЪЯСНЕНИЯ (безопасно, не генерация задач). - 3 уровня подсказок (намёк → шаг → решение). -## P10 — Контент 5–9 классов + ЦТ +## P10 — Контент 5–9 классов + ЦТ — DONE (частично) +**Сделано:** +3 темы (8 всего, 26 генераторов): **Степени** (вычислить aⁿ; xᵃ·xᵇ; (xᵃ)ᵇ), +**Формулы сокр. умножения** (квадрат суммы/разности, разность квадратов), **Линейные +неравенства** — НОВЫЙ тип `kind:'inequality'` (`answerRel:{op,bound}`; парсер отношения +`_parseRel`/`_checkInequality` — нормализация «x op c», приём обратной записи «c op x», +сверка op+границы; ineq-flip учит смене знака при делении на отрицательное). Движок: +**simplify теперь рендерит выражение в KaTeX** (`latex = exprToLatex(srcExpr)`, eyebrow = +действие), неравенство — в KaTeX с отношением; self-check неравенства (внутри/снаружи +решения). Страница: ввод/лейбл для неравенств (`x < 3`), `isLabelKind`. Смоук 397/397 +(T15 неравенства, T16 степени/формулы). **Осталось (стретч):** системы 2 ур-ний +(пара-ответ), дроби 5–6, явная привязка к таксономии ЦТ. + Расширить охват и связать с подготовкой к ЦТ/ЦЭ. - Новые темы: арифметика/дроби/десятичные (5–6), степени, формулы сокр. умножения, разложение на множители, **линейные неравенства** (новый тип ответа: парсинг и