feat(trainer): P1 — темы/навыки, +8 генераторов, подробные пошаговые решения

- таксономия тема→навык (topics/byTopic), метаданные topic/order/subject/grade
- 13 генераторов в 3 темах: Уравнения (+a(x+b)=c(x+d), (ax+b)/c=d), Пропорции (3), Проценты (3)
- проценты как compute-задачи: текстовый prompt + проверка подстановкой (latex уравнения скрыт)
- подробные объяснения: каждый шаг расписан словами + шаг «Проверка» (подстановка корня)
- UI: вкладки тем + чипы навыков, бейджи мастерства, авто-выбор первой неосвоенной темы/навыка
- движок: exprToLatex чинит отрицательные множители (7·(−5)), поле kind, нумерованные шаги решения
- смоуки 238/238 (движок) + 19/19 (страница); план: P1 отмечен DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-25 13:29:44 +03:00
parent c370eaa803
commit 20b8ce2c5b
4 changed files with 314 additions and 99 deletions
+15 -4
View File
@@ -134,6 +134,12 @@
var s = _latex(node);
return _prec(node) < minPrec ? '\\left(' + s + '\\right)' : s;
}
// Операнд умножения: отрицательное/унарное/сумму берём в скобки, иначе
// соседство схлопнет смысл (7*(-5) -> «7-5», 6*(x+1) -> «6x+1»).
function _mulOperand(node) {
if (_isNeg(node) || _prec(node) < 2) return '\\left(' + _latex(node) + '\\right)';
return _latex(node);
}
function _latex(node) {
switch (node.k) {
case 'num': return fmtNum(node.v);
@@ -168,8 +174,8 @@
return base + '^{' + _latex(node.b) + '}';
}
if (op === '*') {
var sep = (node.b.k === 'num') ? ' \\cdot ' : ''; // ·число; иначе соседство (7x, 6(x+1))
return _wrapL(node.a, 2) + sep + _wrapL(node.b, 2);
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)
@@ -233,12 +239,17 @@
var lhsExpr = render(gen.lhs, env);
var rhsExpr = render(gen.rhs, env);
var sEnv = assign(env, { ans: answer });
var ll = exprToLatex(lhsExpr), rl = exprToLatex(rhsExpr);
// compute-задача (проценты): показываем текстовый prompt из display, а
// уравнение lhs=rhs служит лишь для проверки → latex уравнения не строим.
var isCompute = gen.kind === 'compute';
var ll = isCompute ? null : exprToLatex(lhsExpr);
var rl = isCompute ? null : exprToLatex(rhsExpr);
var problem = {
genId: gen.id,
skill: gen.skill,
skill: gen.skill || gen.id, // ключ прогресса = id генератора, если skill не задан
title: gen.title,
kind: gen.kind || 'solve',
lhsExpr: lhsExpr,
rhsExpr: rhsExpr,
display: prettyMath(render(gen.display || (gen.lhs + ' = ' + gen.rhs), env)),
+212 -67
View File
@@ -1,115 +1,253 @@
'use strict';
/* ════════════════════════════════════════════════════════════════════════
Генераторы уравнений — 7 класс (прототип). Это ДАННЫЕ, не код.
Генераторы задач тренажёра — ДАННЫЕ, не код. Таксономия: тема → навык.
Приём «корень-вперёд»: выбираем целый корень (или множитель) и коэффициенты,
затем ВЫВОДИМ свободный член так, чтобы ответ гарантированно был целым, а
уравнение — решаемым. Поэтому самопроверка движка (verifyRoot) всегда
проходит. Шаг решения — { note(текст), tex(формула) }; tex рендерится в KaTeX
через TrainerEngine.exprToLatex (одно равенство на шаг, без цепочек a=b=c).
Приём «корень-вперёд»: выбираем целый корень/множители и коэффициенты, затем
ВЫВОДИМ остальное так, чтобы ответ был целым, а задача — решаемой. Поэтому
самопроверка движка (verifyRoot) всегда проходит. Шаг решения —
{ note(подробный текст), tex(одно равенство) }; tex рендерится в KaTeX
(exprToLatex). Последний шаг «Проверка» подставляет корень — это и педагогика,
и наглядная демонстрация того, как движок проверяет ответ.
Прогрессия 7 класса: простое линейное → скобки → переменная в обеих частях →
уравнение с дробью в знаменателе → дробный коэффициент. Дальше (Уровень 1):
Виды задач:
• solve (деф.) — уравнение lhs = rhs, ученик находит x. Показывается уравнение.
• compute (kind:'compute') — вычислительная задача (проценты): на сцене —
текстовый prompt из display, а lhs:'x' / rhs:<значение> служат ТОЛЬКО для
проверки ответа подстановкой (latex уравнения не показывается).
Темы (7 класс, алгебра): Уравнения → Пропорции → Проценты. Дальше (Уровень 1):
текстовые задачи через LLM с той же подстановочной верификацией.
════════════════════════════════════════════════════════════════════════ */
(function (global) {
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 }
];
var GENERATORS = [
/* 1. ax + b = c */
/* ═══ Тема: Уравнения ═══ */
/* ax + b = c */
{
id: 'lin-basic',
skill: 'linear-basic',
title: 'Линейное: ax + b = c',
grade: 7,
id: 'lin-basic', topic: 'linear-eq', order: 1, subject: 'algebra', grade: 7,
title: 'ax + b = c',
pick: { a: [2, 9], b: [1, 20], root: [-9, 9] },
require: 'root != 0',
derive: { c: 'a*root + b', cmb: 'a*root' }, // cmb = c - b
lhs: '{a}*x + {b}', rhs: '{c}',
display: '{a}x + {b} = {c}',
derive: { c: 'a*root + b', cmb: 'a*root' },
lhs: '{a}*x + {b}', rhs: '{c}', display: '{a}x + {b} = {c}',
answerVar: 'x', answer: 'root', integerAnswer: true,
solution: [
{ note: 'Переносим число вправо:', tex: '{a}x = {cmb}' },
{ note: 'Делим обе части на {a}:', tex: 'x = {cmb} / {a}' },
{ note: 'Ответ:', tex: 'x = {ans}' }
{ note: 'Перед нами линейное уравнение. Наша цель — оставить x одного в левой части. Сначала уберём свободное число {b}: перенесём его вправо, поменяв знак.', tex: '{a}x = {c} - {b}' },
{ note: 'Выполняем вычитание в правой части.', tex: '{a}x = {cmb}' },
{ note: 'Осталось избавиться от множителя при x — делим обе части уравнения на {a}.', tex: 'x = {cmb} / {a}' },
{ note: 'Получаем корень уравнения.', tex: 'x = {ans}' },
{ note: 'Проверка: подставим найденное значение в исходное уравнение — левая часть должна совпасть с правой.', tex: '{a}*({ans}) + {b} = {c}' }
]
},
/* 2. a(x + b) = c */
/* a(x + b) = c */
{
id: 'lin-paren',
skill: 'linear-parentheses',
title: 'Со скобками: a(x + b) = c',
grade: 7,
id: 'lin-paren', topic: 'linear-eq', order: 2, subject: 'algebra', grade: 7,
title: 'a(x + b) = c',
pick: { a: [2, 8], b: [1, 12], root: [-9, 9] },
require: 'root != 0',
derive: { c: 'a*(root + b)', ca: 'root + b' }, // ca = c / a
lhs: '{a}*(x + {b})', rhs: '{c}',
display: '{a}(x + {b}) = {c}',
derive: { c: 'a*(root + b)', ca: 'root + b' },
lhs: '{a}*(x + {b})', rhs: '{c}', display: '{a}(x + {b}) = {c}',
answerVar: 'x', answer: 'root', integerAnswer: true,
solution: [
{ note: 'Делим обе части на {a}:', tex: 'x + {b} = {ca}' },
{ note: 'Переносим {b} влево:', tex: 'x = {ca} - {b}' },
{ note: 'Ответ:', tex: 'x = {ans}' }
{ note: 'Слева число {a} умножается на скобку. Самый короткий путь — разделить обе части уравнения на {a}, чтобы убрать этот множитель.', tex: 'x + {b} = {ca}' },
{ note: 'Справа получилось целое число. Теперь переносим {b} вправо со сменой знака.', tex: 'x = {ca} - {b}' },
{ note: 'Получаем корень уравнения.', tex: 'x = {ans}' },
{ note: 'Проверка: подставляем корень в скобку исходного уравнения.', tex: '{a}*({ans} + {b}) = {c}' }
]
},
/* 3. ax + b = cx + d */
/* ax + b = cx + d */
{
id: 'lin-both-sides',
skill: 'linear-both-sides',
title: 'Переменная с двух сторон: ax + b = cx + d',
grade: 7,
id: 'lin-both-sides', topic: 'linear-eq', order: 3, subject: 'algebra', grade: 7,
title: 'ax + b = cx + d',
pick: { a: [3, 9], c: [1, 8], b: [1, 20], root: [-9, 9] },
constraint: 'c < a', // гарантируем a - c > 0
require: 'root != 0',
derive: { d: '(a - c)*root + b', amc: 'a - c', dmb: '(a - c)*root' }, // dmb = d - b
lhs: '{a}*x + {b}', rhs: '{c}*x + {d}',
display: '{a}x + {b} = {c}x + {d}',
constraint: 'c < a', require: 'root != 0',
derive: { d: '(a - c)*root + b', amc: 'a - c', dmb: '(a - c)*root' },
lhs: '{a}*x + {b}', rhs: '{c}*x + {d}', display: '{a}x + {b} = {c}x + {d}',
answerVar: 'x', answer: 'root', integerAnswer: true,
solution: [
{ note: 'Собираем x слева, числа справа:', tex: '({a} - {c})x = {d} - {b}' },
{ note: 'Приводим подобные:', tex: '{amc}x = {dmb}' },
{ note: 'Делим на {amc}:', tex: 'x = {dmb} / {amc}' },
{ note: 'Ответ:', tex: 'x = {ans}' }
{ note: 'Здесь x есть в обеих частях. Соберём все слагаемые с x слева, а числа справа. Переносимые слагаемые меняют знак.', tex: '({a} - {c})x = {d} - {b}' },
{ note: 'Приводим подобные: вычитаем коэффициенты при x и отдельно числа.', tex: '{amc}x = {dmb}' },
{ note: 'Делим обе части на коэффициент при x, то есть на {amc}.', tex: 'x = {dmb} / {amc}' },
{ note: 'Получаем корень уравнения.', tex: 'x = {ans}' },
{ note: 'Проверка: подставим корень — обе части дадут одно и то же число.', tex: '{a}*({ans}) + {b} = {c}*({ans}) + {d}' }
]
},
/* 4. x/a + b = c (дробь в знаменателе) */
/* a(x + b) = c(x + d) */
{
id: 'lin-frac-denom',
skill: 'linear-fraction-denom',
title: 'Дробь: x/a + b = c',
grade: 7,
id: 'lin-paren-both', topic: 'linear-eq', order: 4, subject: 'algebra', grade: 7,
title: 'a(x+b) = c(x+d)',
pick: { a: [2, 6], c: [2, 6], b: [1, 10], root: [-6, 6] },
constraint: 'a != c',
derive: { V: 'a*(root + b)', d: 'V/c - root', ab: 'a*b', cd: 'c*(V/c - root)', amc: 'a - c', diff: 'c*(V/c - root) - a*b' },
require: 'mod(V, c) == 0 && root != 0',
lhs: '{a}*(x + {b})', rhs: '{c}*(x + {d})', display: '{a}(x + {b}) = {c}(x + {d})',
answerVar: 'x', answer: 'root', integerAnswer: true,
solution: [
{ note: 'Скобки с двух сторон. Раскрываем их: умножаем множитель перед скобкой на каждое слагаемое внутри.', tex: '{a}x + {ab} = {c}x + {cd}' },
{ note: 'Переносим слагаемые с x влево, числа — вправо, и приводим подобные.', tex: '{amc}x = {diff}' },
{ note: 'Делим обе части на {amc}.', tex: 'x = {diff} / {amc}' },
{ note: 'Получаем корень уравнения.', tex: 'x = {ans}' },
{ note: 'Проверка: подставляем корень в обе скобки.', tex: '{a}*({ans} + {b}) = {c}*({ans} + {d})' }
]
},
/* x/a + b = c */
{
id: 'lin-frac-denom', topic: 'linear-eq', order: 5, subject: 'algebra', grade: 7,
title: 'x/a + b = c',
pick: { a: [2, 6], k: [-6, 6], b: [1, 12] },
require: 'k != 0',
derive: { root: 'a*k', c: 'k + b', cmb: 'k' }, // root = a·k, cmb = c - b = k
lhs: 'x/{a} + {b}', rhs: '{c}',
display: 'x/{a} + {b} = {c}',
derive: { root: 'a*k', c: 'k + b', cmb: 'k' },
lhs: 'x/{a} + {b}', rhs: '{c}', display: 'x/{a} + {b} = {c}',
answerVar: 'x', answer: 'root', integerAnswer: true,
solution: [
{ note: 'Вычитаем {b}:', tex: 'x/{a} = {cmb}' },
{ note: 'Умножаем обе части на {a}:', tex: 'x = {cmb} * {a}' },
{ note: 'Ответ:', tex: 'x = {ans}' }
{ note: 'Слева — дробь x/{a} и число {b}. Сначала уберём свободное число: вычтем {b} из обеих частей.', tex: 'x/{a} = {cmb}' },
{ note: 'Чтобы избавиться от знаменателя {a}, умножаем обе части уравнения на {a}.', tex: 'x = {cmb} * {a}' },
{ note: 'Получаем корень уравнения.', tex: 'x = {ans}' },
{ note: 'Проверка: подставляем корень в исходное уравнение.', tex: '{ans}/{a} + {b} = {c}' }
]
},
/* 5. (a·x)/b = c (дробный коэффициент) */
/* (a·x)/b = c */
{
id: 'lin-coef-frac',
skill: 'linear-coef-frac',
title: 'Дробный коэффициент: ax/b = c',
grade: 7,
id: 'lin-coef-frac', topic: 'linear-eq', order: 6, subject: 'algebra', grade: 7,
title: 'ax/b = c',
pick: { a: [2, 5], b: [2, 5], m: [-5, 5] },
require: 'm != 0',
derive: { root: 'b*m', c: 'a*m', cb: 'a*m*b' }, // root = b·m, c = a·m, cb = c·b
lhs: '{a}*x/{b}', rhs: '{c}',
display: '{a}x/{b} = {c}',
derive: { root: 'b*m', c: 'a*m', cb: 'a*m*b' },
lhs: '{a}*x/{b}', rhs: '{c}', display: '{a}x/{b} = {c}',
answerVar: 'x', answer: 'root', integerAnswer: true,
solution: [
{ note: 'Умножаем обе части на {b}:', tex: '{a}x = {cb}' },
{ note: 'Делим на {a}:', tex: 'x = {cb} / {a}' },
{ note: 'Ответ:', tex: 'x = {ans}' }
{ note: 'Слева дробь, в числителе — {a}x. Умножаем обе части уравнения на знаменатель {b}, чтобы избавиться от дроби.', tex: '{a}x = {cb}' },
{ note: 'Теперь делим обе части на коэффициент {a}.', tex: 'x = {cb} / {a}' },
{ note: 'Получаем корень уравнения.', tex: 'x = {ans}' },
{ note: 'Проверка: подставляем корень в исходную дробь.', tex: '{a}*({ans})/{b} = {c}' }
]
},
/* (ax + b)/c = d */
{
id: 'lin-frac-eq', topic: 'linear-eq', order: 7, subject: 'algebra', grade: 7,
title: '(ax + b)/c = d',
pick: { a: [2, 6], b: [1, 12], c: [2, 6], root: [-6, 6] },
derive: { prod: 'a*root + b', d: '(a*root + b)/c', cd: 'a*root + b', cdmb: 'a*root' },
require: 'mod(a*root + b, c) == 0 && root != 0',
lhs: '({a}*x + {b})/{c}', rhs: '{d}', display: '({a}x + {b})/{c} = {d}',
answerVar: 'x', answer: 'root', integerAnswer: true,
solution: [
{ note: 'Вся левая часть делится на {c}. Умножаем обе части уравнения на {c}, чтобы убрать знаменатель.', tex: '{a}x + {b} = {cd}' },
{ note: 'Переносим число {b} в правую часть со сменой знака.', tex: '{a}x = {cdmb}' },
{ note: 'Делим обе части на {a}.', tex: 'x = {cdmb} / {a}' },
{ note: 'Получаем корень уравнения.', tex: 'x = {ans}' },
{ note: 'Проверка: подставляем корень в исходную дробь.', tex: '({a}*({ans}) + {b})/{c} = {d}' }
]
},
/* ═══ Тема: Пропорции ═══ */
/* a/b = c/x */
{
id: 'prop-x-right', topic: 'proportions', order: 1, subject: 'algebra', grade: 7,
title: 'a/b = c/x',
pick: { a: [2, 9], b: [2, 9], t: [2, 9] },
derive: { c: 'a*t', root: 'b*t', bc: 'b*a*t' },
lhs: '{a}/{b}', rhs: '{c}/x', display: '{a}/{b} = {c}/x',
answerVar: 'x', answer: 'root', integerAnswer: true,
solution: [
{ note: 'Это пропорция — равенство двух отношений. По основному свойству пропорции произведение крайних членов равно произведению средних (умножаем «крест-накрест»).', tex: '{a}*x = {b} * {c}' },
{ note: 'Считаем произведение в правой части.', tex: '{a}x = {bc}' },
{ note: 'Делим обе части на {a}, чтобы найти x.', tex: 'x = {bc} / {a}' },
{ note: 'Получаем корень.', tex: 'x = {ans}' },
{ note: 'Проверка: при найденном x обе дроби равны.', tex: '{a}/{b} = {c}/{ans}' }
]
},
/* x/a = b/c */
{
id: 'prop-x-left', topic: 'proportions', order: 2, subject: 'algebra', grade: 7,
title: 'x/a = b/c',
pick: { a: [2, 9], c: [2, 9], s: [2, 9] },
derive: { b: 'c*s', root: 'a*s', ab: 'a*c*s' },
lhs: 'x/{a}', rhs: '{b}/{c}', display: 'x/{a} = {b}/{c}',
answerVar: 'x', answer: 'root', integerAnswer: true,
solution: [
{ note: 'Перед нами пропорция. Перемножаем её члены крест-накрест: числитель левой дроби на знаменатель правой и наоборот.', tex: '{c}*x = {a} * {b}' },
{ note: 'Считаем произведение в правой части.', tex: '{c}x = {ab}' },
{ note: 'Делим обе части на {c}.', tex: 'x = {ab} / {c}' },
{ note: 'Получаем корень.', tex: 'x = {ans}' },
{ note: 'Проверка: обе дроби равны.', tex: '{ans}/{a} = {b}/{c}' }
]
},
/* a/x = b/c */
{
id: 'prop-x-denom', topic: 'proportions', order: 3, subject: 'algebra', grade: 7,
title: 'a/x = b/c',
pick: { b: [2, 9], c: [2, 9], s: [2, 9] },
derive: { a: 'b*s', root: 'c*s', ac: 'b*s*c' },
lhs: '{a}/x', rhs: '{b}/{c}', display: '{a}/x = {b}/{c}',
answerVar: 'x', answer: 'root', integerAnswer: true,
solution: [
{ note: 'Пропорция, где неизвестное стоит в знаменателе. Перемножаем крест-накрест.', tex: '{b}*x = {a} * {c}' },
{ note: 'Считаем произведение в правой части.', tex: '{b}x = {ac}' },
{ note: 'Делим обе части на {b}.', tex: 'x = {ac} / {b}' },
{ note: 'Получаем корень.', tex: 'x = {ans}' },
{ note: 'Проверка: обе дроби равны.', tex: '{a}/{ans} = {b}/{c}' }
]
},
/* ═══ Тема: Проценты (вычислительные задачи) ═══ */
/* p% от числа a */
{
id: 'pct-of', topic: 'percents', order: 1, subject: 'algebra', grade: 7, kind: 'compute',
title: 'p% от числа',
pick: { pidx: [2, 10], abase: [1, 15] },
derive: { p: 'pidx*5', a: 'abase*20', val: 'pidx*abase' },
lhs: 'x', rhs: '{p}*{a}/100', display: 'Найдите {p}% от числа {a}',
answerVar: 'x', answer: 'val', integerAnswer: true,
solution: [
{ note: 'Процент — это сотая доля числа. Чтобы найти {p}% от {a}, нужно умножить число на {p} и разделить на 100.', tex: 'x = {a}*{p}/100' },
{ note: 'Выполняем умножение и деление — получаем ответ.', tex: 'x = {ans}' }
]
},
/* сколько % составляет a от b */
{
id: 'pct-what', topic: 'percents', order: 2, subject: 'algebra', grade: 7, kind: 'compute',
title: 'Сколько процентов',
pick: { pidx: [2, 10], bbase: [1, 8] },
derive: { p: 'pidx*5', b: 'bbase*20', a: 'pidx*bbase' },
lhs: 'x', rhs: '100*{a}/{b}', display: 'Сколько процентов составляет {a} от {b}?',
answerVar: 'x', answer: 'p', integerAnswer: true,
solution: [
{ note: 'Чтобы узнать, какую часть {a} составляет от {b}, делим {a} на {b}. А чтобы перевести эту часть в проценты — умножаем результат на 100.', tex: 'x = {a}/{b}*100' },
{ note: 'Считаем — ответ получается в процентах.', tex: 'x = {ans}' }
]
},
/* p% числа равны a — найти число */
{
id: 'pct-whole', topic: 'percents', order: 3, subject: 'algebra', grade: 7, kind: 'compute',
title: 'Число по проценту',
pick: { pidx: [2, 10], wbase: [1, 12] },
derive: { p: 'pidx*5', whole: 'wbase*20', a: 'pidx*wbase' },
lhs: 'x', rhs: '100*{a}/{p}', display: '{p}% числа равны {a}. Найдите это число.',
answerVar: 'x', answer: 'whole', integerAnswer: true,
solution: [
{ note: 'Известно, что {p}% некоторого числа равны {a}. Значит само число во столько раз больше: умножаем {a} на 100 и делим на {p}.', tex: 'x = {a}*100/{p}' },
{ note: 'Считаем — получаем искомое число.', tex: 'x = {ans}' }
]
}
@@ -119,11 +257,18 @@
for (var i = 0; i < GENERATORS.length; i++) if (GENERATORS[i].id === id) return GENERATORS[i];
return null;
}
function byTopic(key) {
return GENERATORS.filter(function (g) { return g.topic === key; })
.sort(function (a, b) { return (a.order || 0) - (b.order || 0); });
}
global.TrainerGenerators = {
list: function () { return GENERATORS.slice(); },
topics: function () { return TOPICS.slice(); },
byTopic: byTopic,
get: get,
GENERATORS: GENERATORS
GENERATORS: GENERATORS,
TOPICS: TOPICS
};
})(typeof window !== 'undefined' ? window : globalThis);