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'));
+104
View File
@@ -0,0 +1,104 @@
'use strict';
/**
* Integration tests: /api/practice — прогресс ученика в ИИ-тренажёре (Фаза 0).
* Covers: auth-only (401); correct создаёт строку; wrong не растит solved, но
* растит attempts и обнуляет серию; серия из MASTERY_STREAK → mastered;
* прогресс per-user; валидация входа (400).
*/
const { describe, it, before } = require('node:test');
const assert = require('node:assert/strict');
const { app, inject, getToken, cleanup } = require('./setup');
// Mount /api/practice on the shared test app (setup.js не монтирует новые роуты).
app.use('/api/practice', require('../src/routes/practice'));
const { after } = require('node:test');
after(() => cleanup());
const SKILL = 'linear-basic';
describe('/api/practice progress', () => {
let token;
before(async () => {
token = (await getToken('student')).token;
});
it('GET /progress requires auth (401)', async () => {
const res = await inject('GET', '/api/practice/progress', null, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
it('POST /attempt requires auth (401)', async () => {
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: true }, null);
assert.equal(res.status, 401, `got ${res.status}`);
});
it('correct attempt creates a row (solved=1, streak=1)', async () => {
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: true }, token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.ok, true);
assert.equal(res.body.progress.skill, SKILL);
assert.equal(res.body.progress.solved, 1);
assert.equal(res.body.progress.attempts, 1);
assert.equal(res.body.progress.cur_streak, 1);
assert.equal(res.body.progress.best_streak, 1);
assert.equal(res.body.progress.mastered, 0);
});
it('GET /progress lists the row', async () => {
const res = await inject('GET', '/api/practice/progress', null, token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.ok(Array.isArray(res.body.progress));
const row = res.body.progress.find(r => r.skill === SKILL);
assert.ok(row, 'skill row present');
assert.equal(row.solved, 1);
});
it('wrong attempt: attempts++, solved unchanged, streak resets to 0', async () => {
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: false }, token);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.progress.solved, 1, 'solved unchanged');
assert.equal(res.body.progress.attempts, 2, 'attempts incremented');
assert.equal(res.body.progress.cur_streak, 0, 'streak reset');
assert.equal(res.body.progress.best_streak, 1, 'best streak kept');
});
it('streak of 5 correct → mastered=1 (and stays mastered after a miss)', async () => {
const sk = 'mastery-skill';
let last;
for (let i = 0; i < 5; i++) {
last = await inject('POST', '/api/practice/attempt', { skill: sk, correct: true }, token);
}
assert.equal(last.body.progress.cur_streak, 5);
assert.equal(last.body.progress.best_streak, 5);
assert.equal(last.body.progress.mastered, 1, 'mastered after 5 in a row');
const miss = await inject('POST', '/api/practice/attempt', { skill: sk, correct: false }, token);
assert.equal(miss.body.progress.cur_streak, 0, 'streak reset on miss');
assert.equal(miss.body.progress.mastered, 1, 'mastered is sticky');
});
it('progress is per-user (другой ученик начинает с нуля)', async () => {
const other = (await getToken('student')).token;
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: true }, other);
assert.equal(res.status, 200, `got ${res.status}`);
assert.equal(res.body.progress.attempts, 1, 'fresh user has attempts=1');
assert.equal(res.body.progress.solved, 1);
});
it('validation: missing skill → 400', async () => {
const res = await inject('POST', '/api/practice/attempt', { correct: true }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('validation: correct not boolean → 400', async () => {
const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: 'yes' }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
it('validation: skill too long → 400', async () => {
const res = await inject('POST', '/api/practice/attempt', { skill: 'x'.repeat(200), correct: true }, token);
assert.equal(res.status, 400, `got ${res.status}`);
});
});