feat(exam-prep F5): тренажёр случайных задач + /practice/next API (random|unsolved)
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user