From 20b8ce2c5b1a0ad67c02ca07fab212a76d61aecc Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Thu, 25 Jun 2026 13:29:44 +0300 Subject: [PATCH] =?UTF-8?q?feat(trainer):=20P1=20=E2=80=94=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B/=D0=BD=D0=B0=D0=B2=D1=8B=D0=BA=D0=B8,=20+8=20?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B2,=20=D0=BF=D0=BE=D0=B4=D1=80=D0=BE=D0=B1=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=BE=D1=88=D0=B0=D0=B3=D0=BE=D0=B2=D1=8B=D0=B5?= =?UTF-8?q?=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - таксономия тема→навык (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) --- frontend/js/trainer/_trainer_engine.js | 19 +- frontend/js/trainer/generators.js | 279 +++++++++++++++++++------ frontend/trainer.html | 102 ++++++--- plans/ai-trainer/PLAN.md | 13 +- 4 files changed, 314 insertions(+), 99 deletions(-) diff --git a/frontend/js/trainer/_trainer_engine.js b/frontend/js/trainer/_trainer_engine.js index 646cf44..5bdcae4 100644 --- a/frontend/js/trainer/_trainer_engine.js +++ b/frontend/js/trainer/_trainer_engine.js @@ -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)), diff --git a/frontend/js/trainer/generators.js b/frontend/js/trainer/generators.js index c4e3e6f..e8be9a1 100644 --- a/frontend/js/trainer/generators.js +++ b/frontend/js/trainer/generators.js @@ -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); diff --git a/frontend/trainer.html b/frontend/trainer.html index d9de1b8..47e2e28 100644 --- a/frontend/trainer.html +++ b/frontend/trainer.html @@ -75,11 +75,24 @@ background: #f8fafc; border: 1px solid rgba(148,163,184,0.22); } .tr-solution h4 { margin: 0 0 10px; font-size: .82rem; text-transform: uppercase; letter-spacing: .04em; color: #64748b; } - .tr-step { font-size: 1.05rem; color: #334155; padding: 7px 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; } + .tr-step { color: #334155; padding: 11px 0; } .tr-step + .tr-step { border-top: 1px dashed rgba(148,163,184,0.28); } - .tr-step-note { color: #64748b; font-family: 'Manrope', sans-serif; font-size: .85rem; } - .tr-step-math { font-family: 'Cambria Math', serif; } + .tr-step-note { display: block; color: #475569; font-family: 'Manrope', sans-serif; font-size: .92rem; line-height: 1.55; margin-bottom: 6px; } + .tr-step-math { display: block; font-family: 'Cambria Math', serif; font-size: 1.15rem; margin-left: 28px; } + .tr-step-n { display: inline-flex; align-items: center; justify-content: center; width: 21px; height: 21px; border-radius: 50%; background: #e0e7ff; color: #4f46e5; font-family: 'Manrope', sans-serif; font-size: .72rem; font-weight: 800; margin-right: 8px; vertical-align: 1px; } .tr-eq .katex-display { margin: 0; } + /* текстовый prompt (проценты) — компактнее уравнения */ + .tr-eq.tr-eq-text { font-family: 'Manrope', sans-serif; font-weight: 600; font-size: clamp(1.1rem, 3.4vw, 1.55rem); line-height: 1.4; color: #1e293b; } + + /* выбор навыка внутри темы */ + .tr-skills { display: flex; flex-wrap: wrap; gap: 7px; margin: -8px 0 22px; } + .tr-skill { + font: inherit; font-size: .85rem; font-weight: 600; cursor: pointer; font-family: 'Cambria Math', 'Times New Roman', serif; + padding: 6px 12px; border-radius: 10px; border: 1px solid rgba(148,163,184,0.3); background: #fff; color: #475569; transition: .15s; + display: inline-flex; align-items: center; + } + .tr-skill:hover { border-color: #818cf8; color: #4338ca; } + .tr-skill.on { background: #eef2ff; border-color: #818cf8; color: #4338ca; } /* бейджи прогресса на чипах */ .tr-badge { display: inline-flex; margin-left: 7px; color: #16a34a; vertical-align: middle; } @@ -108,6 +121,7 @@
+
@@ -205,21 +219,38 @@ if (h) el.innerHTML = h; else el.textContent = fallbackText; } - var curGen = gens[0]; + var topics = TG.topics ? TG.topics() : [{ key: null, label: 'Задачи' }]; + function skillKey(g) { return g.skill || g.id; } + function skillsOf(topicKey) { return TG.byTopic ? TG.byTopic(topicKey) : gens; } + + var curTopic = topics[0] ? topics[0].key : null; + var curGen = skillsOf(curTopic)[0] || gens[0]; var cur = null; var solved = 0, streak = 0; var answered = false; // задача закрыта (верно/решение показано) → «Проверить» становится «Дальше» var prog = {}; // skill → строка прогресса с сервера - function chipBadge(skill) { - var p = prog[skill]; + function topicMastered(topicKey) { + var ss = skillsOf(topicKey); + return ss.length > 0 && ss.every(function (g) { var p = prog[skillKey(g)]; return p && p.mastered; }); + } + function skillBadge(g) { + var p = prog[skillKey(g)]; if (p && p.mastered) return '' + ICON.star + ''; if (p && p.solved) return '' + p.solved + ''; return ''; } + function renderTopics() { - $('tr-topics').innerHTML = gens.map(function (g, i) { - return ''; + $('tr-topics').innerHTML = topics.map(function (t, i) { + var done = topicMastered(t.key) ? '' + ICON.star + '' : ''; + return ''; + }).join(''); + } + function renderSkills() { + var ss = skillsOf(curTopic); + $('tr-skills').innerHTML = ss.map(function (g, i) { + return ''; }).join(''); } @@ -229,9 +260,11 @@ } function updateStats() { $('tr-solved').textContent = solved; $('tr-streak').textContent = streak; } - function stepHtml(st) { + function stepHtml(st, n) { if (!st) return ''; - var note = st.note ? '' + esc(st.note) + '' : ''; + var num = '' + n + ''; + var note = st.note ? '' + num + esc(st.note) + '' + : '' + num + ''; var math = ''; if (st.latex) { var h = kat(st.latex, false); math = '' + (h || esc(st.tex || '')) + ''; } else if (st.tex) { math = '' + esc(st.tex) + ''; } @@ -245,7 +278,9 @@ if (!cur) { $('tr-eq').textContent = 'Не удалось сгенерировать задачу'; return; } $('tr-skill').textContent = curGen.title; - setMath($('tr-eq'), cur.latex, cur.display, true); + var eq = $('tr-eq'); + eq.classList.toggle('tr-eq-text', !cur.latex); // текстовый prompt (проценты) — другим шрифтом + setMath(eq, cur.latex, cur.display, true); var inp = $('tr-input'); inp.value = ''; inp.disabled = false; var fb = $('tr-feedback'); fb.className = 'tr-feedback'; fb.textContent = ''; @@ -257,15 +292,19 @@ // фоновая отправка попытки на сервер (прогресс/мастерство) function submitAttempt(correct) { if (!LS.practiceSubmit) return; - LS.practiceSubmit(curGen.skill, correct).then(function (r) { - if (r && r.progress) { prog[r.progress.skill] = r.progress; renderTopics(); } + LS.practiceSubmit(skillKey(curGen), correct).then(function (r) { + if (r && r.progress) { prog[r.progress.skill] = r.progress; renderSkills(); renderTopics(); } }).catch(function () {}); } + function solutionHtml(title) { + var steps = (cur.solution || []).map(function (st, i) { return stepHtml(st, i + 1); }).join(''); + return '

