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:
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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'));
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user