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(`
|
||||
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 = ?
|
||||
|
||||
Reference in New Issue
Block a user