Тренировка по темам
-В F6 проставим теги темам (LLM-классификация), а в F7 здесь появится список из ~25 подтем с точностью пользователя и кнопкой «Прорешать 20 задач».
+ +diff --git a/backend/src/routes/exam-prep.js b/backend/src/routes/exam-prep.js index 65cc5a7..39efdcf 100644 --- a/backend/src/routes/exam-prep.js +++ b/backend/src/routes/exam-prep.js @@ -255,6 +255,68 @@ const SQL = { AND a.is_correct = 1 AND DATE(a.created_at / 1000, 'unixepoch') = DATE('now') `), + + /* ── Topics (F7) ────────────────────────────────────────────── */ + + listTopicsWithCounts: db.prepare(` + SELECT + tp.slug, tp.parent_slug, tp.title, tp.sort_order, + COALESCE(stat.total, 0) AS total, + COALESCE(stat.attempted, 0) AS attempted, + COALESCE(stat.solved, 0) AS solved, + COALESCE(stat.attempts, 0) AS attempts, + COALESCE(stat.correct, 0) AS correct + FROM exam_topics tp + LEFT JOIN ( + SELECT + t.subtopic, + COUNT(DISTINCT t.id) AS total, + COUNT(DISTINCT a.exam_task_id) AS attempted, + COUNT(DISTINCT CASE WHEN a.is_correct = 1 THEN a.exam_task_id END) AS solved, + COUNT(a.id) AS attempts, + COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct + FROM exam_tasks t + LEFT JOIN exam_attempts a + ON a.exam_task_id = t.id AND a.user_id = ? + WHERE t.exam_key = ? AND t.subtopic IS NOT NULL + GROUP BY t.subtopic + ) stat ON stat.subtopic = tp.slug + WHERE tp.exam_key = ? + ORDER BY tp.sort_order + `), + + /* Pick tasks for a topic practice session. + Excludes long tasks (no auto-check) and tasks the user has already solved + 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 + FROM exam_tasks t + WHERE t.exam_key = ? AND t.subtopic = ? + AND t.task_type IN ('mc','open') + AND NOT EXISTS ( + SELECT 1 FROM exam_attempts a + WHERE a.exam_task_id = t.id AND a.user_id = ? AND a.is_correct = 1 + ) + ORDER BY RANDOM() + LIMIT ? + `), + + topicTasksAny: db.prepare(` + SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, + answer, solution_html, topic, subtopic, difficulty + FROM exam_tasks t + WHERE t.exam_key = ? AND t.subtopic = ? + AND t.task_type IN ('mc','open') + ORDER BY RANDOM() + LIMIT ? + `), + + getTopicMeta: db.prepare(` + SELECT slug, parent_slug, title, description + FROM exam_topics + WHERE exam_key = ? AND slug = ? + `), }; /* ── GET /api/exam-prep/tracks ── @@ -533,6 +595,85 @@ function stripPreview(html) { return text.length > 100 ? text.slice(0, 100) + '…' : text; } +/* ────────────────────────────────────────────────────────────────── + Topics (F7) — list + per-topic practice batch + ────────────────────────────────────────────────────────────────── */ + +/* ── GET /api/exam-prep/:examKey/topics ── + Returns sections (parents) with children + counts and user accuracy. */ +// @public-by-design: router-level authMiddleware (line 6) covers this route +router.get('/:examKey/topics', (req, res) => { + const { examKey } = req.params; + if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' }); + + const rows = SQL.listTopicsWithCounts.all(req.user.id, examKey, examKey); + + // Build {sections: [{slug,title,subtopics:[...]}]} + const byParent = new Map(); + const sections = []; + for (const r of rows) { + if (r.parent_slug == null) { + const section = { slug: r.slug, title: r.title, subtopics: [], total: 0, solved: 0 }; + sections.push(section); + byParent.set(r.slug, section); + } + } + for (const r of rows) { + if (r.parent_slug != null) { + const sec = byParent.get(r.parent_slug); + if (!sec) continue; + const accuracy = r.attempts > 0 ? Math.round((r.correct / r.attempts) * 100) : null; + const solvedPct = r.total > 0 ? Math.round((r.solved / r.total) * 100) : 0; + sec.subtopics.push({ + slug: r.slug, + title: r.title, + total: r.total, + solved: r.solved, + attempted: r.attempted, + attempts: r.attempts, + correct: r.correct, + accuracy, + solved_pct: solvedPct, + }); + sec.total += r.total; + sec.solved += r.solved; + } + } + + res.json({ sections }); +}); + +/* ── GET /api/exam-prep/:examKey/topics/:slug/tasks ── + Returns a batch of tasks for topic practice. + Query: ?count=10 (5-30) ?exclude_solved=1 (default 1) */ +// @public-by-design: router-level authMiddleware (line 6) covers this route +router.get('/:examKey/topics/:slug/tasks', (req, res) => { + const { examKey, slug } = req.params; + const meta = SQL.getTopicMeta.get(examKey, slug); + if (!meta) return res.status(404).json({ error: 'Topic not found' }); + if (meta.parent_slug == null) { + return res.status(400).json({ error: 'Cannot fetch tasks for a section — pick a specific subtopic' }); + } + + let count = Number(req.query.count) || 10; + count = Math.max(5, Math.min(count, 30)); + const excludeSolved = req.query.exclude_solved !== '0'; + + let rows; + if (excludeSolved) { + rows = SQL.topicTasksUnsolved.all(examKey, slug, req.user.id, count); + if (!rows.length) rows = SQL.topicTasksAny.all(examKey, slug, count); + } else { + rows = SQL.topicTasksAny.all(examKey, slug, count); + } + + res.json({ + topic: { slug: meta.slug, title: meta.title, parent: meta.parent_slug }, + session_id: Date.now(), + tasks: rows.map(shapeTask), + }); +}); + /* ────────────────────────────────────────────────────────────────── Study plan (F10) — by exam date ────────────────────────────────────────────────────────────────── */ diff --git a/frontend/css/exam-prep.css b/frontend/css/exam-prep.css index 833b8e7..669d486 100644 --- a/frontend/css/exam-prep.css +++ b/frontend/css/exam-prep.css @@ -901,6 +901,102 @@ } .dh-plan-derived-card b { color: var(--text); } +/* ═══════════════════════════════════════════════════════════════ + Topics view (`tp-*`) — used by exam-prep-topics.html (F7) + ═══════════════════════════════════════════════════════════════ */ + +.tp-sections { display: flex; flex-direction: column; gap: 16px; } + +.tp-section-head { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 8px; +} +.tp-section-head h3 { margin-bottom: 0; } +.tp-section-meta { + font-family: 'Manrope', sans-serif; font-size: .82rem; font-weight: 700; + color: var(--text-2); +} +.tp-section-bar { margin-bottom: 14px; } + +.tp-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; +} + +.tp-card { + display: flex; flex-direction: column; gap: 8px; + padding: 14px 16px; + border: 1.5px solid var(--border-h); + border-radius: 11px; + background: var(--surface); + text-decoration: none; color: var(--text); + transition: all .15s; +} +.tp-card:hover { + border-color: var(--violet); + box-shadow: 0 4px 14px rgba(155,93,229,.10); +} + +.tp-card-head { + display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; +} +.tp-card-title { + font-family: 'Unbounded', sans-serif; font-size: .88rem; font-weight: 700; + line-height: 1.3; +} +.tp-card-count { + font-family: 'Unbounded', sans-serif; font-size: .72rem; font-weight: 800; + color: var(--text-3); + padding: 2px 7px; border-radius: 5px; + background: rgba(15,23,42,.05); + white-space: nowrap; +} + +.tp-card-progress { + display: flex; align-items: center; gap: 8px; + font-size: .75rem; color: var(--text-3); +} +.tp-card-progress .ep-bar { flex: 1; margin-top: 0; } + +.tp-card-foot { + display: flex; align-items: center; justify-content: space-between; + margin-top: 4px; +} +.tp-card-accuracy { + font-family: 'Unbounded', sans-serif; font-weight: 800; font-size: .82rem; + padding: 3px 9px; border-radius: 5px; + background: rgba(15,23,42,.05); + color: var(--text-2); +} +.tp-card-accuracy.tp-good { background: rgba(6,214,160,.16); color: #06D6A0; } +.tp-card-accuracy.tp-warn { background: rgba(248,150,30,.14); color: #B45309; } +.tp-card-accuracy.tp-bad { background: rgba(230,57,70,.14); color: #E63946; } + +.tp-card-cta { + display: inline-flex; align-items: center; gap: 4px; + font-size: .75rem; font-weight: 700; color: var(--violet); +} +.tp-card-cta svg { width: 12px; height: 12px; } + +/* Topic practice header */ +.tp-back-bar { margin-bottom: 12px; } +.tp-back { + display: inline-flex; align-items: center; gap: 6px; + font-size: .85rem; font-weight: 600; color: var(--text-2); + text-decoration: none; + padding: 6px 10px; border-radius: 7px; + transition: background .12s; +} +.tp-back:hover { background: rgba(155,93,229,.06); color: var(--violet); } +.tp-back svg { width: 14px; height: 14px; } + +.tp-checkbox { + display: inline-flex; align-items: center; gap: 8px; + font-size: .85rem; color: var(--text-2); cursor: pointer; +} +.tp-checkbox input { accent-color: var(--violet); } + /* ── Mobile tweaks ─────────────────────────────────────────────── */ @media (max-width: 640px) { .ep-wrap { padding: 20px 16px 60px; } diff --git a/frontend/exam-prep-topics.html b/frontend/exam-prep-topics.html index 0248116..ce08349 100644 --- a/frontend/exam-prep-topics.html +++ b/frontend/exam-prep-topics.html @@ -8,6 +8,9 @@ + + +
В F6 проставим теги темам (LLM-классификация), а в F7 здесь появится список из ~25 подтем с точностью пользователя и кнопкой «Прорешать 20 задач».
+ +