'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);