feat(exam9): link tasks to textbook + difficulty-ordered random + topic exclusion
Practice (random) now picks tasks by ascending difficulty so the first slot is always level 1 and the session ramps up. Adds ?exclude= to drop specific subtopics from the random pool, with a per-section checkbox modal in the UI. Each task carries a topic_ref (textbook chapter + paragraph) shown as a 'Учить тему · §N' button next to the solution, deep-linking to the right section of /textbook/<slug>. Mapping seeded for all 15 math9 subtopics in migration 028. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
-- 028: Link exam_topics → textbook chapter + paragraph
|
||||
--
|
||||
-- Adds two columns to exam_topics:
|
||||
-- textbook_slug — slug of the textbook chapter (e.g. 'algebra-9-ch3')
|
||||
-- textbook_paragraph — paragraph number inside that chapter (NULL → no anchor)
|
||||
--
|
||||
-- The frontend builds a link like
|
||||
-- /textbook/<slug> (when paragraph is NULL)
|
||||
-- /textbook/<slug>#sec-pN (when paragraph = N)
|
||||
-- so a student who fails an exam task is sent straight to the right §.
|
||||
--
|
||||
-- Mapping covers math9 subtopics. Sections (parent_slug IS NULL) are
|
||||
-- left without a textbook_slug — the per-task link is driven by the
|
||||
-- specific subtopic, not the section.
|
||||
-- ═══════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE exam_topics ADD COLUMN textbook_slug TEXT;
|
||||
ALTER TABLE exam_topics ADD COLUMN textbook_paragraph INTEGER;
|
||||
|
||||
-- ── math9 ───────────────────────────────────────────────────────
|
||||
-- Algebra
|
||||
UPDATE exam_topics SET textbook_slug = 'algebra-9-ch1', textbook_paragraph = 5 WHERE slug = 'alg-expressions';
|
||||
UPDATE exam_topics SET textbook_slug = 'algebra-9-ch1', textbook_paragraph = 3 WHERE slug = 'alg-fractions';
|
||||
UPDATE exam_topics SET textbook_slug = 'algebra-9-ch2', textbook_paragraph = 6 WHERE slug = 'alg-functions';
|
||||
UPDATE exam_topics SET textbook_slug = 'algebra-9-ch3', textbook_paragraph = 10 WHERE slug = 'alg-equations';
|
||||
UPDATE exam_topics SET textbook_slug = 'algebra-9-ch3', textbook_paragraph = 13 WHERE slug = 'alg-inequalities';
|
||||
UPDATE exam_topics SET textbook_slug = 'algebra-9-ch4', textbook_paragraph = 15 WHERE slug = 'alg-progressions';
|
||||
|
||||
-- Topics that aren't covered by a single Grade-9 paragraph — link to hub.
|
||||
UPDATE exam_topics SET textbook_slug = 'algebra-9' WHERE slug IN (
|
||||
'alg-numbers', 'alg-arithmetic', 'alg-powers',
|
||||
'alg-polynomials', 'alg-word-problems'
|
||||
);
|
||||
|
||||
-- Geometry
|
||||
UPDATE exam_topics SET textbook_slug = 'geometry-9-ch1', textbook_paragraph = 1 WHERE slug = 'geom-triangles';
|
||||
UPDATE exam_topics SET textbook_slug = 'geometry-9-ch2', textbook_paragraph = 9 WHERE slug = 'geom-quadrilaterals';
|
||||
UPDATE exam_topics SET textbook_slug = 'geometry-9-ch2', textbook_paragraph = 7 WHERE slug = 'geom-circle';
|
||||
-- Coordinates / circle-equation lives in algebra-9 ch3 §12 (уравнение окружности)
|
||||
UPDATE exam_topics SET textbook_slug = 'algebra-9-ch3', textbook_paragraph = 12 WHERE slug = 'geom-coordinates';
|
||||
|
||||
-- theory-statements has no single textbook home — leave NULL.
|
||||
@@ -70,11 +70,14 @@ const SQL = {
|
||||
`),
|
||||
|
||||
/* Practice batch — RANDOM strategy.
|
||||
Excludes long tasks (no auto-check) by default to keep UX consistent. */
|
||||
Excludes long tasks (no auto-check) by default to keep UX consistent.
|
||||
NOTE: kept for backwards compatibility / fallback. The primary random
|
||||
path goes through pickRandomByDifficulty() below (difficulty-ordered
|
||||
with optional topic exclusions). */
|
||||
practiceRandom: db.prepare(`
|
||||
SELECT
|
||||
id, task_idx, variant, task_type, text_html, figure_html, opts_json,
|
||||
answer, solution_html, topic
|
||||
answer, solution_html, topic, subtopic, difficulty
|
||||
FROM exam_tasks
|
||||
WHERE exam_key = ?
|
||||
AND task_type IN ('mc','open')
|
||||
@@ -82,6 +85,22 @@ const SQL = {
|
||||
LIMIT ?
|
||||
`),
|
||||
|
||||
/* All subtopic slugs known for an exam — used to validate ?exclude=
|
||||
so a bogus slug doesn't silently drop the filter. */
|
||||
listSubtopicSlugs: db.prepare(`
|
||||
SELECT slug FROM exam_topics
|
||||
WHERE exam_key = ? AND parent_slug IS NOT NULL
|
||||
`),
|
||||
|
||||
/* Topic reference map: subtopic_slug → textbook chapter / paragraph.
|
||||
Loaded once and cached so we can stamp `topic_ref` onto every task
|
||||
shape without an extra JOIN per query. */
|
||||
listTopicRefs: db.prepare(`
|
||||
SELECT slug, title, textbook_slug, textbook_paragraph
|
||||
FROM exam_topics
|
||||
WHERE exam_key = ? AND textbook_slug IS NOT NULL
|
||||
`),
|
||||
|
||||
/* Practice batch — UNSOLVED strategy.
|
||||
Excludes tasks the user has already solved correctly at least once. */
|
||||
practiceUnsolved: db.prepare(`
|
||||
@@ -261,6 +280,7 @@ const SQL = {
|
||||
listTopicsWithCounts: db.prepare(`
|
||||
SELECT
|
||||
tp.slug, tp.parent_slug, tp.title, tp.sort_order,
|
||||
tp.textbook_slug, tp.textbook_paragraph,
|
||||
COALESCE(stat.total, 0) AS total,
|
||||
COALESCE(stat.attempted, 0) AS attempted,
|
||||
COALESCE(stat.solved, 0) AS solved,
|
||||
@@ -448,6 +468,7 @@ router.get('/:examKey/variants/:n/tasks', (req, res) => {
|
||||
const rows = SQL.getVariantTasks.all(examKey, n);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Variant not found or empty' });
|
||||
|
||||
const refMap = getTopicRefMap(examKey);
|
||||
const tasks = rows.map(r => ({
|
||||
id: r.id,
|
||||
idx: r.task_idx,
|
||||
@@ -459,13 +480,36 @@ router.get('/:examKey/variants/:n/tasks', (req, res) => {
|
||||
solution: r.solution_html,
|
||||
topic: r.topic,
|
||||
subtopic: r.subtopic,
|
||||
topic_ref: r.subtopic ? (refMap.get(r.subtopic) || null) : null,
|
||||
}));
|
||||
res.json({ variant: n, tasks });
|
||||
});
|
||||
|
||||
function safeJson(s) { try { return JSON.parse(s); } catch { return null; } }
|
||||
|
||||
function shapeTask(r) {
|
||||
/* ── Topic-ref cache ─────────────────────────────────────────────
|
||||
subtopic_slug → { title, textbook_slug, textbook_paragraph }.
|
||||
Loaded lazily per examKey; invalidated never (migrations are the
|
||||
only writer, and a server restart picks up new mappings). */
|
||||
const _topicRefCache = new Map();
|
||||
function getTopicRefMap(examKey) {
|
||||
let map = _topicRefCache.get(examKey);
|
||||
if (map) return map;
|
||||
const rows = SQL.listTopicRefs.all(examKey);
|
||||
map = new Map();
|
||||
for (const r of rows) {
|
||||
map.set(r.slug, {
|
||||
title: r.title,
|
||||
slug: r.textbook_slug,
|
||||
paragraph: r.textbook_paragraph ?? null,
|
||||
});
|
||||
}
|
||||
_topicRefCache.set(examKey, map);
|
||||
return map;
|
||||
}
|
||||
|
||||
function shapeTask(r, refMap) {
|
||||
const ref = (refMap && r.subtopic) ? refMap.get(r.subtopic) : null;
|
||||
return {
|
||||
id: r.id,
|
||||
idx: r.task_idx,
|
||||
@@ -477,9 +521,81 @@ function shapeTask(r) {
|
||||
answer: r.answer,
|
||||
solution: r.solution_html,
|
||||
topic: r.topic ?? null,
|
||||
subtopic: r.subtopic ?? null,
|
||||
difficulty: r.difficulty ?? null,
|
||||
topic_ref: ref || null,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Difficulty distribution for "Random" practice ───────────────
|
||||
Returns an array [n1,n2,n3,n4,n5] of how many tasks of each
|
||||
difficulty level (1..5) to pick, summing to N. Position 1 of the
|
||||
batch is always difficulty 1 (so n1 >= 1 whenever N >= 1). */
|
||||
function distributeByDifficulty(N) {
|
||||
if (N <= 0) return [0, 0, 0, 0, 0];
|
||||
if (N <= 5) {
|
||||
// 1 task per level, then drop hardest first if N<5.
|
||||
// Order [4,3,1,2] keeps slot 0 (difficulty 1) until last so the first
|
||||
// task is always easiest.
|
||||
const c = [1, 1, 1, 1, 1];
|
||||
let drop = 5 - N;
|
||||
const off = [4, 3, 1, 2];
|
||||
for (let i = 0; drop > 0 && i < off.length; i++) { c[off[i]] = 0; drop--; }
|
||||
return c;
|
||||
}
|
||||
// Weighted: 10% / 20% / 30% / 20% / 20%, min 1 per level so first slot = d1.
|
||||
const w = [0.10, 0.20, 0.30, 0.20, 0.20];
|
||||
const c = w.map(p => Math.max(1, Math.round(N * p)));
|
||||
let total = c.reduce((a, b) => a + b, 0);
|
||||
// adjust: trim from level 5 then 4, top-up at level 3
|
||||
while (total > N) {
|
||||
const idx = c[4] > 1 ? 4 : (c[3] > 1 ? 3 : c.indexOf(Math.max(...c)));
|
||||
c[idx]--; total--;
|
||||
}
|
||||
while (total < N) { c[2]++; total++; }
|
||||
return c;
|
||||
}
|
||||
|
||||
/* Pick a difficulty-ordered batch with optional subtopic exclusions.
|
||||
Excludes long tasks (no auto-check). Tasks come back already sorted
|
||||
by difficulty ascending — the response order is the play order. */
|
||||
function pickRandomByDifficulty(examKey, count, excludeSlugs) {
|
||||
const dist = distributeByDifficulty(count);
|
||||
const exParams = excludeSlugs.length ? excludeSlugs : null;
|
||||
const exClause = exParams
|
||||
? `AND (subtopic IS NULL OR subtopic NOT IN (${exParams.map(() => '?').join(',')}))`
|
||||
: '';
|
||||
|
||||
const out = [];
|
||||
for (let d = 1; d <= 5; d++) {
|
||||
const limit = dist[d - 1];
|
||||
if (limit === 0) continue;
|
||||
const sql = `
|
||||
SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json,
|
||||
answer, solution_html, topic, subtopic, difficulty
|
||||
FROM exam_tasks
|
||||
WHERE exam_key = ? AND task_type IN ('mc','open')
|
||||
AND difficulty = ?
|
||||
${exClause}
|
||||
ORDER BY RANDOM()
|
||||
LIMIT ?`;
|
||||
const args = exParams
|
||||
? [examKey, d, ...exParams, limit]
|
||||
: [examKey, d, limit];
|
||||
out.push(...db.prepare(sql).all(...args));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* Parse + validate ?exclude=slug1,slug2 against known subtopics for examKey. */
|
||||
function parseExcludeParam(examKey, raw) {
|
||||
if (!raw) return [];
|
||||
const requested = String(raw).split(',').map(s => s.trim()).filter(Boolean);
|
||||
if (!requested.length) return [];
|
||||
const valid = new Set(SQL.listSubtopicSlugs.all(examKey).map(r => r.slug));
|
||||
return requested.filter(s => valid.has(s));
|
||||
}
|
||||
|
||||
/* ── 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
|
||||
@@ -498,6 +614,8 @@ router.get('/:examKey/practice/next', (req, res) => {
|
||||
let rows;
|
||||
let weakSlugs = null;
|
||||
|
||||
const excludeSlugs = parseExcludeParam(examKey, req.query.exclude);
|
||||
|
||||
if (strategy === 'weak') {
|
||||
weakSlugs = SQL.weakTopicSlugs.all(req.user.id, examKey).map(r => r.slug);
|
||||
if (weakSlugs.length === 0) {
|
||||
@@ -515,14 +633,22 @@ router.get('/:examKey/practice/next', (req, res) => {
|
||||
rows = SQL.practiceUnsolved.all(examKey, req.user.id, count);
|
||||
if (!rows.length) rows = SQL.practiceRandom.all(examKey, count);
|
||||
} else {
|
||||
rows = SQL.practiceRandom.all(examKey, count);
|
||||
// Random: difficulty-ordered batch, position 1 = difficulty 1.
|
||||
rows = pickRandomByDifficulty(examKey, count, excludeSlugs);
|
||||
// Fallback if exclusions wiped the pool — drop them and retry.
|
||||
if (!rows.length && excludeSlugs.length) {
|
||||
rows = pickRandomByDifficulty(examKey, count, []);
|
||||
}
|
||||
if (!rows.length) rows = SQL.practiceRandom.all(examKey, count);
|
||||
}
|
||||
|
||||
const refMap = getTopicRefMap(examKey);
|
||||
res.json({
|
||||
strategy,
|
||||
weak_slugs: weakSlugs,
|
||||
excluded: excludeSlugs,
|
||||
session_id: Date.now(),
|
||||
tasks: rows.map(shapeTask),
|
||||
tasks: rows.map(r => shapeTask(r, refMap)),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -717,6 +843,8 @@ router.get('/:examKey/topics', (req, res) => {
|
||||
attempted: r.attempted,
|
||||
attempts: r.attempts,
|
||||
correct: r.correct,
|
||||
textbook_slug: r.textbook_slug ?? null,
|
||||
textbook_paragraph: r.textbook_paragraph ?? null,
|
||||
accuracy,
|
||||
solved_pct: solvedPct,
|
||||
});
|
||||
@@ -752,10 +880,11 @@ router.get('/:examKey/topics/:slug/tasks', (req, res) => {
|
||||
rows = SQL.topicTasksAny.all(examKey, slug, count);
|
||||
}
|
||||
|
||||
const refMap = getTopicRefMap(examKey);
|
||||
res.json({
|
||||
topic: { slug: meta.slug, title: meta.title, parent: meta.parent_slug },
|
||||
session_id: Date.now(),
|
||||
tasks: rows.map(shapeTask),
|
||||
tasks: rows.map(r => shapeTask(r, refMap)),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user