' + title + '

' + (steps || '
x = ' + esc(fmt(cur.answer)) + '
'); + } + function revealAnswer(giveUp) { var s = $('tr-solution'); - var steps = (cur.solution || []).map(stepHtml).join(''); - s.innerHTML = '

Решение

' + (steps || '
x = ' + esc(fmt(cur.answer)) + '
'); + s.innerHTML = solutionHtml('Решение'); s.style.display = 'block'; if (giveUp) { streak = 0; @@ -303,31 +342,42 @@ // ── события ── $('tr-topics').addEventListener('click', function (e) { var b = e.target.closest('.tr-chip'); if (!b) return; - curGen = gens[+b.getAttribute('data-i')] || gens[0]; - renderTopics(); - newProblem(); + var t = topics[+b.getAttribute('data-ti')]; if (!t) return; + curTopic = t.key; + var ss = skillsOf(curTopic); + // первый неосвоенный навык темы, иначе первый + curGen = ss[0] || curGen; + for (var i = 0; i < ss.length; i++) { var p = prog[skillKey(ss[i])]; if (!(p && p.mastered)) { curGen = ss[i]; break; } } + renderTopics(); renderSkills(); newProblem(); + }); + $('tr-skills').addEventListener('click', function (e) { + var b = e.target.closest('.tr-skill'); if (!b) return; + var ss = skillsOf(curTopic); + curGen = ss[+b.getAttribute('data-si')] || curGen; + renderSkills(); newProblem(); }); $('tr-check').addEventListener('click', check); $('tr-skip').addEventListener('click', newProblem); $('tr-hint').addEventListener('click', function () { if (!cur) return; var s = $('tr-solution'); - s.innerHTML = '

