feat(exam-prep F5): тренажёр случайных задач + /practice/next API (random|unsolved)

This commit is contained in:
Maxim Dolgolyov
2026-05-29 10:57:22 +03:00
parent 0d1474f0f5
commit 4652f9a73d
4 changed files with 429 additions and 6 deletions
+77
View File
@@ -69,6 +69,38 @@ const SQL = {
ORDER BY task_idx
`),
/* Practice batch — RANDOM strategy.
Excludes long tasks (no auto-check) by default to keep UX consistent. */
practiceRandom: db.prepare(`
SELECT
id, task_idx, variant, task_type, text_html, figure_html, opts_json,
answer, solution_html, topic
FROM exam_tasks
WHERE exam_key = ?
AND task_type IN ('mc','open')
ORDER BY RANDOM()
LIMIT ?
`),
/* Practice batch — UNSOLVED strategy.
Excludes tasks the user has already solved correctly at least once. */
practiceUnsolved: db.prepare(`
SELECT
t.id, t.task_idx, t.variant, t.task_type, t.text_html, t.figure_html,
t.opts_json, t.answer, t.solution_html, t.topic
FROM exam_tasks t
WHERE t.exam_key = ?
AND t.task_type IN ('mc','open')
AND NOT EXISTS (
SELECT 1 FROM exam_attempts a
WHERE a.exam_task_id = t.id
AND a.user_id = ?
AND a.is_correct = 1
)
ORDER BY RANDOM()
LIMIT ?
`),
/* For attempt save: confirm the task belongs to a known track + user */
getTaskExamKey: db.prepare(`SELECT exam_key FROM exam_tasks WHERE id = ?`),
@@ -168,6 +200,51 @@ router.get('/:examKey/variants/:n/tasks', (req, res) => {
function safeJson(s) { try { return JSON.parse(s); } catch { return null; } }
function shapeTask(r) {
return {
id: r.id,
idx: r.task_idx,
variant: r.variant ?? null,
type: r.task_type,
text: r.text_html,
figure: r.figure_html,
opts: r.opts_json ? safeJson(r.opts_json) : null,
answer: r.answer,
solution: r.solution_html,
topic: r.topic ?? null,
};
}
/* ── GET /api/exam-prep/:examKey/practice/next ──
Returns up to ?count=N tasks for the practice trainer.
?strategy=random — any mc/open task, fresh random sample
=unsolved — only tasks the user has not yet solved correctly
Long tasks are excluded (no auto-check available). */
// @public-by-design: router-level authMiddleware (line 6) covers this route
router.get('/:examKey/practice/next', (req, res) => {
const { examKey } = req.params;
if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });
let count = Number(req.query.count) || 10;
count = Math.max(1, Math.min(count, 30));
const strategy = req.query.strategy === 'unsolved' ? 'unsolved' : 'random';
let rows;
if (strategy === 'unsolved') {
rows = SQL.practiceUnsolved.all(examKey, req.user.id, count);
// Fallback: if user has solved everything, supply random anyway so UX isn't a dead-end.
if (!rows.length) rows = SQL.practiceRandom.all(examKey, count);
} else {
rows = SQL.practiceRandom.all(examKey, count);
}
res.json({
strategy,
session_id: Date.now(), // ephemeral grouping key for these N attempts
tasks: rows.map(shapeTask),
});
});
/* ── POST /api/exam-prep/attempts ──
Save a single attempt. Used by the variants view (F2) for "solution viewed"
markers, and by the practice / mock views (F3+) for actual answer checking.