feat(exam): Phase 4 — контроллер использует task-level textbook_slug/paragraph
- SELECT добавляет t.textbook_slug, t.textbook_paragraph во все 7 запросов (getVariantTasks, practiceRandom, practiceUnsolved, pickRandomByDifficulty, topicTasksUnsolved/Any, weakBatchTasks, getTasksByIds) - shapeTask() предпочитает task-level ссылку, fallback на refMap subtopic - /variants/:n/tasks аналогично использует per-task поля - getParaTitle() строит map chapter:para -> title из g9_textbook_sections.json Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -73,7 +73,7 @@ const SQL = {
|
|||||||
getVariantTasks: db.prepare(`
|
getVariantTasks: db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
id, task_idx, task_type, text_html, figure_html, opts_json,
|
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
|
FROM exam_tasks
|
||||||
WHERE exam_key = ? AND variant = ?
|
WHERE exam_key = ? AND variant = ?
|
||||||
ORDER BY task_idx
|
ORDER BY task_idx
|
||||||
@@ -87,7 +87,7 @@ const SQL = {
|
|||||||
practiceRandom: db.prepare(`
|
practiceRandom: db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
id, task_idx, variant, task_type, text_html, figure_html, opts_json,
|
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
|
FROM exam_tasks
|
||||||
WHERE exam_key = ?
|
WHERE exam_key = ?
|
||||||
AND task_type IN ('mc','open')
|
AND task_type IN ('mc','open')
|
||||||
@@ -116,7 +116,8 @@ const SQL = {
|
|||||||
practiceUnsolved: db.prepare(`
|
practiceUnsolved: db.prepare(`
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.task_idx, t.variant, t.task_type, t.text_html, t.figure_html,
|
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
|
FROM exam_tasks t
|
||||||
WHERE t.exam_key = ?
|
WHERE t.exam_key = ?
|
||||||
AND t.task_type IN ('mc','open')
|
AND t.task_type IN ('mc','open')
|
||||||
@@ -168,7 +169,7 @@ const SQL = {
|
|||||||
const ph = ids.map(() => '?').join(',');
|
const ph = ids.map(() => '?').join(',');
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
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
|
answer, solution_html, topic, subtopic, textbook_slug, textbook_paragraph
|
||||||
FROM exam_tasks
|
FROM exam_tasks
|
||||||
WHERE id IN (${ph})
|
WHERE id IN (${ph})
|
||||||
`).all(...ids);
|
`).all(...ids);
|
||||||
@@ -320,7 +321,7 @@ const SQL = {
|
|||||||
correctly, when ?exclude_solved=1. */
|
correctly, when ?exclude_solved=1. */
|
||||||
topicTasksUnsolved: db.prepare(`
|
topicTasksUnsolved: db.prepare(`
|
||||||
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
|
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.subtopic = ?
|
||||||
AND t.task_type IN ('mc','open')
|
AND t.task_type IN ('mc','open')
|
||||||
@@ -334,7 +335,7 @@ const SQL = {
|
|||||||
|
|
||||||
topicTasksAny: db.prepare(`
|
topicTasksAny: db.prepare(`
|
||||||
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
|
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.subtopic = ?
|
||||||
AND t.task_type IN ('mc','open')
|
AND t.task_type IN ('mc','open')
|
||||||
@@ -392,7 +393,7 @@ const SQL = {
|
|||||||
const ph = slugs.map(() => '?').join(',');
|
const ph = slugs.map(() => '?').join(',');
|
||||||
return db.prepare(`
|
return db.prepare(`
|
||||||
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
|
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.subtopic IN (${ph})
|
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' });
|
if (!rows.length) return res.status(404).json({ error: 'Variant not found or empty' });
|
||||||
|
|
||||||
const refMap = getTopicRefMap(examKey);
|
const refMap = getTopicRefMap(examKey);
|
||||||
const tasks = rows.map(r => ({
|
const tasks = rows.map(r => {
|
||||||
id: r.id,
|
let topic_ref = null;
|
||||||
idx: r.task_idx,
|
if (r.textbook_slug) {
|
||||||
type: r.task_type,
|
const paraTitle = getParaTitle(r.textbook_slug, r.textbook_paragraph);
|
||||||
text: r.text_html,
|
const fallbackTitle = r.subtopic ? (refMap.get(r.subtopic)?.title || null) : null;
|
||||||
figure: r.figure_html,
|
topic_ref = {
|
||||||
opts: r.opts_json ? safeJson(r.opts_json) : null,
|
title: paraTitle || fallbackTitle,
|
||||||
answer: r.answer,
|
slug: r.textbook_slug,
|
||||||
solution: r.solution_html,
|
paragraph: r.textbook_paragraph ?? null,
|
||||||
topic: r.topic,
|
};
|
||||||
subtopic: r.subtopic,
|
} else if (r.subtopic) {
|
||||||
topic_ref: r.subtopic ? (refMap.get(r.subtopic) || null) : null,
|
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 });
|
res.json({ variant: n, tasks });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -520,7 +535,20 @@ function getTopicRefMap(examKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shapeTask(r, refMap) {
|
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 {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
idx: r.task_idx,
|
idx: r.task_idx,
|
||||||
@@ -534,10 +562,35 @@ function shapeTask(r, refMap) {
|
|||||||
topic: r.topic ?? null,
|
topic: r.topic ?? null,
|
||||||
subtopic: r.subtopic ?? null,
|
subtopic: r.subtopic ?? null,
|
||||||
difficulty: r.difficulty ?? 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 ───────────────
|
/* ── Difficulty distribution for "Random" practice ───────────────
|
||||||
Returns an array [n1,n2,n3,n4,n5] of how many tasks of each
|
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
|
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;
|
if (limit === 0) continue;
|
||||||
const sql = `
|
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
|
answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph
|
||||||
FROM exam_tasks
|
FROM exam_tasks
|
||||||
WHERE exam_key = ? AND task_type IN ('mc','open')
|
WHERE exam_key = ? AND task_type IN ('mc','open')
|
||||||
AND difficulty = ?
|
AND difficulty = ?
|
||||||
|
|||||||
Reference in New Issue
Block a user