Files
step3/api/QuestionsApi/Services/Flashcards/FlashcardScheduleStore.cs
T
hz 7f76ca0fdc Add system (curated) vs. user-uploaded flashcards
System decks/notes are owned by a reserved sentinel user id, reusing
all existing per-user store/index plumbing unchanged. Regular users
see them under a separate "Official Decks" section, can study them
with fully independent per-user FSRS progress (lazily materialized on
first visit), but can never edit or archive them - enforced both in
the client (no Edit/Archive controls) and server-side (ownership guard
on every mutating endpoint). Includes a small idempotent seed command
(`dotnet run -- seed-flashcards`) for testing, since there's no in-app
authoring UI yet.
2026-07-22 15:52:19 -05:00

393 lines
19 KiB
C#

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.
}
}
}
// Sibling of SyncVariantsAsync: materializes this note's variant schedule rows under a
// DIFFERENT user's ownership (the studying user, not the note's own owner). Used only for
// system notes, so a regular user studying shared content gets independent, per-user FSRS
// state. Deliberately duplicated rather than sharing a parameterized private helper with
// SyncVariantsAsync, which is called eagerly on every regular note save and must stay
// untouched/low-risk.
public async Task SyncVariantsForStudyingUserAsync(string studyingUserId, FlashcardNote note)
{
var desiredVariants = note.Type == FlashcardTypes.Cloze
? FlashcardClozeParser.DistinctClozeNumbers(note.Prompt)
: [0];
if (desiredVariants.Count == 0) return;
var existing = await GetByNoteIdAsync(studyingUserId, 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 = studyingUserId,
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 Task<long> CountByStateAsync(string userId, IReadOnlyCollection<string> noteIds, string state) =>
noteIds.Count == 0
? Task.FromResult(0L)
: _schedules.CountDocumentsAsync(v => v.UserId == userId
&& noteIds.Contains(v.NoteId)
&& v.State == state
&& !v.IsSuspended);
// A card counts toward "mastered" once it has graduated to long-term review and reached a
// stability (FSRS's estimate of days until retention drops to ~90%) past this threshold -
// the same rough "won't need attention again soon" cutoff several SRS apps use for a mastery
// stat. Kept separate from the plain Review-state count, which includes recently-graduated
// cards still building stability.
public const double MasteryStabilityDaysThreshold = 21.0;
public Task<long> CountMasteredAsync(string userId, IReadOnlyCollection<string> noteIds) =>
noteIds.Count == 0
? Task.FromResult(0L)
: _schedules.CountDocumentsAsync(v => v.UserId == userId
&& noteIds.Contains(v.NoteId)
&& v.State == FlashcardScheduleStates.Review
&& !v.IsSuspended
&& v.Stability >= MasteryStabilityDaysThreshold);
// Bounded lookback (streaks longer than this are vanishingly rare and this keeps the query
// cheap); returns raw timestamps so the caller can compute consecutive-day streaks in the
// user's own calendar-day semantics.
public Task<List<DateTime>> GetReviewTimestampsSinceAsync(string userId, DateTime sinceUtc) =>
_reviewLogs.Find(v => v.UserId == userId && v.ReviewedUtc >= sinceUtc && v.UndoneUtc == null)
.Project(v => v.ReviewedUtc)
.ToListAsync();
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
};
}