fix(exam-prep): практика/тренажёр берут только выверенные варианты (дедуп)

Тренажёр-по-темам и практика брали FROM exam_tasks без фильтра по варианту — в пул
попадали год-пачки (variant=год≥2011) и variant=0, которые ДУБЛИРУЮТ выверенные
варианты-пробники (51 дубль чистый↔пачка, 20 через variant=0). Ученик мог получить
одну задачу дважды.

Добавлен фильтр variant BETWEEN MV_LO..MV_HI (тот же [101;1999], что у пикера) во все
7 запросов выборки/счёта задач: practiceRandom, practiceUnsolved, topicTasksUnsolved,
topicTasksAny, listTopicsWithCounts (счётчик подтем), weakBatchTasks, pickRandomByDifficulty
(×2). Хелперы MV_LO/MV_HI (для math9 без диапазона — всё, кроме variant=0).

Результат: практика ctmath = только варианты 101–117 (496 задач, 0 дублей между собой),
год-пачки (714 задач) остаются в БД для возможного будущего, но не показываются.
Обратимо, без удаления данных. Рантайм-проверка: 5 эндпоинтов практики/тем → 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-20 13:29:02 +03:00
parent de41b77ae3
commit 59ae4c1dea
+26 -17
View File
@@ -29,6 +29,11 @@ const isMockVariant = (examKey, v) => {
const r = MOCK_VARIANT_RANGE[examKey]; const r = MOCK_VARIANT_RANGE[examKey];
return r ? (v >= r[0] && v <= r[1]) : (v >= 1); return r ? (v >= r[0] && v <= r[1]) : (v >= 1);
}; };
// Границы вариантов для практики/тренажёра: тот же фильтр, что у пикера, чтобы
// год-пачки (variant=год≥2011) и variant=0 НЕ попадали в выборку задач и не
// дублировали выверенные варианты-пробники. Для треков без диапазона — всё, кроме 0.
const MV_LO = ek => (MOCK_VARIANT_RANGE[ek] ? MOCK_VARIANT_RANGE[ek][0] : 1);
const MV_HI = ek => (MOCK_VARIANT_RANGE[ek] ? MOCK_VARIANT_RANGE[ek][1] : 1e15);
/* Человекочитаемая подпись варианта (номер в БД остаётся техническим, напр. 101). /* Человекочитаемая подпись варианта (номер в БД остаётся техническим, напр. 101).
Для ctmath варианты-пробники именуются по источнику; при добавлении новых Для ctmath варианты-пробники именуются по источнику; при добавлении новых
@@ -131,6 +136,7 @@ const SQL = {
answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph
FROM exam_tasks FROM exam_tasks
WHERE exam_key = ? WHERE exam_key = ?
AND variant BETWEEN ? AND ?
AND task_type IN ('mc','open') AND task_type IN ('mc','open')
ORDER BY RANDOM() ORDER BY RANDOM()
LIMIT ? LIMIT ?
@@ -161,6 +167,7 @@ const SQL = {
t.textbook_slug, t.textbook_paragraph t.textbook_slug, t.textbook_paragraph
FROM exam_tasks t FROM exam_tasks t
WHERE t.exam_key = ? WHERE t.exam_key = ?
AND t.variant BETWEEN ? AND ?
AND t.task_type IN ('mc','open') AND t.task_type IN ('mc','open')
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM exam_attempts a SELECT 1 FROM exam_attempts a
@@ -350,7 +357,7 @@ const SQL = {
FROM exam_tasks t FROM exam_tasks t
LEFT JOIN exam_attempts a LEFT JOIN exam_attempts a
ON a.exam_task_id = t.id AND a.user_id = ? ON a.exam_task_id = t.id AND a.user_id = ?
WHERE t.exam_key = ? AND t.subtopic IS NOT NULL WHERE t.exam_key = ? AND t.variant BETWEEN ? AND ? AND t.subtopic IS NOT NULL
GROUP BY t.subtopic GROUP BY t.subtopic
) stat ON stat.subtopic = tp.slug ) stat ON stat.subtopic = tp.slug
WHERE tp.exam_key = ? WHERE tp.exam_key = ?
@@ -364,7 +371,7 @@ const SQL = {
SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json,
answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph
FROM exam_tasks t FROM exam_tasks t
WHERE t.exam_key = ? AND t.subtopic = ? WHERE t.exam_key = ? AND t.variant BETWEEN ? AND ? AND t.subtopic = ?
AND t.task_type IN ('mc','open') AND t.task_type IN ('mc','open')
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM exam_attempts a SELECT 1 FROM exam_attempts a
@@ -378,7 +385,7 @@ const SQL = {
SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json,
answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph
FROM exam_tasks t FROM exam_tasks t
WHERE t.exam_key = ? AND t.subtopic = ? WHERE t.exam_key = ? AND t.variant BETWEEN ? AND ? AND t.subtopic = ?
AND t.task_type IN ('mc','open') AND t.task_type IN ('mc','open')
ORDER BY RANDOM() ORDER BY RANDOM()
LIMIT ? LIMIT ?
@@ -437,6 +444,7 @@ const SQL = {
answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph
FROM exam_tasks t FROM exam_tasks t
WHERE t.exam_key = ? WHERE t.exam_key = ?
AND t.variant BETWEEN ? AND ?
AND t.subtopic IN (${ph}) AND t.subtopic IN (${ph})
AND t.task_type IN ('mc','open') AND t.task_type IN ('mc','open')
AND NOT EXISTS ( AND NOT EXISTS (
@@ -445,7 +453,7 @@ const SQL = {
) )
ORDER BY RANDOM() ORDER BY RANDOM()
LIMIT ? LIMIT ?
`).all(examKey, ...slugs, userId, count); `).all(examKey, MV_LO(examKey), MV_HI(examKey), ...slugs, userId, count);
}, },
}; };
@@ -693,6 +701,7 @@ function pickRandomByDifficulty(examKey, count, excludeSlugs) {
const exClause = exParams const exClause = exParams
? `AND (subtopic IS NULL OR subtopic NOT IN (${exParams.map(() => '?').join(',')}))` ? `AND (subtopic IS NULL OR subtopic NOT IN (${exParams.map(() => '?').join(',')}))`
: ''; : '';
const mvLo = MV_LO(examKey), mvHi = MV_HI(examKey);
const COLS = `id, task_idx, variant, task_type, text_html, figure_html, opts_json, const COLS = `id, task_idx, variant, task_type, text_html, figure_html, opts_json,
answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph`; answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph`;
@@ -704,14 +713,14 @@ function pickRandomByDifficulty(examKey, count, excludeSlugs) {
const sql = ` const sql = `
SELECT ${COLS} SELECT ${COLS}
FROM exam_tasks FROM exam_tasks
WHERE exam_key = ? AND task_type IN ('mc','open') WHERE exam_key = ? AND variant BETWEEN ? AND ? AND task_type IN ('mc','open')
AND difficulty = ? AND difficulty = ?
${exClause} ${exClause}
ORDER BY RANDOM() ORDER BY RANDOM()
LIMIT ?`; LIMIT ?`;
const args = exParams const args = exParams
? [examKey, d, ...exParams, limit] ? [examKey, mvLo, mvHi, d, ...exParams, limit]
: [examKey, d, limit]; : [examKey, mvLo, mvHi, d, limit];
for (const r of db.prepare(sql).all(...args)) { if (!seen.has(r.id)) { seen.add(r.id); out.push(r); } } for (const r of db.prepare(sql).all(...args)) { if (!seen.has(r.id)) { seen.add(r.id); out.push(r); } }
} }
// Backfill to `count` from any difficulty — covers tracks whose tasks don't // Backfill to `count` from any difficulty — covers tracks whose tasks don't
@@ -722,12 +731,12 @@ function pickRandomByDifficulty(examKey, count, excludeSlugs) {
const sql = ` const sql = `
SELECT ${COLS} SELECT ${COLS}
FROM exam_tasks FROM exam_tasks
WHERE exam_key = ? AND task_type IN ('mc','open') WHERE exam_key = ? AND variant BETWEEN ? AND ? AND task_type IN ('mc','open')
${exClause} ${exClause}
${notIn} ${notIn}
ORDER BY RANDOM() ORDER BY RANDOM()
LIMIT ?`; LIMIT ?`;
const args = [examKey, ...(exParams || []), ...ids, count - out.length]; const args = [examKey, mvLo, mvHi, ...(exParams || []), ...ids, count - out.length];
out.push(...db.prepare(sql).all(...args)); out.push(...db.prepare(sql).all(...args));
} }
out.sort((a, b) => (a.difficulty || 0) - (b.difficulty || 0)); out.sort((a, b) => (a.difficulty || 0) - (b.difficulty || 0));
@@ -767,7 +776,7 @@ router.get('/:examKey/practice/next', (req, res) => {
weakSlugs = SQL.weakTopicSlugs.all(req.user.id, examKey).map(r => r.slug); weakSlugs = SQL.weakTopicSlugs.all(req.user.id, examKey).map(r => r.slug);
if (weakSlugs.length === 0) { if (weakSlugs.length === 0) {
// No weak topics yet → fall back to unsolved so the button still works // No weak topics yet → fall back to unsolved so the button still works
rows = SQL.practiceUnsolved.all(examKey, req.user.id, count); rows = SQL.practiceUnsolved.all(examKey, MV_LO(examKey), MV_HI(examKey), req.user.id, count);
strategy = 'unsolved-fallback'; strategy = 'unsolved-fallback';
} else { } else {
rows = SQL.weakBatchTasks(examKey, weakSlugs, req.user.id, count); rows = SQL.weakBatchTasks(examKey, weakSlugs, req.user.id, count);
@@ -777,8 +786,8 @@ router.get('/:examKey/practice/next', (req, res) => {
} }
} }
} else if (strategy === 'unsolved') { } else if (strategy === 'unsolved') {
rows = SQL.practiceUnsolved.all(examKey, req.user.id, count); rows = SQL.practiceUnsolved.all(examKey, MV_LO(examKey), MV_HI(examKey), req.user.id, count);
if (!rows.length) rows = SQL.practiceRandom.all(examKey, count); if (!rows.length) rows = SQL.practiceRandom.all(examKey, MV_LO(examKey), MV_HI(examKey), count);
} else { } else {
// Random: difficulty-ordered batch, position 1 = difficulty 1. // Random: difficulty-ordered batch, position 1 = difficulty 1.
rows = pickRandomByDifficulty(examKey, count, excludeSlugs); rows = pickRandomByDifficulty(examKey, count, excludeSlugs);
@@ -786,7 +795,7 @@ router.get('/:examKey/practice/next', (req, res) => {
if (!rows.length && excludeSlugs.length) { if (!rows.length && excludeSlugs.length) {
rows = pickRandomByDifficulty(examKey, count, []); rows = pickRandomByDifficulty(examKey, count, []);
} }
if (!rows.length) rows = SQL.practiceRandom.all(examKey, count); if (!rows.length) rows = SQL.practiceRandom.all(examKey, MV_LO(examKey), MV_HI(examKey), count);
} }
const refMap = getTopicRefMap(examKey); const refMap = getTopicRefMap(examKey);
@@ -1014,7 +1023,7 @@ router.get('/:examKey/topics', (req, res) => {
const { examKey } = req.params; const { examKey } = req.params;
if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' }); if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });
const rows = SQL.listTopicsWithCounts.all(req.user.id, examKey, examKey); const rows = SQL.listTopicsWithCounts.all(req.user.id, examKey, MV_LO(examKey), MV_HI(examKey), examKey);
// Build {sections: [{slug,title,subtopics:[...]}]} // Build {sections: [{slug,title,subtopics:[...]}]}
const byParent = new Map(); const byParent = new Map();
@@ -1071,10 +1080,10 @@ router.get('/:examKey/topics/:slug/tasks', (req, res) => {
let rows; let rows;
if (excludeSolved) { if (excludeSolved) {
rows = SQL.topicTasksUnsolved.all(examKey, slug, req.user.id, count); rows = SQL.topicTasksUnsolved.all(examKey, MV_LO(examKey), MV_HI(examKey), slug, req.user.id, count);
if (!rows.length) rows = SQL.topicTasksAny.all(examKey, slug, count); if (!rows.length) rows = SQL.topicTasksAny.all(examKey, MV_LO(examKey), MV_HI(examKey), slug, count);
} else { } else {
rows = SQL.topicTasksAny.all(examKey, slug, count); rows = SQL.topicTasksAny.all(examKey, MV_LO(examKey), MV_HI(examKey), slug, count);
} }
const refMap = getTopicRefMap(examKey); const refMap = getTopicRefMap(examKey);