Подсказка

' + stepHtml((cur.solution || [])[0] || { note: '', tex: 'x = ' + fmt(cur.answer), latex: null }); + s.innerHTML = '

Подсказка

' + stepHtml((cur.solution || [])[0] || { note: '', tex: 'x = ' + fmt(cur.answer), latex: null }, 1); s.style.display = 'block'; }); $('tr-solve').addEventListener('click', function () { if (cur) revealAnswer(true); }); $('tr-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); check(); } }); - $('tr-note').textContent = 'Прототип: ' + gens.length + ' генераторов · ответ проверяется подстановкой (5, x=5, 10/2, 2+3) · прогресс сохраняется.'; + $('tr-note').textContent = gens.length + ' навыков в ' + topics.length + ' темах · ответ проверяется подстановкой (5, x=5, 10/2, 2+3) · прогресс сохраняется.'; - // загрузка прогресса → старт (авто-выбор первого неосвоенного навыка) + // загрузка прогресса → старт (авто-выбор первой неосвоенной темы и навыка) function boot() { - for (var i = 0; i < gens.length; i++) { - var p = prog[gens[i].skill]; - if (!(p && p.mastered)) { curGen = gens[i]; break; } + for (var ti = 0; ti < topics.length; ti++) { + if (!topicMastered(topics[ti].key)) { curTopic = topics[ti].key; break; } } - renderTopics(); - newProblem(); + var ss = skillsOf(curTopic); + curGen = ss[0] || gens[0]; + for (var si = 0; si < ss.length; si++) { var p = prog[skillKey(ss[si])]; if (!(p && p.mastered)) { curGen = ss[si]; break; } } + renderTopics(); renderSkills(); newProblem(); } (LS.practiceProgressList ? LS.practiceProgressList() : Promise.resolve(null)) .then(function (r) { if (r && r.progress) r.progress.forEach(function (row) { prog[row.skill] = row; }); }) diff --git a/plans/ai-trainer/PLAN.md b/plans/ai-trainer/PLAN.md index daf0345..1a11772 100644 --- a/plans/ai-trainer/PLAN.md +++ b/plans/ai-trainer/PLAN.md @@ -31,9 +31,18 @@ LLM в ядре не участвует — его роль (Уровень 1+) --- -## Phase 1 — Ширина контента (генераторы) +## Phase 1 — Ширина контента (генераторы) — DONE -**Цель:** перестать быть «демкой одной темы». Структура `класс → предмет → тема → навык`. +**Сделано:** таксономия `тема → навык` с метаданными (`topic/order/subject/grade`), +`TrainerGenerators.topics()/byTopic()`. **13 генераторов в 3 темах**: Уравнения (7: +`ax+b=c`, `a(x+b)=c`, `ax+b=cx+d`, `a(x+b)=c(x+d)`, `x/a+b=c`, `ax/b=c`, `(ax+b)/c=d`), +Пропорции (3), Проценты (3, `kind:'compute'` — текстовый prompt + проверка подстановкой). +UI: выбор темы (вкладки) → навыки (чипы) с бейджами мастерства, авто-выбор первой +неосвоенной темы/навыка. **Подробные объяснения**: каждый шаг расписан словами + шаг +«Проверка» (подстановка корня). Движок: `exprToLatex` чинит отрицательные множители +(`7·(−5)`), `kind:'compute'`. Смоуки 238/238 (движок) + 19/19 (страница). + +**Цель (исходная):** перестать быть «демкой одной темы». Структура `класс → предмет → тема → навык`. - Реестр генераторов: вынести в данные с метаданными `{ grade, subject, topic, skill, order, difficulty }`. Группировка чипов по темам/классам; выбор класса/предмета вверху.