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:
hz
2026-07-21 15:08:52 -05:00
parent 29b89dc404
commit 48a8c8361d
41 changed files with 4458 additions and 8 deletions
+83
View File
@@ -0,0 +1,83 @@
# Flashcards
A separate spaced-repetition system alongside the missed-question review queue. Users author
basic and cloze flashcards (or generate a basic card from a resolved question), organize them
in flat decks, and study them on an FSRS schedule.
## Scheduling
Reviews are scored by [FSRS.Core](https://www.nuget.org/packages/FSRS.Core) 1.0.7, wrapped
behind `IFlashcardScheduler` (`api/QuestionsApi/Services/Flashcards/FlashcardScheduler.cs`) so
the rest of the API never touches the library directly. Pinned, non-configurable defaults:
- Desired retention: 90%
- Maximum interval: 36,500 days
- Interval fuzzing: disabled, so a review's outcome is a deterministic function of stored state
(needed for idempotent replay from the offline outbox, and for golden-vector testing)
- Algorithm/parameter version is recorded per card (`FlashcardSchedule.AlgorithmVersion`) so a
future FSRS upgrade or personalized-parameter feature can be introduced without losing the
ability to tell which cards were scheduled under which version
There is no per-user parameter optimization yet; `FlashcardScheduleStore` stores complete
before/after snapshots on every `FlashcardReviewLog` entry specifically so that a future
optimizer can be trained from full history without a data migration.
`api/QuestionsApi.Tests/FlashcardSchedulerTests.cs` validates the wrapper against the official
FSRS formulas (initial stability/difficulty per rating, and the interval-equals-stability
identity that holds at exactly 90% desired retention) computed from the library's own shipped
default parameters, rather than against hand-copied constants - this stays correct even if
FSRS.Core's defaults are retrained upstream.
## Data model
Six user-owned Mongo collections, all indexed on `(UserId, ...)`:
- `FlashcardDecks` - flat (no nesting); every user has exactly one `IsDefault` deck, created
lazily on first use.
- `FlashcardNotes` - authored content (`Prompt`/`Answer`/`Extra`/`Tags`/`Images`). Cloze notes
store their `{{cN::answer::hint}}` markup in `Prompt`.
- `FlashcardSchedules` - one row per generated card, keyed by `(NoteId, Variant)`. Basic notes
always have variant `0`; cloze notes get one row per distinct cloze number found in the
prompt. Kept in a separate collection from notes so editing a note's content never discards
its review history.
- `FlashcardReviewLogs` - immutable, keyed by the client-generated review UUID for idempotent
submission; carries full before/after schedule snapshots.
- `FlashcardMediaAssets` - user-uploaded images (PNG/JPEG/WebP, 5 MB/4-per-note limits).
- `FlashcardSettings` - one row per user (global new/review daily limits).
Attaching a question's own image to a flashcard does not copy bytes: `FlashcardImageReference`
has a `Kind` (`"question"` vs `"owned"`) so the client can resolve `"question"` references
against the existing `/api/questions/media/{id}` endpoint instead of duplicating the asset.
Sibling cloze cards are buried (`FlashcardSchedule.BuriedUntilUtc`) until the next local study
day whenever any one sibling is reviewed. Daily new/review limits reset at the same boundary.
That boundary is supplied by the client as `studyDayStartUtc` on study/review requests (the
local calendar day at session start, expressed as a UTC instant) rather than computed
server-side, per the spec's client-local-day requirement.
## API
All endpoints live under `/api/flashcards` (`FlashcardsController.cs`) and require `USER`/`SUPER`
auth. Deck/note mutations use the same optimistic-concurrency (`ExpectedRevision` + 409 Conflict)
pattern as the rest of the API. Review submission is idempotent on `ClientReviewId`; undo only
allows reverting the single most recent review on a card (it refuses if the card's revision has
moved since).
## Import/export
CSV is UTF-8, text-only, headers `stable_id,deck,type,prompt,answer,extra,tags,
source_question_external_id` (tags semicolon-separated); it omits images, schedule state, and
review history and reports those omissions via the `X-Flashcard-Export-Omissions` response
header. App JSON is full-fidelity (decks, settings, notes, schedules, review logs, base64
images) and merges on import by the note's stable UUID. Both formats go through a
create/update/skip/error preview (`FlashcardImportPreviews`, TTL-expired after an hour) before
`import/commit` applies it; commit never silently drops a row - everything is created, updated,
skipped as an exact-content duplicate, or reported as a row-level error. Limits: 25 MB and
10,000 notes per import.
## Deployment
No new secrets. `FlashcardsController`/stores register the same way as the rest of the API
(`Program.cs`, `AddSingleton`). The Godot client resolves its API base URL from
`step3/flashcards_api_url` in `project.godot`, following the same same-origin-on-web resolution
as every other autoload service (`api_endpoint_config.gd`).
+4
View File
@@ -28,3 +28,7 @@ must initiate recovery with their exact username.
Exports are versioned JSON files containing account metadata and user-owned study records.
Password hashes, refresh tokens, global question content, and moderation data owned by other
users are excluded.
Flashcard decks, notes, generated cards, review logs, media, and settings are user-owned
collections (see [FLASHCARDS.md](FLASHCARDS.md)) and are included in export/purge the same way
as the rest of a learner's study data.
+2
View File
@@ -10,6 +10,8 @@ It checks:
- registration, login, refresh, authenticated profile, dashboard, ratings,
review queue, and randomized question retrieval;
- random CCS attempt creation and abandonment;
- flashcard deck/card creation, studying and grading a card, confirming it
drops out of the due queue, and a CSV export/import round trip;
- public web/legal assets, completion of the Godot loader, and a visible,
non-uniform canvas without browser or critical asset errors.