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:
Maxim Dolgolyov
2026-06-03 16:18:41 +03:00
parent e210410526
commit a096f3bcd9
+76 -23
View File
@@ -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 = ?