'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 };