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:
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
|
||||
+76
-26
@@ -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 @@
|
||||
</div>
|
||||
|
||||
<div class="tr-topics" id="tr-topics"></div>
|
||||
<div class="tr-skills" id="tr-skills"></div>
|
||||
|
||||
<div class="tr-card">
|
||||
<div class="tr-skill" id="tr-skill"></div>
|
||||
@@ -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 '<span class="tr-badge" title="Освоено">' + ICON.star + '</span>';
|
||||
if (p && p.solved) return '<span class="tr-badge-n">' + p.solved + '</span>';
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderTopics() {
|
||||
$('tr-topics').innerHTML = gens.map(function (g, i) {
|
||||
return '<button class="tr-chip' + (g === curGen ? ' on' : '') + '" type="button" data-i="' + i + '">' + esc(g.title) + chipBadge(g.skill) + '</button>';
|
||||
$('tr-topics').innerHTML = topics.map(function (t, i) {
|
||||
var done = topicMastered(t.key) ? '<span class="tr-badge" title="Тема освоена">' + ICON.star + '</span>' : '';
|
||||
return '<button class="tr-chip' + (t.key === curTopic ? ' on' : '') + '" type="button" data-ti="' + i + '">' + esc(t.label) + done + '</button>';
|
||||
}).join('');
|
||||
}
|
||||
function renderSkills() {
|
||||
var ss = skillsOf(curTopic);
|
||||
$('tr-skills').innerHTML = ss.map(function (g, i) {
|
||||
return '<button class="tr-skill' + (g === curGen ? ' on' : '') + '" type="button" data-si="' + i + '">' + esc(g.title) + skillBadge(g) + '</button>';
|
||||
}).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 ? '<span class="tr-step-note">' + esc(st.note) + '</span>' : '';
|
||||
var num = '<span class="tr-step-n">' + n + '</span>';
|
||||
var note = st.note ? '<span class="tr-step-note">' + num + esc(st.note) + '</span>'
|
||||
: '<span class="tr-step-note">' + num + '</span>';
|
||||
var math = '';
|
||||
if (st.latex) { var h = kat(st.latex, false); math = '<span class="tr-step-math">' + (h || esc(st.tex || '')) + '</span>'; }
|
||||
else if (st.tex) { math = '<span class="tr-step-math">' + esc(st.tex) + '</span>'; }
|
||||
@@ -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 '<h4>' + title + '</h4>' + (steps || '<div class="tr-step"><span class="tr-step-math">x = ' + esc(fmt(cur.answer)) + '</span></div>');
|
||||
}
|
||||
|
||||
function revealAnswer(giveUp) {
|
||||
var s = $('tr-solution');
|
||||
var steps = (cur.solution || []).map(stepHtml).join('');
|
||||
s.innerHTML = '<h4>Решение</h4>' + (steps || '<div class="tr-step">x = ' + esc(fmt(cur.answer)) + '</div>');
|
||||
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 = '<h4>Подсказка</h4>' + stepHtml((cur.solution || [])[0] || { note: '', tex: 'x = ' + fmt(cur.answer), latex: null });
|
||||
s.innerHTML = '<h4>Подсказка</h4>' + 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; }); })
|
||||
|
||||
@@ -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 }`.
|
||||
Группировка чипов по темам/классам; выбор класса/предмета вверху.
|
||||
|
||||
Reference in New Issue
Block a user