From ad7265d5531d4be24530e13dca00bf204e676224 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 12 Jun 2026 23:27:40 +0300 Subject: [PATCH] =?UTF-8?q?feat(flashcards):=20Anki-=D1=81=D1=82=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=D0=BE=D0=B2=20=E2=80=94=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=80=D0=B0=D0=B7=D0=BB=D0=B8=D1=87=D0=B0=D1=8E=D1=82=D1=81?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше на новой карте Снова/Трудно/Знаю/Легко все давали 1 день (чистый SM-2: оценка влияла только на ease factor). Теперь интервал зависит от оценки: новая карта Легко=4д (остальные 1д), на повторах Трудно ×1.2 / Знаю ×ef / Легко ×ef×1.3 (easy-бонус). Серверный sm2() и клиентское превью fcNextInterval синхронны — проверено 0 расхождений на 256 комбинациях. Co-Authored-By: Claude Opus 4.8 --- .../src/controllers/flashcardController.js | 24 +++++++++++++------ frontend/flashcards.html | 19 ++++++++------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/backend/src/controllers/flashcardController.js b/backend/src/controllers/flashcardController.js index 4a43a44..4c3d39c 100644 --- a/backend/src/controllers/flashcardController.js +++ b/backend/src/controllers/flashcardController.js @@ -10,10 +10,14 @@ function safeImg(url) { return /^\/uploads\/flashcards\/[A-Za-z0-9._-]+$/.test(u) ? u : ''; } -/* ── SM-2 algorithm ─────────────────────────────────────────────────────── - quality: 0 = blackout, 1 = wrong, 2 = wrong but familiar, - 3 = correct with difficulty, 4 = correct, 5 = perfect +/* ── SM-2 (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; @@ -21,11 +25,17 @@ function sm2(easeFactor, intervalDays, repetitions, quality) { if (quality < 3) { n = 0; - iv = 1; + iv = 1; // Снова — сброс } else { - if (n === 0) iv = 1; - else if (n === 1) iv = 6; - else iv = Math.round(iv * ef); + 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); + } 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); + } n++; } ef = Math.max(1.3, ef + 0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)); diff --git a/frontend/flashcards.html b/frontend/flashcards.html index 32e5fef..6008c19 100644 --- a/frontend/flashcards.html +++ b/frontend/flashcards.html @@ -1569,20 +1569,21 @@ function finishStudy() { `).join(''); } -/* ── estimated next interval preview for sq buttons ── - ВАЖНО: точная копия серверного sm2() (flashcardController.js), иначе - превью врёт. В чистом SM-2 интервал для q>=3 НЕ зависит от значения q - (q влияет только на ease factor), поэтому «Трудно/Знаю/Легко» при первых - повторениях дают одинаковый интервал — это корректно. - (Дифференциация интервалов по кнопкам — кандидат на Фазу 4.) */ +/* ── превью следующего интервала для кнопок Снова/Трудно/Знаю/Легко ── + ВАЖНО: точная копия логики интервалов серверного sm2() (flashcardController.js), + иначе превью врёт. Anki-стиль: на новой карте Легко=4д выделяется, на зрелых + Трудно ×1.2 / Знаю ×ef / Легко ×ef×1.3. */ +const FC_HARD_MULT = 1.2, FC_EASY_BONUS = 1.3; function fcNextInterval(card, q) { const ef = card.ease_factor || 2.5; const iv = card.interval_days || 1; const rep = card.repetitions || 0; if (q < 3) return 1; - if (rep === 0) return 1; - if (rep === 1) return 6; - return Math.round(iv * ef); + if (rep === 0) return q === 5 ? 4 : 1; + if (rep === 1) return q === 3 ? 3 : q === 4 ? 6 : Math.round(6 * FC_EASY_BONUS); + if (q === 3) return Math.max(iv + 1, Math.round(iv * FC_HARD_MULT)); + if (q === 4) return Math.round(iv * ef); + return Math.round(iv * ef * FC_EASY_BONUS); } function fcDaysLabel(n) { if (n <= 1) return '1 день';