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:
@@ -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 ─────────────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user