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:
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user