diff --git a/backend/src/controllers/practiceController.js b/backend/src/controllers/practiceController.js index bcdcf0d..e8468db 100644 --- a/backend/src/controllers/practiceController.js +++ b/backend/src/controllers/practiceController.js @@ -14,12 +14,17 @@ const db = require('../db/db'); const MAX_SKILL = 120; // длина skill (TEXT) const MASTERY_STREAK = 5; // серия верных подряд для «освоено» +// Интервалы повторения (дни) по уровню Leitner-коробки box 0..5. +const INTERVAL_DAYS = [0, 1, 3, 7, 16, 30]; -/* GET /api/practice/progress — прогресс текущего ученика по всем навыкам. */ +/* GET /api/practice/progress — прогресс текущего ученика по всем навыкам. + * `due` (0/1) — навык пора повторить (срок прошёл или не назначен). */ function listProgress(req, res) { const uid = req.user.id; const rows = db.prepare(` - SELECT skill, solved, attempts, cur_streak, best_streak, mastered, updated_at + SELECT skill, solved, attempts, cur_streak, best_streak, mastered, box, due_at, + CASE WHEN due_at IS NULL OR due_at <= datetime('now') THEN 1 ELSE 0 END AS due, + updated_at FROM practice_progress WHERE user_id = ? ORDER BY updated_at DESC, id DESC @@ -41,15 +46,20 @@ function submitAttempt(req, res) { const correct = b.correct; const existing = db.prepare( - 'SELECT id, solved, attempts, cur_streak, best_streak, mastered FROM practice_progress WHERE user_id = ? AND skill = ?' + 'SELECT id, solved, attempts, cur_streak, best_streak, mastered, box FROM practice_progress WHERE user_id = ? AND skill = ?' ).get(uid, skill); + // Leitner: верно → box+1 (до 5), неверно → 0. Срок = сейчас + интервал(box). + const prevBox = existing ? (existing.box || 0) : 0; + const box = correct ? Math.min(prevBox + 1, 5) : 0; + const dueMod = '+' + INTERVAL_DAYS[box] + ' days'; + if (!existing) { const curStreak = correct ? 1 : 0; db.prepare(` - INSERT INTO practice_progress (user_id, skill, solved, attempts, cur_streak, best_streak, mastered, updated_at) - VALUES (?, ?, ?, 1, ?, ?, ?, datetime('now')) - `).run(uid, skill, correct ? 1 : 0, curStreak, curStreak, curStreak >= MASTERY_STREAK ? 1 : 0); + INSERT INTO practice_progress (user_id, skill, solved, attempts, cur_streak, best_streak, mastered, box, due_at, updated_at) + VALUES (?, ?, ?, 1, ?, ?, ?, ?, datetime('now', ?), datetime('now')) + `).run(uid, skill, correct ? 1 : 0, curStreak, curStreak, curStreak >= MASTERY_STREAK ? 1 : 0, box, dueMod); } else { const curStreak = correct ? (existing.cur_streak + 1) : 0; const bestStreak = Math.max(existing.best_streak || 0, curStreak); @@ -57,14 +67,18 @@ function submitAttempt(req, res) { db.prepare(` UPDATE practice_progress SET solved = solved + ?, attempts = attempts + 1, - cur_streak = ?, best_streak = ?, mastered = ?, updated_at = datetime('now') + cur_streak = ?, best_streak = ?, mastered = ?, box = ?, due_at = datetime('now', ?), + updated_at = datetime('now') WHERE id = ? - `).run(correct ? 1 : 0, curStreak, bestStreak, mastered, existing.id); + `).run(correct ? 1 : 0, curStreak, bestStreak, mastered, box, dueMod, existing.id); } - const row = db.prepare( - 'SELECT skill, solved, attempts, cur_streak, best_streak, mastered, updated_at FROM practice_progress WHERE user_id = ? AND skill = ?' - ).get(uid, skill); + const row = db.prepare(` + SELECT skill, solved, attempts, cur_streak, best_streak, mastered, box, due_at, + CASE WHEN due_at IS NULL OR due_at <= datetime('now') THEN 1 ELSE 0 END AS due, + updated_at + FROM practice_progress WHERE user_id = ? AND skill = ? + `).get(uid, skill); res.json({ ok: true, progress: row, masteryStreak: MASTERY_STREAK }); } diff --git a/backend/src/db/migrations/082_practice_sr.sql b/backend/src/db/migrations/082_practice_sr.sql new file mode 100644 index 0000000..4b6a563 --- /dev/null +++ b/backend/src/db/migrations/082_practice_sr.sql @@ -0,0 +1,13 @@ +-- ═══════════════════════════════════════════════════════════════ +-- 082: SR-поля тренажёра (интервальное повторение по навыкам, Фаза 2). +-- +-- К practice_progress добавляем Leitner-«коробку» и срок следующего показа: +-- box — уровень 0..5 (выше = увереннее освоено, реже повторяем). +-- due_at — когда навык снова стоит показать (datetime). NULL = «как можно скорее». +-- На верный ответ box растёт и срок отодвигается; на ошибку box сбрасывается в 0 +-- и срок = сейчас (навык всплывёт первым при следующем заходе). Адаптивный +-- подборщик на клиенте показывает «просроченные» навыки (due_at <= now) раньше. +-- ═══════════════════════════════════════════════════════════════ + +ALTER TABLE practice_progress ADD COLUMN box INTEGER NOT NULL DEFAULT 0; +ALTER TABLE practice_progress ADD COLUMN due_at TEXT; diff --git a/backend/tests/practice.test.js b/backend/tests/practice.test.js index d4fb6ca..480172b 100644 --- a/backend/tests/practice.test.js +++ b/backend/tests/practice.test.js @@ -79,6 +79,18 @@ describe('/api/practice progress', () => { assert.equal(miss.body.progress.mastered, 1, 'mastered is sticky'); }); + it('SR: box растёт на верный ответ и сбрасывается на ошибку; due отражает срок', async () => { + const sk = 'sr-skill'; + const c1 = await inject('POST', '/api/practice/attempt', { skill: sk, correct: true }, token); + assert.equal(c1.body.progress.box, 1, 'box=1 после первого верного'); + assert.equal(c1.body.progress.due, 0, 'свежий навык не просрочен (срок в будущем)'); + const c2 = await inject('POST', '/api/practice/attempt', { skill: sk, correct: true }, token); + assert.equal(c2.body.progress.box, 2, 'box растёт на следующем верном'); + const w = await inject('POST', '/api/practice/attempt', { skill: sk, correct: false }, token); + assert.equal(w.body.progress.box, 0, 'ошибка сбрасывает box в 0'); + assert.equal(w.body.progress.due, 1, 'после ошибки навык сразу к повторению (due=1)'); + }); + it('progress is per-user (другой ученик начинает с нуля)', async () => { const other = (await getToken('student')).token; const res = await inject('POST', '/api/practice/attempt', { skill: SKILL, correct: true }, other); diff --git a/frontend/js/trainer/adaptive.js b/frontend/js/trainer/adaptive.js new file mode 100644 index 0000000..8ff90fb --- /dev/null +++ b/frontend/js/trainer/adaptive.js @@ -0,0 +1,106 @@ +'use strict'; +/* ════════════════════════════════════════════════════════════════════════ + TrainerAdaptive — адаптивный подбор навыка + очередь повторения (Фаза 2). + + Чистая логика без DOM/сети (тестируется headless). Решает «что дать дальше», + ведя ученика от простого к сложному и возвращая то, в чём он ошибался. + + Приоритет nextSkill(): + 1) In-session повтор: навык, который провалили В ЭТОЙ сессии и подошёл срок + (due <= answered). Это лёгкое интервальное повторение внутри сессии. + 2) Кросс-сессионный повтор: навык с серверным флагом due (срок Leitner прошёл). + 3) Прогрессия: первый по порядку НЕ освоенный навык (simple → complex). + 4) Удержание: всё освоено → навык с наименьшей коробкой (box), затем по порядку. + На каждом шаге избегаем немедленного повтора последнего навыка, если есть выбор. + + API (window.TrainerAdaptive): + nextSkill({ ordered, progress, queue, answered, last }) -> skillId | null + onWrong(queue, skill, answered) -> queue' (поставить навык на повтор) + onCorrect(queue, skill) -> queue' (снять навык с повтора) + sessionStats(events) -> { total, correct, accuracy, skills, weak } + ════════════════════════════════════════════════════════════════════════ */ +(function (global) { + + var GAP_BASE = 2; // через сколько задач навык всплывёт после ошибки + var GAP_MAX = 8; + + function nextSkill(opts) { + opts = opts || {}; + var ordered = opts.ordered || []; + var prog = opts.progress || {}; + var queue = opts.queue || []; + var answered = opts.answered || 0; + var last = opts.last || null; + if (!ordered.length) return null; + + var ids = ordered.map(function (g) { return g.id; }); + function known(id) { return ids.indexOf(id) !== -1; } + function pos(id) { return ids.indexOf(id); } + function notLast(id) { return id !== last; } + + // 1) In-session повтор: подошедшие по сроку записи очереди. + var dueQ = queue.filter(function (q) { return q.due <= answered && known(q.skill); }) + .sort(function (a, b) { return a.due - b.due; }); + var pick1 = dueQ.filter(function (q) { return notLast(q.skill); })[0] || (dueQ.length === 1 ? dueQ[0] : null); + if (pick1) return pick1.skill; + + // 2) Кросс-сессионный повтор: серверный due (срок Leitner прошёл). + var overdue = ordered.filter(function (g) { var p = prog[g.id]; return p && p.due && notLast(g.id); }); + if (overdue.length) return overdue[0].id; + + // 3) Прогрессия: первый по порядку не освоенный. + var prog1 = ordered.filter(function (g) { var p = prog[g.id]; return !(p && p.mastered) && notLast(g.id); }); + if (prog1.length) return prog1[0].id; + + // 4) Удержание: всё освоено — наименьшая коробка, затем по порядку. + var pool = ordered.filter(function (g) { return notLast(g.id); }); + if (!pool.length) pool = ordered.slice(); + pool.sort(function (a, b) { + var ba = (prog[a.id] && prog[a.id].box) || 0; + var bb = (prog[b.id] && prog[b.id].box) || 0; + return ba - bb || (pos(a.id) - pos(b.id)); + }); + return pool.length ? pool[0].id : ids[0]; + } + + function onWrong(queue, skill, answered) { + queue = queue || []; + var existing = queue.filter(function (q) { return q.skill === skill; })[0]; + var gap = existing ? Math.min((existing.gap || GAP_BASE) + 2, GAP_MAX) : GAP_BASE; + var rest = queue.filter(function (q) { return q.skill !== skill; }); + rest.push({ skill: skill, due: (answered || 0) + gap, gap: gap }); + return rest; + } + + function onCorrect(queue, skill) { + return (queue || []).filter(function (q) { return q.skill !== skill; }); + } + + function sessionStats(events) { + events = events || []; + var total = events.length; + var correct = 0, bySkill = {}; + events.forEach(function (e) { + if (e.correct) correct++; + var s = bySkill[e.skill] || (bySkill[e.skill] = { c: 0, n: 0 }); + s.n++; if (e.correct) s.c++; + }); + var skills = Object.keys(bySkill); + var weak = skills.filter(function (s) { return bySkill[s].c < bySkill[s].n; }); + return { + total: total, + correct: correct, + accuracy: total ? Math.round(100 * correct / total) : 0, + skills: skills, + weak: weak + }; + } + + global.TrainerAdaptive = { + nextSkill: nextSkill, + onWrong: onWrong, + onCorrect: onCorrect, + sessionStats: sessionStats + }; + +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/trainer.html b/frontend/trainer.html index 47e2e28..57b5bec 100644 --- a/frontend/trainer.html +++ b/frontend/trainer.html @@ -101,6 +101,29 @@ .tr-badge-n { margin-left: 7px; font-size: .7rem; font-weight: 800; color: #94a3b8; background: rgba(148,163,184,0.16); border-radius: 99px; padding: 1px 7px; } .tr-chip.on .tr-badge-n { color: #e0e7ff; background: rgba(255,255,255,0.2); } + /* ── режим (умная тренировка) ── */ + .tr-mode { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; } + .tr-mode-btn { + font: inherit; font-size: .85rem; font-weight: 700; cursor: pointer; display: inline-flex; align-items: center; gap: 7px; + padding: 8px 14px; border-radius: 99px; border: 1px solid rgba(148,163,184,0.32); background: #fff; color: #475569; transition: .15s; + } + .tr-mode-btn .ic { width: 16px; height: 16px; } + .tr-mode-btn:hover { border-color: #818cf8; color: #4f46e5; } + .tr-mode-btn.on { background: #6366f1; border-color: #6366f1; color: #fff; box-shadow: 0 6px 16px rgba(99,102,241,0.28); } + .tr-session { font-size: .85rem; font-weight: 700; color: #6366f1; } + + /* ── итог сессии ── */ + .tr-summary { + background: #fff; border: 1px solid rgba(148,163,184,0.22); border-radius: 18px; + padding: 26px; box-shadow: 0 14px 40px rgba(15,23,42,0.06); text-align: center; + } + .tr-sum-h { margin: 0 0 16px; font-family: 'Manrope', sans-serif; font-weight: 800; font-size: 1.3rem; color: #1e293b; } + .tr-sum-row { display: inline-flex; flex-direction: column; align-items: center; margin: 0 16px 10px; } + .tr-sum-row b { font-size: 1.7rem; font-weight: 800; color: #4f46e5; font-family: 'Manrope', sans-serif; line-height: 1.1; } + .tr-sum-row span { font-size: .74rem; color: #94a3b8; text-transform: uppercase; letter-spacing: .04em; } + .tr-sum-weak { margin: 8px 0 20px; color: #d97706; font-weight: 600; font-size: .92rem; } + .tr-sum-weak.tr-sum-good { color: #16a34a; } + /* ── статистика ── */ .tr-stats { display: flex; gap: 20px; justify-content: center; margin: 22px 0 4px; } .tr-stat { text-align: center; } @@ -120,6 +143,14 @@
Задачи генерируются автоматически и проверяются мгновенно. Решай по одной — бесконечно.
+
+ + +
+
@@ -154,6 +185,8 @@ + +
0решено
0серия
@@ -172,6 +205,7 @@ + @@ -192,8 +226,9 @@ }).catch(function () {}); } - var TE = window.TrainerEngine, TG = window.TrainerGenerators; + var TE = window.TrainerEngine, TG = window.TrainerGenerators, TA = window.TrainerAdaptive; var gens = TG.list(); + var ordered = gens; // прогрессия = порядок объявления (темы по order, навыки по order) var ICON = { ok: '', @@ -227,9 +262,13 @@ var curGen = skillsOf(curTopic)[0] || gens[0]; var cur = null; var solved = 0, streak = 0; - var answered = false; // задача закрыта (верно/решение показано) → «Проверить» становится «Дальше» + var answered = false; // задача решена (верно/неверно/показано решение) → «Проверить» становится «Дальше» var prog = {}; // skill → строка прогресса с сервера + // адаптивная сессия + var smart = true, GOAL = 10; + var sessAnswered = 0, sessEvents = [], reviewQ = [], summaryShown = false; + function topicMastered(topicKey) { var ss = skillsOf(topicKey); return ss.length > 0 && ss.every(function (g) { var p = prog[skillKey(g)]; return p && p.mastered; }); @@ -301,40 +340,92 @@ var steps = (cur.solution || []).map(function (st, i) { return stepHtml(st, i + 1); }).join(''); return '

