feat(trainer): ИИ-тренажёр — генераторы задач + SimExpr-верификатор, прогресс, фича-флаг

- движок _trainer_engine.js: instantiate/generateBatch/verifyRoot/checkStudentAnswer/exprToLatex
- 5 генераторов уравнений 7 класса (generators.js), приём «корень-вперёд» → целые ответы
- страница /trainer: KaTeX-рендер, чипы-темы, мгновенная проверка, подсказка/решение, авто-выбор навыка
- прогресс practice_progress (мигр.081) + /api/practice/progress|attempt + LS.practiceProgressList/Submit
- фича-флаг trainer: тумблер в админке (Модули), requireFeature, FEATURE_HREFS (скрытие сайдбара+редирект), MODULE_CATALOG
- fix: подключён Lucide CDN на странице (иначе иконки сайдбара пустые)
- тесты practice.test.js (10/10); план развития plans/ai-trainer/PLAN.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-25 13:11:47 +03:00
parent 91917f952c
commit c370eaa803
13 changed files with 1141 additions and 0 deletions
@@ -993,6 +993,7 @@ const MODULE_CATALOG = [
{ key: 'crossword', name: 'Кроссворд', url: '/crossword', desc: 'Игра-кроссворд по терминам предметов; закрепляет понятия, даёт XP.' },
{ key: 'hangman', name: 'Виселица', url: '/hangman', desc: 'Игра «Виселица» по терминам предметов; закрепляет слова, даёт XP.' },
{ key: 'quantik', name: 'Квантик: Законы Мира', url: '/quantik', desc: 'Физическая игра-головоломка: уровни на 2D-механике, звёзды и прогресс.' },
{ key: 'trainer', name: 'Тренажёр', url: '/trainer', desc: 'ИИ-тренажёр: бесконечные сгенерированные задачи по темам с мгновенной проверкой ответа и прогрессом по навыкам.' },
{ key: 'live_quiz', name: 'Live-викторина', url: '/live-quiz', desc: 'Викторина в реальном времени: учитель запускает, ученики отвечают одновременно.' },
{ key: 'sitemap', name: 'Путеводитель', url: '/sitemap', desc: 'Карта-обзор всех разделов платформы со ссылками.' },
{ key: 'sim_builder', name: 'Конструктор симуляций', url: '/sim-builder', desc: 'Авторинг 2D-симуляций (учитель/админ).' },
@@ -0,0 +1,71 @@
'use strict';
/* Practice progress (ИИ-тренажёр, Фаза 0).
*
* Прогресс ученика по навыкам тренажёра. Навык = skill генератора; задачи
* генерируются и проверяются на клиенте (детерминированно, подстановкой), а
* сервер хранит только агрегаты. На каждую попытку клиент шлёт { skill, correct };
* сервер делает upsert: solved/attempts, текущая и лучшая серия, флаг mastered.
*
* Стиль следует gameController / customSimController: node:sqlite db.prepare,
* auth-only (роутер ставит authMiddleware), валидация входа без исполнения,
* статусы 400. Прогресс всегда принадлежит req.user — проверка владения не нужна.
*/
const db = require('../db/db');
const MAX_SKILL = 120; // длина skill (TEXT)
const MASTERY_STREAK = 5; // серия верных подряд для «освоено»
/* GET /api/practice/progress — прогресс текущего ученика по всем навыкам. */
function listProgress(req, res) {
const uid = req.user.id;
const rows = db.prepare(`
SELECT skill, solved, attempts, cur_streak, best_streak, mastered, updated_at
FROM practice_progress
WHERE user_id = ?
ORDER BY updated_at DESC, id DESC
`).all(uid);
res.json({ progress: rows, masteryStreak: MASTERY_STREAK });
}
/* POST /api/practice/attempt body: { skill, correct }
* Upsert агрегата попытки. Валидация: skill строка ≤120; correct — boolean.
* НИЧЕГО не исполняет (skill — лишь ключ). */
function submitAttempt(req, res) {
const uid = req.user.id;
const b = req.body || {};
const skill = typeof b.skill === 'string' ? b.skill.trim() : '';
if (!skill) return res.status(400).json({ error: 'skill обязателен' });
if (skill.length > MAX_SKILL) return res.status(400).json({ error: `skill длиннее ${MAX_SKILL} символов` });
if (typeof b.correct !== 'boolean') return res.status(400).json({ error: 'correct должно быть boolean' });
const correct = b.correct;
const existing = db.prepare(
'SELECT id, solved, attempts, cur_streak, best_streak, mastered FROM practice_progress WHERE user_id = ? AND skill = ?'
).get(uid, skill);
if (!existing) {
const curStreak = correct ? 1 : 0;
db.prepare(`
INSERT INTO practice_progress (user_id, skill, solved, attempts, cur_streak, best_streak, mastered, updated_at)
VALUES (?, ?, ?, 1, ?, ?, ?, datetime('now'))
`).run(uid, skill, correct ? 1 : 0, curStreak, curStreak, curStreak >= MASTERY_STREAK ? 1 : 0);
} else {
const curStreak = correct ? (existing.cur_streak + 1) : 0;
const bestStreak = Math.max(existing.best_streak || 0, curStreak);
const mastered = (existing.mastered || (curStreak >= MASTERY_STREAK)) ? 1 : 0;
db.prepare(`
UPDATE practice_progress
SET solved = solved + ?, attempts = attempts + 1,
cur_streak = ?, best_streak = ?, mastered = ?, updated_at = datetime('now')
WHERE id = ?
`).run(correct ? 1 : 0, curStreak, bestStreak, mastered, existing.id);
}
const row = db.prepare(
'SELECT skill, solved, attempts, cur_streak, best_streak, mastered, updated_at FROM practice_progress WHERE user_id = ? AND skill = ?'
).get(uid, skill);
res.json({ ok: true, progress: row, masteryStreak: MASTERY_STREAK });
}
module.exports = { listProgress, submitAttempt };