From 59ae4c1dea7d5c1697f42288e4eda354fc5b7c8d Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 20 Jun 2026 13:29:02 +0300 Subject: [PATCH] =?UTF-8?q?fix(exam-prep):=20=D0=BF=D1=80=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D0=BA=D0=B0/=D1=82=D1=80=D0=B5=D0=BD=D0=B0=D0=B6?= =?UTF-8?q?=D1=91=D1=80=20=D0=B1=D0=B5=D1=80=D1=83=D1=82=20=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B2=D1=8B=D0=B2=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D1=8B=20(=D0=B4=D0=B5=D0=B4=D1=83=D0=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тренажёр-по-темам и практика брали 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) --- backend/src/routes/exam-prep.js | 43 ++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/backend/src/routes/exam-prep.js b/backend/src/routes/exam-prep.js index cb3a37d..e0a6344 100644 --- a/backend/src/routes/exam-prep.js +++ b/backend/src/routes/exam-prep.js @@ -29,6 +29,11 @@ const isMockVariant = (examKey, v) => { const r = MOCK_VARIANT_RANGE[examKey]; 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). Для ctmath варианты-пробники именуются по источнику; при добавлении новых @@ -131,6 +136,7 @@ const SQL = { answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph FROM exam_tasks WHERE exam_key = ? + AND variant BETWEEN ? AND ? AND task_type IN ('mc','open') ORDER BY RANDOM() LIMIT ? @@ -161,6 +167,7 @@ const SQL = { t.textbook_slug, t.textbook_paragraph FROM exam_tasks t WHERE t.exam_key = ? + AND t.variant BETWEEN ? AND ? AND t.task_type IN ('mc','open') AND NOT EXISTS ( SELECT 1 FROM exam_attempts a @@ -350,7 +357,7 @@ const SQL = { FROM exam_tasks t LEFT JOIN exam_attempts a 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 ) stat ON stat.subtopic = tp.slug WHERE tp.exam_key = ? @@ -364,7 +371,7 @@ const SQL = { SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph 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 NOT EXISTS ( 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, answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph 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') ORDER BY RANDOM() LIMIT ? @@ -437,6 +444,7 @@ const SQL = { answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph FROM exam_tasks t WHERE t.exam_key = ? + AND t.variant BETWEEN ? AND ? AND t.subtopic IN (${ph}) AND t.task_type IN ('mc','open') AND NOT EXISTS ( @@ -445,7 +453,7 @@ const SQL = { ) ORDER BY RANDOM() 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 ? `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, answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph`; @@ -704,14 +713,14 @@ function pickRandomByDifficulty(examKey, count, excludeSlugs) { const sql = ` SELECT ${COLS} 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 = ? ${exClause} ORDER BY RANDOM() LIMIT ?`; const args = exParams - ? [examKey, d, ...exParams, limit] - : [examKey, d, limit]; + ? [examKey, mvLo, mvHi, d, ...exParams, 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); } } } // Backfill to `count` from any difficulty — covers tracks whose tasks don't @@ -722,12 +731,12 @@ function pickRandomByDifficulty(examKey, count, excludeSlugs) { const sql = ` SELECT ${COLS} 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} ${notIn} ORDER BY RANDOM() 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.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); if (weakSlugs.length === 0) { // 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'; } else { rows = SQL.weakBatchTasks(examKey, weakSlugs, req.user.id, count); @@ -777,8 +786,8 @@ router.get('/:examKey/practice/next', (req, res) => { } } } else if (strategy === 'unsolved') { - rows = SQL.practiceUnsolved.all(examKey, req.user.id, count); - if (!rows.length) rows = SQL.practiceRandom.all(examKey, count); + rows = SQL.practiceUnsolved.all(examKey, MV_LO(examKey), MV_HI(examKey), req.user.id, count); + if (!rows.length) rows = SQL.practiceRandom.all(examKey, MV_LO(examKey), MV_HI(examKey), count); } else { // Random: difficulty-ordered batch, position 1 = difficulty 1. rows = pickRandomByDifficulty(examKey, count, excludeSlugs); @@ -786,7 +795,7 @@ router.get('/:examKey/practice/next', (req, res) => { if (!rows.length && excludeSlugs.length) { 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); @@ -1014,7 +1023,7 @@ router.get('/:examKey/topics', (req, res) => { const { examKey } = req.params; 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:[...]}]} const byParent = new Map(); @@ -1071,10 +1080,10 @@ router.get('/:examKey/topics/:slug/tasks', (req, res) => { let rows; if (excludeSolved) { - rows = SQL.topicTasksUnsolved.all(examKey, slug, req.user.id, count); - if (!rows.length) rows = SQL.topicTasksAny.all(examKey, slug, 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, MV_LO(examKey), MV_HI(examKey), slug, count); } 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);