From 3cc52e21b04310212d20706e6ef4500b21301a0f Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 29 May 2026 14:55:47 +0300 Subject: [PATCH] feat(exam9): link tasks to textbook + difficulty-ordered random + topic exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/. Mapping seeded for all 15 math9 subtopics in migration 028. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../028_exam_topic_textbook_links.sql | 43 ++++++ backend/src/routes/exam-prep.js | 141 +++++++++++++++++- frontend/css/exam-prep.css | 81 ++++++++++ frontend/js/exam-prep/practice.js | 104 ++++++++++++- frontend/js/exam-prep/task-card.js | 26 +++- 5 files changed, 386 insertions(+), 9 deletions(-) create mode 100644 backend/src/db/migrations/028_exam_topic_textbook_links.sql diff --git a/backend/src/db/migrations/028_exam_topic_textbook_links.sql b/backend/src/db/migrations/028_exam_topic_textbook_links.sql new file mode 100644 index 0000000..98eacef --- /dev/null +++ b/backend/src/db/migrations/028_exam_topic_textbook_links.sql @@ -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/ (when paragraph is NULL) +-- /textbook/#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. diff --git a/backend/src/routes/exam-prep.js b/backend/src/routes/exam-prep.js index 830c942..75fb0b9 100644 --- a/backend/src/routes/exam-prep.js +++ b/backend/src/routes/exam-prep.js @@ -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)), }); }); diff --git a/frontend/css/exam-prep.css b/frontend/css/exam-prep.css index 7211188..fcdbe37 100644 --- a/frontend/css/exam-prep.css +++ b/frontend/css/exam-prep.css @@ -1054,3 +1054,84 @@ .tc-input-row { gap: 8px; } .tc-ans-input { max-width: 100%; } } + +/* ── "Учить тему" link inside a task card ───────────────────────── */ +.tc-sol-row { + display: flex; align-items: center; gap: 10px; + flex-wrap: wrap; +} +.tc-ref-btn { + display: inline-flex; align-items: center; gap: 6px; + margin-left: auto; + padding: 6px 12px; + border: 1.5px solid var(--violet, #7c3aed); + border-radius: 9px; + background: rgba(124, 58, 237, 0.08); + color: var(--violet, #7c3aed); + font-family: 'Manrope', sans-serif; font-size: .82rem; font-weight: 700; + text-decoration: none; + transition: background .15s, color .15s; +} +.tc-ref-btn:hover { + background: var(--violet, #7c3aed); color: #fff; +} +.tc-ref-btn svg { width: 14px; height: 14px; } + +/* ── Difficulty-progression note above the task list ─────────────── */ +.pr-difficulty-note { + display: flex; align-items: center; gap: 8px; + margin-top: 10px; + padding: 8px 12px; + background: rgba(6, 214, 160, 0.08); + border-left: 3px solid #06D6A0; + border-radius: 6px; + font-size: .82rem; + color: var(--text-2); +} +.pr-difficulty-note i, .pr-difficulty-note svg { width: 14px; height: 14px; flex-shrink: 0; } + +/* ── Exclude-topics button + count badge ─────────────────────────── */ +.pr-exclude-btn { + display: inline-flex; align-items: center; gap: 6px; + position: relative; +} +.pr-exclude-count { + display: inline-flex; align-items: center; justify-content: center; + min-width: 18px; height: 18px; + padding: 0 5px; + border-radius: 9px; + background: var(--violet, #7c3aed); color: #fff; + font-size: .7rem; font-weight: 800; line-height: 1; +} + +/* ── Exclude-topics modal ────────────────────────────────────────── */ +.pr-ex-form { display: flex; flex-direction: column; gap: 16px; max-height: 60vh; overflow-y: auto; } +.pr-ex-hint { font-size: .82rem; color: var(--text-3); margin: 0; } +.pr-ex-section { border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; } +.pr-ex-section-head { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 8px; + font-family: 'Unbounded', sans-serif; font-size: .82rem; font-weight: 800; + letter-spacing: .02em; color: var(--text); +} +.pr-ex-toggle-all { + border: 1px solid var(--border-h); background: transparent; color: var(--text-2); + padding: 3px 10px; border-radius: 6px; + font-family: 'Manrope', sans-serif; font-size: .72rem; font-weight: 700; + cursor: pointer; transition: all .12s; +} +.pr-ex-toggle-all:hover { border-color: var(--violet); color: var(--violet); } +.pr-ex-items { display: flex; flex-direction: column; gap: 4px; } +.pr-ex-item { + display: flex; align-items: center; gap: 10px; + padding: 6px 8px; + border-radius: 6px; + cursor: pointer; + font-size: .9rem; + transition: background .12s; +} +.pr-ex-item:hover { background: var(--border); } +.pr-ex-item input { accent-color: var(--violet); flex-shrink: 0; } +.pr-ex-title { flex: 1; } +.pr-ex-meta { font-size: .76rem; color: var(--text-3); } +.pr-ex-empty { padding: 16px; text-align: center; color: var(--text-3); font-size: .85rem; } diff --git a/frontend/js/exam-prep/practice.js b/frontend/js/exam-prep/practice.js index f342678..d0be3e4 100644 --- a/frontend/js/exam-prep/practice.js +++ b/frontend/js/exam-prep/practice.js @@ -15,6 +15,8 @@ let batch = null; // { strategy, session_id, tasks: [...] } let strategy = readStrategyFromUrl() || readPersistedStrategy() || 'random'; let count = readPersistedCount(); + let excludeSlugs = readPersistedExclude(); // Set + let topicSections = null; // { sections: [...] } — lazy-loaded let finalized = false; const results = new Map(); // taskId -> { isCorrect: 0|1|null, attempts: number } @@ -39,6 +41,17 @@ function persistCount(n) { try { localStorage.setItem(`exam_prep_${examKey}_practice_count`, String(n)); } catch {} } + function readPersistedExclude() { + try { + const raw = localStorage.getItem(`exam_prep_${examKey}_practice_exclude`) || ''; + return new Set(raw.split(',').filter(Boolean)); + } catch { return new Set(); } + } + function persistExclude(set) { + try { + localStorage.setItem(`exam_prep_${examKey}_practice_exclude`, Array.from(set).join(',')); + } catch {} + } /* ── Boot: show controls and load first batch ───────────────── */ await loadBatch(); @@ -47,6 +60,11 @@ function controlsHTML() { const opts = [5, 10, 15, 20].map(n => ` `).join(''); + const excludeBadge = excludeSlugs.size + ? `${excludeSlugs.size}` : ''; + // Exclude only applies to the difficulty-ordered random pool. Hide + // for other strategies so users don't think it filters them too. + const showExclude = strategy === 'random'; return `
@@ -66,10 +84,19 @@
+ ${showExclude ? ` + ` : ''}
+ ${strategy === 'random' ? ` +
+ + Задачи идут по возрастанию сложности: 1-я всегда лёгкая, к концу — сложнее. +
` : ''}
`; } @@ -102,7 +129,11 @@ wireControls(); try { - batch = await EP.api.getPracticeNext(examKey, { count, strategy }); + const query = { count, strategy }; + if (strategy === 'random' && excludeSlugs.size) { + query.exclude = Array.from(excludeSlugs).join(','); + } + batch = await EP.api.getPracticeNext(examKey, query); } catch (e) { main.querySelector('.ep-empty').outerHTML = `
@@ -234,6 +265,77 @@ } const restart = main.querySelector('.pr-restart'); if (restart) restart.onclick = () => loadBatch(); + + const excludeBtn = main.querySelector('.pr-exclude-btn'); + if (excludeBtn) excludeBtn.onclick = () => openExcludeModal(); + } + + /* ── Exclude-topics modal ───────────────────────────────────── */ + async function openExcludeModal() { + if (!topicSections) { + try { + topicSections = await EP.api.listTopics(examKey); + } catch (e) { + LS.toast?.('Не удалось загрузить темы', 'error'); + return; + } + } + const sections = topicSections.sections || []; + const sectionsHtml = sections.map(sec => { + const items = (sec.subtopics || []).map(st => { + const checked = excludeSlugs.has(st.slug) ? 'checked' : ''; + return ` + `; + }).join(''); + return ` +
+
+ ${escapeHtml(sec.title)} + +
+
${items}
+
`; + }).join(''); + + const body = ` +
+

Отмеченные темы не попадут в случайный пул. Сложность распределения сохраняется.

+ ${sectionsHtml || '
Темы не настроены
'} +
`; + + const m = LS.modal({ + title: 'Исключить темы из пула', + content: body, + size: 'md', + actions: [ + { label: 'Сбросить', onClick: () => { + m.body.querySelectorAll('input[type="checkbox"]').forEach(c => c.checked = false); + } }, + { label: 'Отмена', onClick: () => m.close() }, + { label: 'Применить', primary: true, onClick: () => { + const checked = Array.from(m.body.querySelectorAll('input[type="checkbox"]:checked')).map(c => c.value); + excludeSlugs = new Set(checked); + persistExclude(excludeSlugs); + m.close(); + loadBatch(); + } }, + ], + }); + + // "Все" toggle per section + m.body.querySelectorAll('.pr-ex-toggle-all').forEach(btn => { + btn.onclick = () => { + const items = m.body.querySelector(`[data-section-items="${btn.dataset.section}"]`); + if (!items) return; + const boxes = items.querySelectorAll('input[type="checkbox"]'); + const allChecked = Array.from(boxes).every(b => b.checked); + boxes.forEach(b => b.checked = !allChecked); + }; + }); } function escapeHtml(s) { diff --git a/frontend/js/exam-prep/task-card.js b/frontend/js/exam-prep/task-card.js index 444b474..dbf1a7e 100644 --- a/frontend/js/exam-prep/task-card.js +++ b/frontend/js/exam-prep/task-card.js @@ -34,6 +34,24 @@ return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c])); } + /* topic_ref → "Учить тему" deep-link to the textbook chapter/paragraph. + ref = { slug, paragraph, title }. Paragraph is null for hub links. */ + function buildRefLink(ref) { + if (!ref || !ref.slug) return ''; + const href = ref.paragraph + ? `/textbook/${encodeURIComponent(ref.slug)}#sec-p${ref.paragraph}` + : `/textbook/${encodeURIComponent(ref.slug)}`; + const label = ref.paragraph ? `§${ref.paragraph}` : 'учебник'; + const title = ref.title ? `Учить тему: ${escapeHtml(ref.title)}` : 'Перейти к материалу'; + return ` + + Учить тему · ${escapeHtml(label)} + `; + } + function buildOptsBlock(taskId, opts) { const isLong = opts.some(([, t]) => t.length > 40 && !t.startsWith('$')); const cls = isLong ? 'tc-opts tc-opts-vertical' : 'tc-opts'; @@ -101,11 +119,15 @@
` + verdictSlot; } + const refLink = buildRefLink(task.topic_ref); const solBlock = (showSol && task.solution) ? `
- +
+ + ${refLink} +
${task.solution}
-
` : ''; + ` : (refLink ? `
${refLink}
` : ''); card.innerHTML = `