Files
Learn_System/backend/src/controllers/practiceController.js
T
Maxim Dolgolyov cd7c75ff08 feat(trainer): P4 — авторинг задач учителем + раздача классу
- POST /api/practice/author: учитель пишет story/lhs/rhs/answer → та же проверка подстановкой (validateAndVerify) → пул; не сходится → 422
- POST /api/practice/assign: выдать тему классу → durable pushNotif каждому ученику (ссылка /trainer); владелец/админ, чужой → 403
- клиент: LS.practiceAuthor/Assign; в теме «Текстовые задачи» учителю кнопки «Своя задача» (модалка-форма) и «Выдать классу» (пикер классов)
- тесты: author (валид→пул, неверный→422, ученик→403), assign (владелец уведомляет, чужой→403) — practice 19/19 + practice-gen 16/16
- смоук страницы 27/27; план P4 → DONE (lean: ручной авторинг + раздача, без полного DSL-конструктора)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:30:02 +03:00

219 lines
12 KiB
JavaScript

'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; // серия верных подряд для «освоено»
// Интервалы повторения (дни) по уровню Leitner-коробки box 0..5.
const INTERVAL_DAYS = [0, 1, 3, 7, 16, 30];
/* GET /api/practice/progress — прогресс текущего ученика по всем навыкам.
* `due` (0/1) — навык пора повторить (срок прошёл или не назначен). */
function listProgress(req, res) {
const uid = req.user.id;
const rows = db.prepare(`
SELECT skill, solved, attempts, cur_streak, best_streak, mastered, box, due_at,
CASE WHEN due_at IS NULL OR due_at <= datetime('now') THEN 1 ELSE 0 END AS due,
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, box FROM practice_progress WHERE user_id = ? AND skill = ?'
).get(uid, skill);
// Leitner: верно → box+1 (до 5), неверно → 0. Срок = сейчас + интервал(box).
const prevBox = existing ? (existing.box || 0) : 0;
const box = correct ? Math.min(prevBox + 1, 5) : 0;
const dueMod = '+' + INTERVAL_DAYS[box] + ' days';
if (!existing) {
const curStreak = correct ? 1 : 0;
db.prepare(`
INSERT INTO practice_progress (user_id, skill, solved, attempts, cur_streak, best_streak, mastered, box, due_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?, ?, ?, datetime('now', ?), datetime('now'))
`).run(uid, skill, correct ? 1 : 0, curStreak, curStreak, curStreak >= MASTERY_STREAK ? 1 : 0, box, dueMod);
} 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 = ?, box = ?, due_at = datetime('now', ?),
updated_at = datetime('now')
WHERE id = ?
`).run(correct ? 1 : 0, curStreak, bestStreak, mastered, box, dueMod, existing.id);
}
const row = db.prepare(`
SELECT skill, solved, attempts, cur_streak, best_streak, mastered, box, due_at,
CASE WHEN due_at IS NULL OR due_at <= datetime('now') THEN 1 ELSE 0 END AS due,
updated_at
FROM practice_progress WHERE user_id = ? AND skill = ?
`).get(uid, skill);
res.json({ ok: true, progress: row, masteryStreak: MASTERY_STREAK });
}
/* ── Пул текстовых задач (Уровень 1, LLM + проверка) ── */
const genService = require('../services/practiceGenService');
const { pushNotif } = require('../utils/notifications');
const POOL_TOPICS = { 'word-linear': 1, 'word-proportion': 1, 'word-percent': 1 };
function toClientProblem(r) {
let solution = [];
try { solution = r.solution_json ? JSON.parse(r.solution_json) : []; } catch (e) { solution = []; }
return {
id: r.id, kind: 'word', topic: r.topic, skill: r.skill,
story: r.story, lhsExpr: r.lhs, rhsExpr: r.rhs,
answerVar: r.answer_var, answer: r.answer, solution: solution
};
}
/* GET /api/practice/pool?skill=&limit= — одобренные задачи пула (ученикам). */
function listPool(req, res) {
const skill = (req.query && typeof req.query.skill === 'string') ? req.query.skill.trim().slice(0, MAX_SKILL) : '';
const limit = Math.min(parseInt((req.query && req.query.limit), 10) || 20, 50);
const rows = skill
? db.prepare("SELECT * FROM practice_problems WHERE status='approved' AND (skill = ? OR topic = ?) ORDER BY id DESC LIMIT ?").all(skill, skill, limit)
: db.prepare("SELECT * FROM practice_problems WHERE status='approved' ORDER BY id DESC LIMIT ?").all(limit);
res.json({ problems: rows.map(toClientProblem) });
}
/* POST /api/practice/generate { topic } — учитель/админ генерирует задачу в пул.
* Сервис проверяет корректность подстановкой; не прошло — в БД НЕ пишем. */
async function generateProblem(req, res) {
const topic = (req.body && typeof req.body.topic === 'string') ? req.body.topic.trim() : 'word-linear';
if (!POOL_TOPICS[topic]) return res.status(400).json({ error: 'unknown topic' });
let result;
try { result = await genService.generate(topic, { maxRetries: 3 }); }
catch (e) { return res.status(500).json({ error: 'generation failed' }); }
if (!result.ok) {
const code = (result.error === 'off') ? 503 : 422; // нет провайдера → 503; не проверилось → 422
return res.status(code).json({ error: result.error, reason: result.reason || null, attempts: result.attempts });
}
const p = result.problem;
const info = db.prepare(`
INSERT INTO practice_problems (topic, skill, difficulty, story, lhs, rhs, answer_var, answer, solution_json, status, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'approved', ?)
`).run(topic, topic, 1, p.story, p.lhs, p.rhs, p.answerVar, p.answer, JSON.stringify(p.solution || []), req.user.id);
const row = db.prepare('SELECT * FROM practice_problems WHERE id = ?').get(info.lastInsertRowid);
res.json({ ok: true, problem: toClientProblem(row), attempts: result.attempts });
}
/* POST /api/practice/author — учитель пишет задачу ВРУЧНУЮ (без LLM).
* Та же проверка подстановкой (validateAndVerify): не сходится → 422, в пул не пишем. */
function authorProblem(req, res) {
const b = req.body || {};
const topic = (typeof b.topic === 'string') ? b.topic.trim() : 'word-linear';
if (!POOL_TOPICS[topic]) return res.status(400).json({ error: 'unknown topic' });
const v = genService.validateAndVerify({
story: b.story, lhs: b.lhs, rhs: b.rhs, answer: b.answer, answerVar: b.answerVar, solution: b.solution
});
if (!v.ok) return res.status(422).json({ error: 'verify', reason: v.reason });
const p = v.problem;
const info = db.prepare(`
INSERT INTO practice_problems (topic, skill, difficulty, story, lhs, rhs, answer_var, answer, solution_json, status, created_by)
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, 'approved', ?)
`).run(topic, topic, p.story, p.lhs, p.rhs, p.answerVar, p.answer, JSON.stringify(p.solution || []), req.user.id);
const row = db.prepare('SELECT * FROM practice_problems WHERE id = ?').get(info.lastInsertRowid);
res.json({ ok: true, problem: toClientProblem(row) });
}
/* POST /api/practice/assign { class_id, topic, title } — выдать тему классу.
* Адресное durable-уведомление каждому ученику (pushNotif → таблица + SSE), ссылка /trainer.
* Доступ: владелец класса или админ. */
function assignToClass(req, res) {
const uid = req.user.id, role = req.user.role;
const b = req.body || {};
const classId = parseInt(b.class_id, 10);
if (!classId) return res.status(400).json({ error: 'class_id обязателен' });
const title = (typeof b.title === 'string' ? b.title.trim() : '').slice(0, 200);
if (role !== 'admin') {
const own = db.prepare('SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?').get(classId, uid);
if (!own) return res.status(403).json({ error: 'не ваш класс' });
}
const members = db.prepare('SELECT user_id FROM class_members WHERE class_id = ?').all(classId);
const msg = 'Тренажёр: ' + (title || 'новое задание для практики');
members.forEach(m => pushNotif(m.user_id, 'practice', msg, '/trainer'));
res.json({ ok: true, notified: members.length });
}
/* GET /api/practice/class-stats?class_id= — аналитика класса для учителя.
* Возвращает агрегаты по навыкам (кто застрял) + матрицу ученик×навык для
* тепловой карты. Доступ: владелец класса (teacher_id) или админ. */
function classStats(req, res) {
const uid = req.user.id, role = req.user.role;
const classId = parseInt((req.query && req.query.class_id), 10);
if (!classId) return res.status(400).json({ error: 'class_id обязателен' });
if (role !== 'admin') {
const own = db.prepare('SELECT 1 FROM classes WHERE id = ? AND teacher_id = ?').get(classId, uid);
if (!own) return res.status(403).json({ error: 'не ваш класс' });
}
const students = db.prepare(
'SELECT u.id, u.name FROM class_members cm JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name'
).all(classId);
if (!students.length) return res.json({ students: [], skills: [], perSkill: [] });
const ids = students.map(s => s.id);
const ph = ids.map(() => '?').join(',');
const rows = db.prepare(
`SELECT user_id, skill, solved, attempts, mastered FROM practice_progress WHERE user_id IN (${ph})`
).all(...ids);
const bySkill = {}, byStudent = {};
for (const r of rows) {
const s = bySkill[r.skill] || (bySkill[r.skill] = { skill: r.skill, attempted: 0, solved: 0, attempts: 0, mastered: 0 });
s.attempted++; s.solved += r.solved; s.attempts += r.attempts; if (r.mastered) s.mastered++;
const st = byStudent[r.user_id] || (byStudent[r.user_id] = {});
st[r.skill] = { solved: r.solved, attempts: r.attempts, mastered: r.mastered ? 1 : 0,
accuracy: r.attempts ? Math.round(100 * r.solved / r.attempts) : 0 };
}
const skills = Object.keys(bySkill).sort();
const perSkill = skills.map(k => {
const s = bySkill[k];
return { skill: k, attempted: s.attempted, mastered: s.mastered,
accuracy: s.attempts ? Math.round(100 * s.solved / s.attempts) : 0 };
});
const studentRows = students.map(s => ({ id: s.id, name: s.name, perSkill: byStudent[s.id] || {} }));
res.json({ students: studentRows, skills, perSkill });
}
module.exports = { listProgress, submitAttempt, listPool, generateProblem, authorProblem, assignToClass, classStats };