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.
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services.Flashcards;
|
||||
|
||||
public enum FlashcardReviewOutcomeKind { Success, Duplicate, Conflict, NotFound }
|
||||
|
||||
public sealed class FlashcardReviewOutcome
|
||||
{
|
||||
public FlashcardReviewOutcomeKind Kind { get; init; }
|
||||
public FlashcardSchedule? Schedule { get; init; }
|
||||
public static FlashcardReviewOutcome NotFound() => new() { Kind = FlashcardReviewOutcomeKind.NotFound };
|
||||
public static FlashcardReviewOutcome Conflict(FlashcardSchedule? current) => new() { Kind = FlashcardReviewOutcomeKind.Conflict, Schedule = current };
|
||||
public static FlashcardReviewOutcome Duplicate(FlashcardSchedule? current) => new() { Kind = FlashcardReviewOutcomeKind.Duplicate, Schedule = current };
|
||||
public static FlashcardReviewOutcome Success(FlashcardSchedule schedule) => new() { Kind = FlashcardReviewOutcomeKind.Success, Schedule = schedule };
|
||||
}
|
||||
|
||||
public sealed class FlashcardScheduleStore
|
||||
{
|
||||
private readonly IMongoCollection<FlashcardSchedule> _schedules;
|
||||
private readonly IMongoCollection<FlashcardReviewLog> _reviewLogs;
|
||||
private readonly IFlashcardScheduler _scheduler;
|
||||
|
||||
public FlashcardScheduleStore(MongoDatabaseProvider mongo, IFlashcardScheduler scheduler)
|
||||
{
|
||||
_schedules = mongo.Database.GetCollection<FlashcardSchedule>("FlashcardSchedules");
|
||||
_reviewLogs = mongo.Database.GetCollection<FlashcardReviewLog>("FlashcardReviewLogs");
|
||||
_scheduler = scheduler;
|
||||
|
||||
_schedules.Indexes.CreateMany([
|
||||
new CreateIndexModel<FlashcardSchedule>(
|
||||
Builders<FlashcardSchedule>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.NoteId).Ascending(v => v.Variant),
|
||||
new CreateIndexOptions { Unique = true, Name = "flashcard_schedule_unique_user_note_variant" }),
|
||||
new CreateIndexModel<FlashcardSchedule>(
|
||||
Builders<FlashcardSchedule>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.State).Ascending(v => v.DueUtc),
|
||||
new CreateIndexOptions { Name = "flashcard_schedule_user_state_due" })
|
||||
]);
|
||||
_reviewLogs.Indexes.CreateMany([
|
||||
new CreateIndexModel<FlashcardReviewLog>(
|
||||
Builders<FlashcardReviewLog>.IndexKeys.Ascending(v => v.UserId).Descending(v => v.ReviewedUtc),
|
||||
new CreateIndexOptions { Name = "flashcard_review_log_user_reviewed" }),
|
||||
new CreateIndexModel<FlashcardReviewLog>(
|
||||
Builders<FlashcardReviewLog>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.CardId),
|
||||
new CreateIndexOptions { Name = "flashcard_review_log_user_card" })
|
||||
]);
|
||||
}
|
||||
|
||||
// Basic notes always have exactly one card (variant 0). Cloze notes get one independently
|
||||
// scheduled card per distinct {{cN::...}} number found in the prompt. Existing variants are
|
||||
// never removed on re-sync (even if a cloze number disappears from edited text) so a note
|
||||
// edit never discards review history.
|
||||
public async Task SyncVariantsAsync(FlashcardNote note)
|
||||
{
|
||||
var desiredVariants = note.Type == FlashcardTypes.Cloze
|
||||
? FlashcardClozeParser.DistinctClozeNumbers(note.Prompt)
|
||||
: [0];
|
||||
if (desiredVariants.Count == 0) return;
|
||||
|
||||
var existing = await GetByNoteIdAsync(note.UserId, note.Id!);
|
||||
var existingVariants = existing.Select(v => v.Variant).ToHashSet();
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var variant in desiredVariants.Where(v => !existingVariants.Contains(v)))
|
||||
{
|
||||
var schedule = new FlashcardSchedule
|
||||
{
|
||||
UserId = note.UserId,
|
||||
NoteId = note.Id!,
|
||||
Variant = variant,
|
||||
State = FlashcardScheduleStates.New,
|
||||
DueUtc = now,
|
||||
CreatedUtc = now
|
||||
};
|
||||
try
|
||||
{
|
||||
await _schedules.InsertOneAsync(schedule);
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
// Concurrent save already created this variant.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<FlashcardSchedule?> GetAsync(string userId, string cardId) =>
|
||||
await _schedules.Find(v => v.UserId == userId && v.Id == cardId).FirstOrDefaultAsync();
|
||||
|
||||
public Task<List<FlashcardSchedule>> GetByNoteIdAsync(string userId, string noteId) =>
|
||||
_schedules.Find(v => v.UserId == userId && v.NoteId == noteId).ToListAsync();
|
||||
|
||||
public async Task<List<FlashcardSchedule>> GetDueAsync(
|
||||
string userId, IReadOnlyCollection<string> noteIds, DateTime nowUtc, DateTime studyDayStartUtc, int limit)
|
||||
{
|
||||
if (noteIds.Count == 0 || limit <= 0) return [];
|
||||
return await _schedules.Find(v => v.UserId == userId
|
||||
&& noteIds.Contains(v.NoteId)
|
||||
&& v.State != FlashcardScheduleStates.New
|
||||
&& !v.IsSuspended
|
||||
&& (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc)
|
||||
&& v.DueUtc <= nowUtc)
|
||||
.SortBy(v => v.DueUtc)
|
||||
.Limit(limit)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<FlashcardSchedule>> GetNewAsync(
|
||||
string userId, IReadOnlyCollection<string> noteIds, DateTime studyDayStartUtc, int limit)
|
||||
{
|
||||
if (noteIds.Count == 0 || limit <= 0) return [];
|
||||
return await _schedules.Find(v => v.UserId == userId
|
||||
&& noteIds.Contains(v.NoteId)
|
||||
&& v.State == FlashcardScheduleStates.New
|
||||
&& !v.IsSuspended
|
||||
&& (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc))
|
||||
.SortBy(v => v.CreatedUtc)
|
||||
.Limit(limit)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<long> CountDueAsync(string userId, IReadOnlyCollection<string> noteIds, DateTime nowUtc, DateTime studyDayStartUtc) =>
|
||||
noteIds.Count == 0
|
||||
? Task.FromResult(0L)
|
||||
: _schedules.CountDocumentsAsync(v => v.UserId == userId
|
||||
&& noteIds.Contains(v.NoteId)
|
||||
&& v.State != FlashcardScheduleStates.New
|
||||
&& !v.IsSuspended
|
||||
&& (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc)
|
||||
&& v.DueUtc <= nowUtc);
|
||||
|
||||
public Task<long> CountNewAsync(string userId, IReadOnlyCollection<string> noteIds, DateTime studyDayStartUtc) =>
|
||||
noteIds.Count == 0
|
||||
? Task.FromResult(0L)
|
||||
: _schedules.CountDocumentsAsync(v => v.UserId == userId
|
||||
&& noteIds.Contains(v.NoteId)
|
||||
&& v.State == FlashcardScheduleStates.New
|
||||
&& !v.IsSuspended
|
||||
&& (v.BuriedUntilUtc == null || v.BuriedUntilUtc <= studyDayStartUtc));
|
||||
|
||||
public Task<long> CountReviewsSinceAsync(string userId, DateTime sinceUtc) =>
|
||||
_reviewLogs.CountDocumentsAsync(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null);
|
||||
|
||||
public async Task<int> CountNewIntroducedSinceAsync(string userId, DateTime sinceUtc) =>
|
||||
checked((int)await _reviewLogs.CountDocumentsAsync(v => v.UserId == userId
|
||||
&& v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null && v.Before.State == FlashcardScheduleStates.New));
|
||||
|
||||
public Task<List<FlashcardReviewLog>> GetReviewsSinceAsync(string userId, DateTime sinceUtc) =>
|
||||
_reviewLogs.Find(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc).SortBy(v => v.ReviewedUtc).ToListAsync();
|
||||
|
||||
public Task<List<FlashcardReviewLog>> GetReviewLogsForCardAsync(string userId, string cardId) =>
|
||||
_reviewLogs.Find(v => v.UserId == userId && v.CardId == cardId).SortBy(v => v.ReviewedUtc).ToListAsync();
|
||||
|
||||
// Used only by JSON import: restores a previously-exported card's FSRS state verbatim
|
||||
// (not a normal review) so importing preserves history instead of re-deriving it.
|
||||
public Task ImportScheduleStateAsync(string userId, string noteId, FlashcardSchedule imported) =>
|
||||
_schedules.UpdateOneAsync(
|
||||
v => v.UserId == userId && v.NoteId == noteId && v.Variant == imported.Variant,
|
||||
Builders<FlashcardSchedule>.Update
|
||||
.Set(v => v.State, imported.State)
|
||||
.Set(v => v.Step, imported.Step)
|
||||
.Set(v => v.Stability, imported.Stability)
|
||||
.Set(v => v.Difficulty, imported.Difficulty)
|
||||
.Set(v => v.DueUtc, imported.DueUtc)
|
||||
.Set(v => v.LastReviewUtc, imported.LastReviewUtc)
|
||||
.Set(v => v.IsSuspended, imported.IsSuspended)
|
||||
.Set(v => v.BuriedUntilUtc, imported.BuriedUntilUtc)
|
||||
.Inc(v => v.Revision, 1));
|
||||
|
||||
public async Task ImportReviewLogAsync(string userId, string cardId, FlashcardReviewLog imported)
|
||||
{
|
||||
var log = new FlashcardReviewLog
|
||||
{
|
||||
Id = string.IsNullOrWhiteSpace(imported.Id) ? Guid.NewGuid().ToString() : imported.Id,
|
||||
UserId = userId,
|
||||
CardId = cardId,
|
||||
Rating = imported.Rating,
|
||||
ReviewedUtc = imported.ReviewedUtc,
|
||||
DurationSeconds = imported.DurationSeconds,
|
||||
Before = imported.Before,
|
||||
After = imported.After,
|
||||
CreatedUtc = imported.CreatedUtc,
|
||||
UndoneUtc = imported.UndoneUtc
|
||||
};
|
||||
try
|
||||
{
|
||||
await _reviewLogs.InsertOneAsync(log);
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
// Already imported (re-running the same import commit).
|
||||
}
|
||||
}
|
||||
|
||||
public List<FlashcardRatingOption> PreviewRatingOptions(FlashcardSchedule schedule, DateTime nowUtc) =>
|
||||
_scheduler.PreviewRatingOptions(schedule, nowUtc);
|
||||
|
||||
public async Task<FlashcardReviewOutcome> SubmitReviewAsync(string userId, FlashcardReviewRequest request)
|
||||
{
|
||||
var schedule = await GetAsync(userId, request.CardId);
|
||||
if (schedule is null) return FlashcardReviewOutcome.NotFound();
|
||||
|
||||
if (schedule.Revision != request.ExpectedRevision)
|
||||
{
|
||||
var priorLog = await _reviewLogs.Find(v => v.Id == request.ClientReviewId && v.UserId == userId).FirstOrDefaultAsync();
|
||||
return priorLog is not null && priorLog.CardId == request.CardId
|
||||
? FlashcardReviewOutcome.Duplicate(schedule)
|
||||
: FlashcardReviewOutcome.Conflict(schedule);
|
||||
}
|
||||
|
||||
var before = Snapshot(schedule);
|
||||
var result = _scheduler.Review(schedule, request.Rating, request.ReviewedUtc, request.DurationSeconds);
|
||||
|
||||
var updated = await _schedules.FindOneAndUpdateAsync(
|
||||
v => v.UserId == userId && v.Id == request.CardId && v.Revision == request.ExpectedRevision,
|
||||
Builders<FlashcardSchedule>.Update
|
||||
.Set(v => v.State, result.State)
|
||||
.Set(v => v.Step, result.Step)
|
||||
.Set(v => v.Stability, result.Stability)
|
||||
.Set(v => v.Difficulty, result.Difficulty)
|
||||
.Set(v => v.DueUtc, result.DueUtc)
|
||||
.Set(v => v.LastReviewUtc, result.LastReviewUtc)
|
||||
.Set(v => v.AlgorithmVersion, FsrsFlashcardScheduler.AlgorithmVersion)
|
||||
.Inc(v => v.Revision, 1),
|
||||
new FindOneAndUpdateOptions<FlashcardSchedule> { ReturnDocument = ReturnDocument.After });
|
||||
|
||||
if (updated is null)
|
||||
{
|
||||
var raceLog = await _reviewLogs.Find(v => v.Id == request.ClientReviewId && v.UserId == userId).FirstOrDefaultAsync();
|
||||
var current = await GetAsync(userId, request.CardId);
|
||||
return raceLog is not null && raceLog.CardId == request.CardId
|
||||
? FlashcardReviewOutcome.Duplicate(current)
|
||||
: FlashcardReviewOutcome.Conflict(current);
|
||||
}
|
||||
|
||||
var log = new FlashcardReviewLog
|
||||
{
|
||||
Id = request.ClientReviewId,
|
||||
UserId = userId,
|
||||
CardId = request.CardId,
|
||||
Rating = request.Rating,
|
||||
ReviewedUtc = request.ReviewedUtc,
|
||||
DurationSeconds = Math.Clamp(request.DurationSeconds, 0, 86_400),
|
||||
Before = before,
|
||||
After = Snapshot(updated),
|
||||
CreatedUtc = DateTime.UtcNow
|
||||
};
|
||||
try
|
||||
{
|
||||
await _reviewLogs.InsertOneAsync(log);
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
// A retried duplicate request already recorded this review; the schedule update
|
||||
// above only applies once because it matched the pre-review revision exactly.
|
||||
}
|
||||
|
||||
// Bury cloze siblings (basic notes have exactly one variant, so this is a no-op for them).
|
||||
var buriedUntil = FlashcardScheduling.NextStudyDayStart(request.StudyDayStartUtc);
|
||||
await _schedules.UpdateManyAsync(
|
||||
v => v.UserId == userId && v.NoteId == updated.NoteId && v.Id != updated.Id,
|
||||
Builders<FlashcardSchedule>.Update.Set(v => v.BuriedUntilUtc, buriedUntil));
|
||||
|
||||
return FlashcardReviewOutcome.Success(updated);
|
||||
}
|
||||
|
||||
public async Task<FlashcardReviewOutcome> UndoAsync(string userId, string reviewLogId)
|
||||
{
|
||||
var log = await _reviewLogs.Find(v => v.Id == reviewLogId && v.UserId == userId && v.UndoneUtc == null).FirstOrDefaultAsync();
|
||||
if (log is null) return FlashcardReviewOutcome.NotFound();
|
||||
|
||||
var current = await GetAsync(userId, log.CardId);
|
||||
if (current is null) return FlashcardReviewOutcome.NotFound();
|
||||
if (current.Revision != log.After.Revision)
|
||||
return FlashcardReviewOutcome.Conflict(current);
|
||||
|
||||
var restored = await _schedules.FindOneAndUpdateAsync(
|
||||
v => v.UserId == userId && v.Id == log.CardId && v.Revision == log.After.Revision,
|
||||
Builders<FlashcardSchedule>.Update
|
||||
.Set(v => v.State, log.Before.State)
|
||||
.Set(v => v.Step, log.Before.Step)
|
||||
.Set(v => v.Stability, log.Before.Stability)
|
||||
.Set(v => v.Difficulty, log.Before.Difficulty)
|
||||
.Set(v => v.DueUtc, log.Before.DueUtc)
|
||||
.Set(v => v.LastReviewUtc, log.Before.LastReviewUtc)
|
||||
.Inc(v => v.Revision, 1),
|
||||
new FindOneAndUpdateOptions<FlashcardSchedule> { ReturnDocument = ReturnDocument.After });
|
||||
|
||||
if (restored is null) return FlashcardReviewOutcome.Conflict(await GetAsync(userId, log.CardId));
|
||||
|
||||
await _reviewLogs.UpdateOneAsync(
|
||||
v => v.Id == reviewLogId,
|
||||
Builders<FlashcardReviewLog>.Update.Set(v => v.UndoneUtc, DateTime.UtcNow));
|
||||
return FlashcardReviewOutcome.Success(restored);
|
||||
}
|
||||
|
||||
public async Task<long> SetSuspendedAsync(string userId, IReadOnlyCollection<string> cardIds, bool suspended)
|
||||
{
|
||||
if (cardIds.Count == 0) return 0;
|
||||
var result = await _schedules.UpdateManyAsync(
|
||||
v => v.UserId == userId && cardIds.Contains(v.Id!),
|
||||
Builders<FlashcardSchedule>.Update.Set(v => v.IsSuspended, suspended).Inc(v => v.Revision, 1));
|
||||
return result.ModifiedCount;
|
||||
}
|
||||
|
||||
public async Task<long> BuryAsync(string userId, IReadOnlyCollection<string> cardIds, DateTime untilUtc)
|
||||
{
|
||||
if (cardIds.Count == 0) return 0;
|
||||
var result = await _schedules.UpdateManyAsync(
|
||||
v => v.UserId == userId && cardIds.Contains(v.Id!),
|
||||
Builders<FlashcardSchedule>.Update.Set(v => v.BuriedUntilUtc, untilUtc).Inc(v => v.Revision, 1));
|
||||
return result.ModifiedCount;
|
||||
}
|
||||
|
||||
private static FlashcardScheduleSnapshot Snapshot(FlashcardSchedule schedule) => new()
|
||||
{
|
||||
State = schedule.State,
|
||||
Step = schedule.Step,
|
||||
Stability = schedule.Stability,
|
||||
Difficulty = schedule.Difficulty,
|
||||
DueUtc = schedule.DueUtc,
|
||||
LastReviewUtc = schedule.LastReviewUtc,
|
||||
Revision = schedule.Revision
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user