feat(trainer): P2 — умная тренировка, интервальное повторение, итог сессии
- adaptive.js (TrainerAdaptive): nextSkill (in-session повтор → серверный due → прогрессия → удержание), onWrong/onCorrect (очередь повторения), 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); план P2 → DONE Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
+113
-16
@@ -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 @@
|
||||
<div class="tr-sub">Задачи генерируются автоматически и проверяются мгновенно. Решай по одной — бесконечно.</div>
|
||||
</div>
|
||||
|
||||
<div class="tr-mode">
|
||||
<button class="tr-mode-btn on" id="tr-smart-btn" type="button">
|
||||
<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.8 4.6L18.5 9l-4.7 1.4L12 15l-1.8-4.6L5.5 9l4.7-1.4z"/><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7z"/></svg>
|
||||
Умная тренировка
|
||||
</button>
|
||||
<span class="tr-session" id="tr-session"></span>
|
||||
</div>
|
||||
|
||||
<div class="tr-topics" id="tr-topics"></div>
|
||||
<div class="tr-skills" id="tr-skills"></div>
|
||||
|
||||
@@ -154,6 +185,8 @@
|
||||
<div class="tr-solution" id="tr-solution" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<div class="tr-summary" id="tr-summary" style="display:none"></div>
|
||||
|
||||
<div class="tr-stats">
|
||||
<div class="tr-stat"><b id="tr-solved">0</b><span>решено</span></div>
|
||||
<div class="tr-stat"><b id="tr-streak">0</b><span>серия</span></div>
|
||||
@@ -172,6 +205,7 @@
|
||||
<script src="/js/labs/_sim_expr.js"></script>
|
||||
<script src="/js/trainer/_trainer_engine.js"></script>
|
||||
<script src="/js/trainer/generators.js"></script>
|
||||
<script src="/js/trainer/adaptive.js"></script>
|
||||
<!-- KaTeX для рендера уравнений и шагов решения -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
|
||||
@@ -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: '<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
|
||||
@@ -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 '<h4>' + title + '</h4>' + (steps || '<div class="tr-step"><span class="tr-step-math">x = ' + esc(fmt(cur.answer)) + '</span></div>');
|
||||
}
|
||||
|
||||
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 =
|
||||
'<h3 class="tr-sum-h">Итог сессии</h3>' +
|
||||
'<div class="tr-sum-row"><b>' + st.correct + ' / ' + st.total + '</b><span>верно</span></div>' +
|
||||
'<div class="tr-sum-row"><b>' + st.accuracy + '%</b><span>точность</span></div>' +
|
||||
'<div class="tr-sum-row"><b>' + st.skills.length + '</b><span>навыков</span></div>' +
|
||||
(weak.length ? '<div class="tr-sum-weak">Стоит повторить: ' + esc(weak.join(', ')) + '</div>'
|
||||
: '<div class="tr-sum-weak tr-sum-good">Отличная сессия — без ошибок!</div>') +
|
||||
'<button class="tr-btn tr-primary" id="tr-sum-go" type="button">Продолжить</button>';
|
||||
$('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 + ' <span>Верно!</span> ' + (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; }); })
|
||||
|
||||
@@ -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 при серии, откат при ошибках).
|
||||
|
||||
Reference in New Issue
Block a user