48a73d9f8e
- 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>
107 lines
5.3 KiB
JavaScript
107 lines
5.3 KiB
JavaScript
'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);
|