' + title + '

' + (steps || '
x = ' + esc(fmt(cur.answer)) + '
'); } - - function revealAnswer(giveUp) { + function revealSolution() { var s = $('tr-solution'); s.innerHTML = solutionHtml('Решение'); s.style.display = 'block'; - if (giveUp) { + } + + // ── адаптивная сессия ── + function updateSession() { + $('tr-session').textContent = smart ? ('Сессия: ' + Math.min(sessAnswered, GOAL) + ' / ' + GOAL) : ''; + } + function pickNext(lastSkill) { + if (!TA) return; + var last = (lastSkill !== undefined) ? lastSkill : (curGen ? skillKey(curGen) : null); + var id = TA.nextSkill({ ordered: ordered, progress: prog, queue: reviewQ, answered: sessAnswered, last: last }); + var g = id ? gens.filter(function (x) { return skillKey(x) === id; })[0] : null; + if (g) { curGen = g; curTopic = g.topic; renderTopics(); renderSkills(); } + } + function recordAnswer(correct) { + var sk = skillKey(curGen); + sessEvents.push({ skill: sk, correct: correct }); + sessAnswered++; + if (TA) reviewQ = correct ? TA.onCorrect(reviewQ, sk) : TA.onWrong(reviewQ, sk, sessAnswered); + updateSession(); + } + function advance() { + if (smart && sessAnswered >= GOAL && !summaryShown) { showSummary(); return; } + if (smart) pickNext(); + newProblem(); + } + function showSummary() { + summaryShown = true; + var st = TA ? TA.sessionStats(sessEvents) : { total: sessAnswered, correct: solved, accuracy: 0, skills: [], weak: [] }; + var weak = st.weak.map(function (s) { var g = gens.filter(function (x) { return skillKey(x) === s; })[0]; return g ? g.title : s; }); + $('tr-summary').innerHTML = + '

