feat(trainer): разбор типовых ошибок (репетитор C1, движок)

- TrainerEngine.analyzeMistake(problem, value) -> {type, hint} | null: по неверному числовому ответу распознаёт типовую ошибку и даёт адресную подсказку, НЕ выдавая ответ
- solve: уравнение восстанавливается как линейное f(x)=A·x+B по двум точкам (без структуры генератора) -> ловит «забыл разделить на коэффициент»
- общие эвристики: перепутан знак (value≈-correct), близкая арифметическая ошибка (|Δ|≤20%), иначе generic
- работает для solve/compute; пара/корни/неравенство пропускаются
- смоук движка 825/825 (T20: nodivide/sign/arith/generic/null)
- страница НЕ тронута (редизайн в параллельной сессии); показ подсказки на неверном ответе подключу на странице вместе с полировкой ввода систем после редизайна

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-25 16:52:00 +03:00
parent 21ffbbfe6c
commit fb81beca39
+42
View File
@@ -452,6 +452,47 @@
};
}
/* ── Разбор типовой ошибки ученика (репетитор, направление C) ──
По неверному ЧИСЛОВОМУ ответу пытается распознать типовую ошибку и дать
адресную подсказку, НЕ выдавая правильный ответ. Работает для solve/compute.
Для solve уравнение восстанавливается как линейное f(x)=A·x+B по двум точкам
(без структуры генератора) → ловим «забыл разделить на коэффициент». Плюс
общие эвристики: перепутан знак, близкая арифметическая ошибка.
Возвращает { type, hint } или null (ошибка не распознана / ответ верный). */
function _linAB(problem) {
var av = problem.answerVar || 'x';
var e0 = {}, e1 = {}; e0[av] = 0; e1[av] = 1;
var g0 = evalExpr(problem.lhsExpr, e0) - evalExpr(problem.rhsExpr, e0);
var g1 = evalExpr(problem.lhsExpr, e1) - evalExpr(problem.rhsExpr, e1);
if (!isFinite(g0) || !isFinite(g1)) return null;
return { A: g1 - g0, B: g0 }; // f(x) = A·x + B, корень = -B/A
}
function analyzeMistake(problem, value) {
if (!problem || !isFinite(value)) return null;
var kind = problem.kind || 'solve';
if (kind !== 'solve' && kind !== 'compute') return null; // пара/корни/неравенство — отдельно
var correct = problem.answer;
var tol = 1e-6 * Math.max(1, Math.abs(correct));
if (Math.abs(value - correct) <= tol) return null; // на самом деле верно
// структурно: линейное уравнение → «забыл разделить на коэффициент»
if (kind === 'solve') {
var ab = _linAB(problem);
if (ab && Math.abs(ab.A) > 1.5) {
var noDivide = -ab.B; // значение на шаге «A·x = -B», ещё не делённое на A (= A·correct)
if (Math.abs(value - noDivide) <= Math.max(tol, 1e-6 * Math.abs(noDivide)))
return { type: 'nodivide', hint: 'Похоже, ты не разделил обе части на коэффициент при переменной (' + fmtNum(ab.A) + '). Раздели — и получишь ответ.' };
}
}
// перепутан знак ответа
if (correct !== 0 && Math.abs(value + correct) <= Math.max(tol, 1e-6 * Math.abs(correct)))
return { type: 'sign', hint: 'Кажется, перепутан знак. Проверь знаки при переносе слагаемых через знак «=».' };
// близкая арифметическая ошибка
if (Math.abs(value - correct) <= Math.max(1, Math.abs(correct) * 0.2))
return { type: 'arith', hint: 'Очень близко — похоже на арифметическую ошибку в вычислениях. Пересчитай аккуратно.' };
return { type: 'generic', hint: 'Разбери решение по шагам и попробуй похожую задачу.' };
}
/* Система: ученик вводит пару «x = 2; y = 3» (или «2; 3»). Проверяем подстановкой в ОБА уравнения.
Метки переменных опциональны; без меток — по порядку answerVars. */
function _checkSystem(problem, raw) {
@@ -609,6 +650,7 @@
generateBatch: generateBatch,
verifyRoot: verifyRoot,
checkStudentAnswer: checkStudentAnswer,
analyzeMistake: analyzeMistake,
checkStep: checkStep,
makeRng: makeRng,
// мелочи наружу для билдера/тестов