diff --git a/backend/src/routes/exam-prep.js b/backend/src/routes/exam-prep.js index fc484c1..578c0ab 100644 --- a/backend/src/routes/exam-prep.js +++ b/backend/src/routes/exam-prep.js @@ -73,7 +73,7 @@ const SQL = { getVariantTasks: db.prepare(` SELECT id, task_idx, task_type, text_html, figure_html, opts_json, - answer, solution_html, topic, subtopic + answer, solution_html, topic, subtopic, textbook_slug, textbook_paragraph FROM exam_tasks WHERE exam_key = ? AND variant = ? ORDER BY task_idx @@ -87,7 +87,7 @@ const SQL = { practiceRandom: db.prepare(` SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, - answer, solution_html, topic, subtopic, difficulty + answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph FROM exam_tasks WHERE exam_key = ? AND task_type IN ('mc','open') @@ -116,7 +116,8 @@ const SQL = { 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 + t.opts_json, t.answer, t.solution_html, t.topic, t.subtopic, t.difficulty, + t.textbook_slug, t.textbook_paragraph FROM exam_tasks t WHERE t.exam_key = ? AND t.task_type IN ('mc','open') @@ -168,7 +169,7 @@ const SQL = { const ph = ids.map(() => '?').join(','); return db.prepare(` SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, - answer, solution_html, topic + answer, solution_html, topic, subtopic, textbook_slug, textbook_paragraph FROM exam_tasks WHERE id IN (${ph}) `).all(...ids); @@ -320,7 +321,7 @@ const SQL = { correctly, when ?exclude_solved=1. */ topicTasksUnsolved: db.prepare(` SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, - answer, solution_html, topic, subtopic, difficulty + answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph FROM exam_tasks t WHERE t.exam_key = ? AND t.subtopic = ? AND t.task_type IN ('mc','open') @@ -334,7 +335,7 @@ const SQL = { topicTasksAny: db.prepare(` SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, - answer, solution_html, topic, subtopic, difficulty + answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph FROM exam_tasks t WHERE t.exam_key = ? AND t.subtopic = ? AND t.task_type IN ('mc','open') @@ -392,7 +393,7 @@ const SQL = { const ph = slugs.map(() => '?').join(','); return db.prepare(` SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, - answer, solution_html, topic, subtopic, difficulty + answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph FROM exam_tasks t WHERE t.exam_key = ? AND t.subtopic IN (${ph}) @@ -480,19 +481,33 @@ router.get('/:examKey/variants/:n/tasks', (req, res) => { 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, - 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, - subtopic: r.subtopic, - topic_ref: r.subtopic ? (refMap.get(r.subtopic) || null) : null, - })); + const tasks = rows.map(r => { + let topic_ref = null; + if (r.textbook_slug) { + const paraTitle = getParaTitle(r.textbook_slug, r.textbook_paragraph); + const fallbackTitle = r.subtopic ? (refMap.get(r.subtopic)?.title || null) : null; + topic_ref = { + title: paraTitle || fallbackTitle, + slug: r.textbook_slug, + paragraph: r.textbook_paragraph ?? null, + }; + } else if (r.subtopic) { + topic_ref = refMap.get(r.subtopic) || null; + } + return { + id: r.id, + idx: r.task_idx, + 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, + subtopic: r.subtopic, + topic_ref, + }; + }); res.json({ variant: n, tasks }); }); @@ -520,7 +535,20 @@ function getTopicRefMap(examKey) { } function shapeTask(r, refMap) { - const ref = (refMap && r.subtopic) ? refMap.get(r.subtopic) : null; + // Prefer task-level textbook over subtopic-level fallback. + let topic_ref = null; + if (r.textbook_slug) { + // Build title from para map, fall back to subtopic title via refMap. + const fallbackTitle = (refMap && r.subtopic) ? (refMap.get(r.subtopic)?.title || null) : null; + const paraTitle = getParaTitle(r.textbook_slug, r.textbook_paragraph); + topic_ref = { + title: paraTitle || fallbackTitle, + slug: r.textbook_slug, + paragraph: r.textbook_paragraph ?? null, + }; + } else if (refMap && r.subtopic) { + topic_ref = refMap.get(r.subtopic) || null; + } return { id: r.id, idx: r.task_idx, @@ -534,10 +562,35 @@ function shapeTask(r, refMap) { topic: r.topic ?? null, subtopic: r.subtopic ?? null, difficulty: r.difficulty ?? null, - topic_ref: ref || null, + topic_ref: topic_ref || null, }; } +/* ── Para-title cache ───────────────────────────────────────────── + chapter_slug + paragraph_int → title string from g9_textbook_sections.json. + Built once on first call; covers all books 5-9. */ +let _paraCache = null; +function _buildParaCache() { + const cache = new Map(); + try { + const sections = require('../../scripts/data/g9_textbook_sections'); + for (const s of sections) { + if (!s.para_id || !s.title) continue; + const m = String(s.para_id).match(/^p(\d+)$/); + if (!m) continue; + const n = parseInt(m[1], 10); + cache.set(`${s.chapter_slug}:${n}`, s.title); + } + } catch { /* if file missing, silently skip */ } + return cache; +} + +function getParaTitle(chapterSlug, paraNum) { + if (!chapterSlug || paraNum == null) return null; + if (!_paraCache) _paraCache = _buildParaCache(); + return _paraCache.get(`${chapterSlug}:${paraNum}`) || 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 @@ -583,7 +636,7 @@ function pickRandomByDifficulty(examKey, count, excludeSlugs) { 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 + answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph FROM exam_tasks WHERE exam_key = ? AND task_type IN ('mc','open') AND difficulty = ?