Итог сессии

' + + '
' + st.correct + ' / ' + st.total + 'верно
' + + '
' + st.accuracy + '%точность
' + + '
' + st.skills.length + 'навыков
' + + (weak.length ? '
Стоит повторить: ' + esc(weak.join(', ')) + '
' + : '
Отличная сессия — без ошибок!
') + + ''; + $('tr-card').style.display = 'none'; + $('tr-summary').style.display = 'block'; + $('tr-sum-go').addEventListener('click', function () { + $('tr-summary').style.display = 'none'; + $('tr-card').style.display = ''; + sessAnswered = 0; sessEvents = []; summaryShown = false; + updateSession(); + if (smart) pickNext(); + newProblem(); + }); + } + + // «Решение» до ответа = сдаться (засчитывается как неверно один раз) + function revealAnswer(giveUp) { + revealSolution(); + if (giveUp && !answered) { streak = 0; $('tr-input').disabled = true; var fb = $('tr-feedback'); fb.className = 'tr-feedback'; setMath(fb, 'x = ' + cur.answer, 'Ответ: x = ' + fmt(cur.answer), false); setMode(true); + recordAnswer(false); submitAttempt(false); updateStats(); - submitAttempt(false); } } function check() { - if (answered) { newProblem(); return; } + if (answered) { advance(); return; } var r = TE.checkStudentAnswer(cur, $('tr-input').value); var fb = $('tr-feedback'); if (r.reason === 'empty' || r.reason === 'parse' || r.reason === 'nan') { - fb.className = 'tr-feedback warn'; fb.textContent = r.message; return; + fb.className = 'tr-feedback warn'; fb.textContent = r.message; return; // не решено, можно поправить ввод } + $('tr-input').disabled = true; + setMode(true); if (r.ok) { solved++; streak++; fb.className = 'tr-feedback ok'; fb.innerHTML = ICON.ok + ' Верно! ' + (kat('x = ' + cur.answer, false) || esc('x = ' + fmt(cur.answer))); - $('tr-input').disabled = true; - setMode(true); - submitAttempt(true); + recordAnswer(true); submitAttempt(true); } else { streak = 0; - fb.className = 'tr-feedback bad'; fb.innerHTML = ICON.bad + ' Пока неверно — попробуй ещё раз.'; - submitAttempt(false); + fb.className = 'tr-feedback bad'; fb.innerHTML = ICON.bad + ' Неверно. Разбери решение и реши похожую.'; + recordAnswer(false); submitAttempt(false); + revealSolution(); } updateStats(); } @@ -356,6 +447,11 @@ curGen = ss[+b.getAttribute('data-si')] || curGen; renderSkills(); newProblem(); }); + $('tr-smart-btn').addEventListener('click', function () { + smart = !smart; + $('tr-smart-btn').classList.toggle('on', smart); + updateSession(); + }); $('tr-check').addEventListener('click', check); $('tr-skip').addEventListener('click', newProblem); $('tr-hint').addEventListener('click', function () { @@ -367,9 +463,9 @@ $('tr-solve').addEventListener('click', function () { if (cur) revealAnswer(true); }); $('tr-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); check(); } }); - $('tr-note').textContent = gens.length + ' навыков в ' + topics.length + ' темах · ответ проверяется подстановкой (5, x=5, 10/2, 2+3) · прогресс сохраняется.'; + $('tr-note').textContent = gens.length + ' навыков в ' + topics.length + ' темах · умная тренировка ведёт от простого к сложному и возвращает ошибки · прогресс сохраняется.'; - // загрузка прогресса → старт (авто-выбор первой неосвоенной темы и навыка) + // загрузка прогресса → старт (умный режим: адаптивный первый навык) function boot() { for (var ti = 0; ti < topics.length; ti++) { if (!topicMastered(topics[ti].key)) { curTopic = topics[ti].key; break; } @@ -377,7 +473,8 @@ var ss = skillsOf(curTopic); curGen = ss[0] || gens[0]; for (var si = 0; si < ss.length; si++) { var p = prog[skillKey(ss[si])]; if (!(p && p.mastered)) { curGen = ss[si]; break; } } - renderTopics(); renderSkills(); newProblem(); + if (smart) pickNext(null); // адаптивный первый навык (last=null — можно взять текущий) + renderTopics(); renderSkills(); updateSession(); newProblem(); } (LS.practiceProgressList ? LS.practiceProgressList() : Promise.resolve(null)) .then(function (r) { if (r && r.progress) r.progress.forEach(function (row) { prog[row.skill] = row; }); }) diff --git a/plans/ai-trainer/PLAN.md b/plans/ai-trainer/PLAN.md index 1a11772..dad928d 100644 --- a/plans/ai-trainer/PLAN.md +++ b/plans/ai-trainer/PLAN.md @@ -53,9 +53,19 @@ UI: выбор темы (вкладки) → навыки (чипы) с бейд - **Acceptance:** ≥3 темы × ≥3 навыка, у каждого generateBatch(50) даёт 50 разных корректных задач; solvability-смоук на сетке параметров. -## Phase 2 — Адаптивность и интервальное повторение +## Phase 2 — Адаптивность и интервальное повторение — DONE -**Цель:** вести ученика, а не давать случайное. +**Сделано:** `frontend/js/trainer/adaptive.js` (`window.TrainerAdaptive`, чистая логика) — +`nextSkill` (приоритет: in-session повтор → серверный due → прогрессия → удержание по +box), `onWrong/onCorrect` (in-session очередь повторения), `sessionStats`. **Умная +тренировка** на странице (по умолчанию вкл, тумблер): авто-подбор навыка, ведёт от +простого к сложному, возвращает ошибки; сессия из 10 задач с **итогом** (верно/точность/ +навыки/«стоит повторить»). Неверный ответ авто-показывает решение. Сервер: SR-поля +`box`+`due_at` на `practice_progress` (мигр.**082**, Leitner-интервалы 0/1/3/7/16/30 дней), +`listProgress` отдаёт `box/due_at/due`. Смоуки: adaptive 12/12, страница 23/23, +practice.test.js 11/11 (+SR box/due). + +**Цель (исходная):** вести ученика, а не давать случайное. - Диагностика на входе (по 1–2 задачи на навык) → стартовый уровень. - Подбор следующего навыка по мастерству (escalate при серии, откат при ошибках).