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:
@@ -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);
|
||||
Reference in New Issue
Block a user