feat(exam-prep F8): слабые темы на дашборде + strategy=weak в тренажёре
This commit is contained in:
@@ -317,6 +317,64 @@ const SQL = {
|
||||
FROM exam_topics
|
||||
WHERE exam_key = ? AND slug = ?
|
||||
`),
|
||||
|
||||
/* ── Weak topics (F8) ────────────────────────────────────────
|
||||
Subtopics where the user has ≥3 attempts and accuracy < 60%.
|
||||
Sorted by (accuracy ASC, attempts DESC). */
|
||||
weakTopics: db.prepare(`
|
||||
SELECT
|
||||
tp.slug, tp.title, tp.parent_slug,
|
||||
COUNT(a.id) AS attempts,
|
||||
COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct,
|
||||
COUNT(DISTINCT t.id) AS total_tasks,
|
||||
COUNT(DISTINCT CASE WHEN a.is_correct = 1 THEN a.exam_task_id END) AS solved_tasks
|
||||
FROM exam_topics tp
|
||||
JOIN exam_tasks t ON t.subtopic = tp.slug AND t.exam_key = tp.exam_key
|
||||
JOIN exam_attempts a ON a.exam_task_id = t.id
|
||||
AND a.user_id = ?
|
||||
AND a.is_correct IS NOT NULL
|
||||
WHERE tp.exam_key = ? AND tp.parent_slug IS NOT NULL
|
||||
GROUP BY tp.slug
|
||||
HAVING attempts >= 3
|
||||
AND (CAST(correct AS REAL) / attempts) < 0.6
|
||||
ORDER BY (CAST(correct AS REAL) / attempts) ASC, attempts DESC
|
||||
LIMIT 3
|
||||
`),
|
||||
|
||||
weakTopicSlugs: db.prepare(`
|
||||
SELECT tp.slug
|
||||
FROM exam_topics tp
|
||||
JOIN exam_tasks t ON t.subtopic = tp.slug AND t.exam_key = tp.exam_key
|
||||
JOIN exam_attempts a ON a.exam_task_id = t.id AND a.user_id = ? AND a.is_correct IS NOT NULL
|
||||
WHERE tp.exam_key = ? AND tp.parent_slug IS NOT NULL
|
||||
GROUP BY tp.slug
|
||||
HAVING COUNT(a.id) >= 3
|
||||
AND (CAST(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END) AS REAL) / COUNT(a.id)) < 0.6
|
||||
ORDER BY (CAST(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END) AS REAL) / COUNT(a.id)) ASC,
|
||||
COUNT(a.id) DESC
|
||||
LIMIT 3
|
||||
`),
|
||||
|
||||
/* Practice batch — WEAK strategy: pick random tasks from top-3 weak subtopics,
|
||||
skipping tasks the user has already solved correctly. */
|
||||
weakBatchTasks: (examKey, slugs, userId, count) => {
|
||||
if (!slugs.length) return [];
|
||||
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
|
||||
FROM exam_tasks t
|
||||
WHERE t.exam_key = ?
|
||||
AND t.subtopic IN (${ph})
|
||||
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 ?
|
||||
`).all(examKey, ...slugs, userId, count);
|
||||
},
|
||||
};
|
||||
|
||||
/* ── GET /api/exam-prep/tracks ──
|
||||
@@ -435,11 +493,26 @@ router.get('/:examKey/practice/next', (req, res) => {
|
||||
let count = Number(req.query.count) || 10;
|
||||
count = Math.max(1, Math.min(count, 30));
|
||||
|
||||
const strategy = req.query.strategy === 'unsolved' ? 'unsolved' : 'random';
|
||||
const strategyRaw = req.query.strategy;
|
||||
let strategy = ['unsolved', 'weak', 'random'].includes(strategyRaw) ? strategyRaw : 'random';
|
||||
let rows;
|
||||
if (strategy === 'unsolved') {
|
||||
let weakSlugs = null;
|
||||
|
||||
if (strategy === 'weak') {
|
||||
weakSlugs = SQL.weakTopicSlugs.all(req.user.id, examKey).map(r => r.slug);
|
||||
if (weakSlugs.length === 0) {
|
||||
// No weak topics yet → fall back to unsolved so the button still works
|
||||
rows = SQL.practiceUnsolved.all(examKey, req.user.id, count);
|
||||
strategy = 'unsolved-fallback';
|
||||
} else {
|
||||
rows = SQL.weakBatchTasks(examKey, weakSlugs, req.user.id, count);
|
||||
if (!rows.length) {
|
||||
// weak topics exist but all unsolved tasks exhausted → fallback to any from those topics
|
||||
rows = SQL.weakBatchTasks(examKey, weakSlugs, -1 /* never matches */, count);
|
||||
}
|
||||
}
|
||||
} else if (strategy === 'unsolved') {
|
||||
rows = SQL.practiceUnsolved.all(examKey, req.user.id, count);
|
||||
// Fallback: if user has solved everything, supply random anyway so UX isn't a dead-end.
|
||||
if (!rows.length) rows = SQL.practiceRandom.all(examKey, count);
|
||||
} else {
|
||||
rows = SQL.practiceRandom.all(examKey, count);
|
||||
@@ -447,7 +520,8 @@ router.get('/:examKey/practice/next', (req, res) => {
|
||||
|
||||
res.json({
|
||||
strategy,
|
||||
session_id: Date.now(), // ephemeral grouping key for these N attempts
|
||||
weak_slugs: weakSlugs,
|
||||
session_id: Date.now(),
|
||||
tasks: rows.map(shapeTask),
|
||||
});
|
||||
});
|
||||
@@ -556,6 +630,7 @@ router.get('/:examKey/dashboard', (req, res) => {
|
||||
const recent = SQL.recentAttempts.all(req.user.id, examKey, 8);
|
||||
const heat = SQL.activityHeatmap.all(req.user.id, examKey, twentyEightDaysAgo);
|
||||
const mocks = SQL.recentMocks.all(req.user.id, examKey, 3);
|
||||
const weak = SQL.weakTopics.all(req.user.id, examKey);
|
||||
|
||||
res.json({
|
||||
streak,
|
||||
@@ -564,6 +639,16 @@ router.get('/:examKey/dashboard', (req, res) => {
|
||||
correct: acc7.correct,
|
||||
pct: acc7.attempts ? Math.round((acc7.correct / acc7.attempts) * 100) : null,
|
||||
},
|
||||
weak_topics: weak.map(w => ({
|
||||
slug: w.slug,
|
||||
title: w.title,
|
||||
parent: w.parent_slug,
|
||||
attempts: w.attempts,
|
||||
correct: w.correct,
|
||||
accuracy: Math.round((w.correct / w.attempts) * 100),
|
||||
total_tasks: w.total_tasks,
|
||||
solved_tasks: w.solved_tasks,
|
||||
})),
|
||||
recent_attempts: recent.map(r => ({
|
||||
task_id: r.exam_task_id,
|
||||
variant: r.variant,
|
||||
|
||||
Reference in New Issue
Block a user