Files
step3/api/QuestionsApi/Services/Flashcards/FlashcardScheduler.cs
hz 48a8c8361d Add flashcards feature with spaced repetition scheduling
Adds a full flashcards system: FSRS-based scheduling, cloze/basic
card types, CSV import/export, media storage, and study UI, wired
into navigation and the dashboard alongside existing question flows.
2026-07-21 15:08:52 -05:00

117 lines
4.5 KiB
C#

using FSRS.Core.Enums;
using FSRS.Core.Models;
using FSRS.Core.Services;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public sealed class FlashcardScheduleResult
{
public string State { get; init; } = FlashcardScheduleStates.Learning;
public int? Step { get; init; }
public double Stability { get; init; }
public double Difficulty { get; init; }
public DateTime DueUtc { get; init; }
public DateTime LastReviewUtc { get; init; }
}
public interface IFlashcardScheduler
{
FlashcardScheduleResult Review(FlashcardSchedule schedule, string rating, DateTime reviewedUtc, int durationSeconds);
List<FlashcardRatingOption> PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc);
}
// Wraps FSRS.Core 1.0.7 behind an internal interface, pinned to the repo's fixed defaults
// (90% desired retention, 36,500 day max interval, no per-user parameter optimization).
// Fuzzing is disabled so scheduling is deterministic: reproducible for tests/support, and
// so a review's outcome depends only on stored state, never on wall-clock randomness.
public sealed class FsrsFlashcardScheduler : IFlashcardScheduler
{
public const string AlgorithmVersion = "fsrs-core-1.0.7";
public const double DesiredRetention = 0.90;
public const int MaximumIntervalDays = 36_500;
private readonly Scheduler _scheduler = new(
desiredRetention: DesiredRetention,
maximumInterval: MaximumIntervalDays,
enableFuzzing: false);
public FlashcardScheduleResult Review(FlashcardSchedule schedule, string rating, DateTime reviewedUtc, int durationSeconds)
{
var card = ToCard(schedule);
var (updated, _) = _scheduler.ReviewCard(card, ParseRating(rating), reviewedUtc, Math.Max(0, durationSeconds) * 1000);
return FromCard(updated);
}
public List<FlashcardRatingOption> PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc)
{
var options = new List<FlashcardRatingOption>();
foreach (var rating in FlashcardRatings.All)
{
var card = ToCard(schedule);
var (updated, _) = _scheduler.ReviewCard(card, ParseRating(rating), nowUtc);
options.Add(new FlashcardRatingOption
{
Rating = rating,
DueUtc = updated.Due,
IntervalLabel = FormatInterval(updated.Due - nowUtc)
});
}
return options;
}
private static Card ToCard(FlashcardSchedule schedule) =>
schedule.LastReviewUtc is null
? new Card()
: new Card(
state: ParseState(schedule.State),
step: schedule.Step,
stability: schedule.Stability,
difficulty: schedule.Difficulty,
due: schedule.DueUtc,
lastReview: schedule.LastReviewUtc);
private static FlashcardScheduleResult FromCard(Card card) => new()
{
State = FormatState(card.State),
Step = card.Step,
Stability = card.Stability ?? 0,
Difficulty = card.Difficulty ?? 0,
DueUtc = card.Due,
LastReviewUtc = card.LastReview ?? DateTime.UtcNow
};
private static Rating ParseRating(string rating) => rating switch
{
FlashcardRatings.Again => Rating.Again,
FlashcardRatings.Hard => Rating.Hard,
FlashcardRatings.Good => Rating.Good,
FlashcardRatings.Easy => Rating.Easy,
_ => throw new ArgumentOutOfRangeException(nameof(rating), rating, "unknown flashcard rating")
};
private static State ParseState(string state) => state switch
{
FlashcardScheduleStates.Review => State.Review,
FlashcardScheduleStates.Relearning => State.Relearning,
_ => State.Learning
};
private static string FormatState(State state) => state switch
{
State.Review => FlashcardScheduleStates.Review,
State.Relearning => FlashcardScheduleStates.Relearning,
_ => FlashcardScheduleStates.Learning
};
public static string FormatInterval(TimeSpan span)
{
if (span.TotalMinutes < 1) return "<1m";
if (span.TotalHours < 1) return $"{(int)Math.Round(span.TotalMinutes)}m";
if (span.TotalDays < 1) return $"{(int)Math.Round(span.TotalHours)}h";
if (span.TotalDays < 30) return $"{(int)Math.Round(span.TotalDays)}d";
if (span.TotalDays < 365) return $"{Math.Round(span.TotalDays / 30, 1)}mo";
return $"{Math.Round(span.TotalDays / 365, 1)}y";
}
}