feat(flashcards): learning-steps SR — повторный показ «Снова» в сессии, лимит новых карт/день

Tier-1 апгрейд интервального повторения:
- schedule() с состояниями learning/relearning/review вместо плоского sm2():
  новая карта проходит шаги [1,10] мин, «Снова» возвращает на шаг 0 (минуты),
  «Знаю» продвигает шаг → выпуск (1д), «Легко» выпускает сразу (4д); зрелая
  «Снова» = lapse → relearning (ef−0.2, ×0.5).
- study-сессия: динамическая очередь — недоученная карта (graduated=false)
  возвращается через 3 карты и показывается снова в той же сессии.
- лимит новых карт/день (decks.new_per_day, деф.20) в getStudySession и бейдже.
- превью кнопок fcPreview() показывает минуты/дни, зеркало серверной логики.
- миграция 074: state/learning_step/lapses/created_at + new_per_day + индексы.
- тесты SRS 9/9 (шаги, lapse, лимит новых).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-13 13:10:00 +03:00
parent cbb6edf372
commit 5c01a5c7ed
4 changed files with 389 additions and 85 deletions
+152 -57
View File
@@ -10,52 +10,116 @@ function safeImg(url) {
return /^\/uploads\/flashcards\/[A-Za-z0-9._-]+$/.test(u) ? u : '';
}
/* ── SM-2 (Anki-стиль: кнопки различаются) ─────────────────────────────────
/* ── Планировщик с learning-steps (Anki-стиль) ─────────────────────────────
quality: 0/2 = Снова, 3 = Трудно, 4 = Знаю, 5 = Легко.
В отличие от чистого SM-2, интервал зависит от оценки уже на первых повторах:
на новой карте Снова/Трудно/Знаю → 1д, Легко → 4д; на зрелых — Трудно ×1.2,
Знаю ×ef, Легко ×ef×1.3 (easy-бонус). ВАЖНО: клиентское превью
fcNextInterval() в flashcards.html — точная копия этой логики интервалов.
─────────────────────────────────────────────────────────────────────── */
const FC_HARD_MULT = 1.2, FC_EASY_BONUS = 1.3;
function sm2(easeFactor, intervalDays, repetitions, quality) {
let ef = easeFactor;
let n = repetitions;
let iv = intervalDays;
if (quality < 3) {
n = 0;
iv = 1; // Снова — сброс
} else {
if (n === 0) {
iv = (quality === 5) ? 4 : 1; // выпуск: Легко 4д, иначе 1д
} else if (n === 1) {
iv = (quality === 3) ? 3 : (quality === 4) ? 6 : Math.round(6 * FC_EASY_BONUS);
Карточка живёт в одном из состояний:
learning — новая, проходит шаги обучения FC_LEARN_STEPS (минуты);
relearning — зрелая, провалена → снова шаги FC_RELEARN_STEPS;
review — выпущена, день-интервалы SM-2.
На learning/relearning «Снова» возвращает на шаг 0 (минуты!) и карта
ПОВТОРНО показывается в той же сессии (re-queue делает клиент по флагу
graduated=false). «Знаю» продвигает шаг; пройдя последний — выпуск в review
(FC_GRAD_IV дн.), «Легко» выпускает сразу (FC_EASY_IV дн.). На review «Снова»
= lapse → relearning (ef 0.2, интервал ×0.5). Зрелые интервалы: Трудно ×1.2,
Знаю ×ef, Легко ×ef×1.3.
Под-дневные интервалы хранятся в due_at, поэтому interval_days — целые дни
последнего выпуска. ВАЖНО: клиентское превью fcPreview() в flashcards.html —
зеркало интервальной части этой логики.
─────────────────────────────────────────────────────────────────────── */
const FC_LEARN_STEPS = [1, 10]; // минуты — шаги новой карты
const FC_RELEARN_STEPS = [10]; // минуты — шаги после провала зрелой
const FC_GRAD_IV = 1; // дней — выпуск через «Знаю»
const FC_EASY_IV = 4; // дней — выпуск через «Легко»
const FC_HARD_MULT = 1.2, FC_EASY_BONUS = 1.3, FC_MIN_EF = 1.3;
/* prev: { state, learning_step, ease_factor, interval_days, repetitions, lapses }.
Возвращает следующее расписание + dueInSec (для клиентского re-queue) и
graduated (карта в review → из сессии можно убрать). */
function schedule(prev, quality, nowMs) {
let state = prev.state || 'new';
let step = prev.learning_step || 0;
let ef = prev.ease_factor || 2.5;
let iv = prev.interval_days || 0;
let reps = prev.repetitions || 0;
let lapses = prev.lapses || 0;
let dueSec;
const learning = (state === 'new' || state === 'learning' || state === 'relearning');
if (learning) {
const steps = (state === 'relearning') ? FC_RELEARN_STEPS : FC_LEARN_STEPS;
if (quality === 5) { // Легко → выпуск сразу
state = 'review'; step = 0; reps = Math.max(reps, 1);
iv = FC_EASY_IV; dueSec = iv * 86400;
} else if (quality < 3) { // Снова → первый шаг (минуты)
if (state === 'new') state = 'learning';
step = 0; dueSec = steps[0] * 60;
} else if (quality === 3) { // Трудно → повтор текущего шага
if (state === 'new') state = 'learning';
dueSec = steps[Math.min(step, steps.length - 1)] * 60;
} else { // Знаю → следующий шаг
if (state === 'new') state = 'learning';
step += 1;
if (step >= steps.length) { // прошёл все шаги → выпуск
const grad = (state === 'relearning') ? Math.max(1, Math.round(iv)) : FC_GRAD_IV;
state = 'review'; step = 0; reps = Math.max(reps, 1);
iv = grad; dueSec = iv * 86400;
} else {
dueSec = steps[step] * 60;
}
}
ef = Math.max(FC_MIN_EF, ef); // ease меняем только на review-ответах
} else { // state === 'review'
if (quality < 3) { // провал → relearning
lapses += 1;
ef = Math.max(FC_MIN_EF, ef - 0.20);
iv = Math.max(1, Math.round(iv * 0.5)); // целевой интервал после переучивания
reps = 0; state = 'relearning'; step = 0;
dueSec = FC_RELEARN_STEPS[0] * 60;
} else {
if (quality === 3) iv = Math.max(iv + 1, Math.round(iv * FC_HARD_MULT));
else if (quality === 4) iv = Math.round(iv * ef);
else iv = Math.round(iv * ef * FC_EASY_BONUS);
else if (quality === 4) iv = Math.max(iv + 1, Math.round(iv * ef));
else iv = Math.max(iv + 1, Math.round(iv * ef * FC_EASY_BONUS));
reps += 1;
ef = Math.max(FC_MIN_EF, ef + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
dueSec = iv * 86400;
}
n++;
}
ef = Math.max(1.3, ef + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02));
const due = new Date(Date.now() + iv * 86400000).toISOString();
return { easeFactor: ef, intervalDays: iv, repetitions: n, dueAt: due };
const dueAt = new Date(nowMs + dueSec * 1000).toISOString();
return { state, learningStep: step, easeFactor: ef, intervalDays: iv, repetitions: reps,
lapses, dueAt, dueInSec: dueSec, graduated: state === 'review' };
}
/* ── GET /api/flashcards/decks ─────────────────────────────────────────── */
function listDecks(req, res) {
const uid = req.user.id;
// due_count = зрелые/learning к повтору (due_at<=now) + новые, но не больше
// дневного лимита new_per_day за вычетом уже введённых сегодня — чтобы бейдж
// отражал реальный размер сессии, а не весь бэклог новых карт.
const decks = db.prepare(`
SELECT d.*,
(SELECT COUNT(*) FROM flashcard_cards c WHERE c.deck_id = d.id) AS card_count,
(SELECT COUNT(*) FROM flashcard_cards c
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE c.deck_id = d.id AND (r.id IS NULL OR r.due_at <= datetime('now'))) AS due_count
(
(SELECT COUNT(*) FROM flashcard_cards c
JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE c.deck_id = d.id AND r.due_at <= datetime('now'))
+ MIN(
(SELECT COUNT(*) FROM flashcard_cards c
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE c.deck_id = d.id AND r.id IS NULL),
MAX(0, d.new_per_day - (SELECT COUNT(*) FROM flashcard_reviews r
JOIN flashcard_cards c ON c.id = r.card_id
WHERE c.deck_id = d.id AND r.user_id = ? AND date(r.created_at) = date('now')))
)
) AS due_count
FROM flashcard_decks d
WHERE d.user_id = ?
ORDER BY d.created_at DESC
`).all(uid, uid);
`).all(uid, uid, uid, uid);
res.json({ decks });
}
@@ -209,31 +273,45 @@ function deleteCard(req, res) {
/* ── GET /api/flashcards/decks/:id/study ───────────────────────────────── */
function getStudySession(req, res) {
const uid = req.user.id;
const deck = db.prepare(`SELECT id FROM flashcard_decks WHERE id = ? AND user_id = ?`)
const deck = db.prepare(`SELECT id, new_per_day FROM flashcard_decks WHERE id = ? AND user_id = ?`)
.get(req.params.id, uid);
if (!deck) return res.status(404).json({ error: 'Not found' });
// due cards first, then new cards (no review yet), limit 20
const cards = db.prepare(`
// 1) Карты к повторению (есть строка отзыва, due_at<=now): learning — по минутам,
// review — по дням. Отдаём по возрастанию due_at (срочные learning впереди).
const dueCards = db.prepare(`
SELECT c.id, c.front, c.back, c.front_image, c.back_image,
COALESCE(r.ease_factor, 2.5) AS ease_factor,
COALESCE(r.interval_days, 1) AS interval_days,
COALESCE(r.repetitions, 0) AS repetitions,
COALESCE(r.due_at, datetime('now')) AS due_at,
r.last_reviewed,
CASE WHEN r.id IS NULL THEN 0 ELSE 1 END AS seen
r.ease_factor, r.interval_days, r.repetitions, r.due_at, r.last_reviewed,
r.state, r.learning_step, 1 AS seen
FROM flashcard_cards c
JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE c.deck_id = ? AND r.due_at <= datetime('now')
ORDER BY r.due_at ASC
LIMIT 100
`).all(uid, deck.id);
// 2) Новые карты (без отзыва), но не больше дневного лимита за вычетом уже
// введённых сегодня — защита от перегруза на большой колоде.
const newToday = db.prepare(`
SELECT COUNT(*) AS n FROM flashcard_reviews r
JOIN flashcard_cards c ON c.id = r.card_id
WHERE c.deck_id = ? AND r.user_id = ? AND date(r.created_at) = date('now')
`).get(deck.id, uid).n;
const newBudget = Math.max(0, (deck.new_per_day || 20) - newToday);
const newCards = newBudget > 0 ? db.prepare(`
SELECT c.id, c.front, c.back, c.front_image, c.back_image,
2.5 AS ease_factor, 0 AS interval_days, 0 AS repetitions,
datetime('now') AS due_at, NULL AS last_reviewed,
'new' AS state, 0 AS learning_step, 0 AS seen
FROM flashcard_cards c
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE c.deck_id = ?
AND (r.id IS NULL OR r.due_at <= datetime('now'))
ORDER BY seen ASC, r.due_at ASC
LIMIT 20
`).all(uid, deck.id);
const total_due = db.prepare(`
SELECT COUNT(*) AS n FROM flashcard_cards c
LEFT JOIN flashcard_reviews r ON r.card_id = c.id AND r.user_id = ?
WHERE c.deck_id = ? AND (r.id IS NULL OR r.due_at <= datetime('now'))
`).get(uid, deck.id).n;
res.json({ cards, total_due });
WHERE c.deck_id = ? AND r.id IS NULL
ORDER BY c.order_idx, c.id
LIMIT ?
`).all(uid, deck.id, newBudget) : [];
const cards = dueCards.concat(newCards);
res.json({ cards, total_due: cards.length });
}
/* ── POST /api/flashcards/cards/:id/review ─────────────────────────────── */
@@ -254,22 +332,39 @@ function submitReview(req, res) {
`SELECT * FROM flashcard_reviews WHERE user_id = ? AND card_id = ?`
).get(uid, card.id);
const prev = existing || { ease_factor: 2.5, interval_days: 1, repetitions: 0 };
const next = sm2(prev.ease_factor, prev.interval_days, prev.repetitions, quality);
const prev = existing || { state: 'new', learning_step: 0, ease_factor: 2.5,
interval_days: 0, repetitions: 0, lapses: 0 };
const next = schedule(prev, quality, Date.now());
if (existing) {
db.prepare(`
UPDATE flashcard_reviews
SET ease_factor=?, interval_days=?, repetitions=?, due_at=?, last_reviewed=datetime('now')
SET state=?, learning_step=?, ease_factor=?, interval_days=?, repetitions=?,
lapses=?, due_at=?, last_reviewed=datetime('now')
WHERE user_id=? AND card_id=?
`).run(next.easeFactor, next.intervalDays, next.repetitions, next.dueAt, uid, card.id);
`).run(next.state, next.learningStep, next.easeFactor, next.intervalDays,
next.repetitions, next.lapses, next.dueAt, uid, card.id);
} else {
db.prepare(`
INSERT INTO flashcard_reviews (user_id, card_id, ease_factor, interval_days, repetitions, due_at, last_reviewed)
VALUES (?,?,?,?,?,?,datetime('now'))
`).run(uid, card.id, next.easeFactor, next.intervalDays, next.repetitions, next.dueAt);
INSERT INTO flashcard_reviews
(user_id, card_id, state, learning_step, ease_factor, interval_days,
repetitions, lapses, due_at, last_reviewed, created_at)
VALUES (?,?,?,?,?,?,?,?,?,datetime('now'),datetime('now'))
`).run(uid, card.id, next.state, next.learningStep, next.easeFactor,
next.intervalDays, next.repetitions, next.lapses, next.dueAt);
}
res.json({ ok: true, next_review: next.dueAt, interval_days: next.intervalDays });
res.json({
ok: true,
graduated: next.graduated, // true → карта в review (из сессии можно убрать)
due_in_sec: next.dueInSec,
next_review: next.dueAt,
interval_days: next.intervalDays,
next: {
state: next.state, learning_step: next.learningStep,
ease_factor: next.easeFactor, interval_days: next.intervalDays,
repetitions: next.repetitions,
},
});
}
/* ── GET /api/flashcards/stats ─────────────────────────────────────────── */