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 };
@@ -0,0 +1,31 @@
-- ═══════════════════════════════════════════════════════════════
-- 081: Practice progress (ИИ-тренажёр, Фаза 0).
--
-- Прогресс ученика по НАВЫКАМ тренажёра. Навык = skill генератора
-- (напр. 'linear-basic'); задачи генерируются на клиенте детерминированно
-- и проверяются подстановкой — сервер хранит лишь агрегаты результата.
--
-- На каждую попытку клиент шлёт { skill, correct }. Сервер делает upsert:
-- solved — всего верных ответов
-- attempts — всего попыток (верных и нет)
-- cur_streak — текущая серия верных подряд (обнуляется ошибкой)
-- best_streak — лучшая серия
-- mastered — 1, как только cur_streak достиг порога (липкое)
-- UNIQUE(user_id, skill) — одна строка на пару ученик-навык.
-- user_id ON DELETE CASCADE — прогресс удаляется вместе с учеником.
-- ═══════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS practice_progress (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
skill TEXT NOT NULL, -- идентификатор навыка генератора
solved INTEGER NOT NULL DEFAULT 0, -- всего верных ответов
attempts INTEGER NOT NULL DEFAULT 0, -- всего попыток
cur_streak INTEGER NOT NULL DEFAULT 0, -- текущая серия верных подряд
best_streak INTEGER NOT NULL DEFAULT 0, -- лучшая серия
mastered INTEGER NOT NULL DEFAULT 0, -- 1, когда серия достигала порога
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE (user_id, skill)
);
CREATE INDEX IF NOT EXISTS idx_practice_progress_user ON practice_progress (user_id);
+16
View File
@@ -0,0 +1,16 @@
'use strict';
/* /api/practice — прогресс ученика в ИИ-тренажёре (Фаза 0).
* Все роуты — auth-only (тренируются ученики). router.use(authMiddleware)
* → lint:routes baseline 0. Прогресс всегда принадлежит req.user — нет
* межпользовательских роутов, проверка владения не требуется. */
const express = require('express');
const router = express.Router();
const { authMiddleware } = require('../middleware/auth');
const c = require('../controllers/practiceController');
router.use(authMiddleware);
router.get('/progress', c.listProgress);
router.post('/attempt', c.submitAttempt);
module.exports = router;
+1
View File
@@ -198,6 +198,7 @@ app.use('/api/lab', labRoutes);
app.use('/api/materials', require('./routes/materials'));
app.use('/api/custom-sims', require('./routes/customSims'));
app.use('/api/game', require('./routes/game'));
app.use('/api/practice', requireFeature('trainer'), require('./routes/practice'));
app.use('/api/wishes', require('./routes/wishes'));
app.use('/api/client-errors', require('./routes/clientErrors'));
app.use('/api/prep', require('./routes/prep'));