Author SHA1 Message Date
hz 0676c7f95e Require production approval for question media 2026-07-30 04:18:31 -05:00
hz f39bfb42be Merge pull request 'Main' (#13) from main into production
Reviewed-on: admin/step3#13
2026-07-29 01:35:56 -05:00
hz c14ac1d65b Require reference ranges for published question labs 2026-07-29 01:28:37 -05:00
hz f082be38e0 Restore Rz Coins caption and fix rank/ELO badge wrapping below toolbar
Reverts the coin caption back to "Rz Coins" and shrinks the badge's
icons/fonts/spacing so both segments fit in the same footprint budget
the toolbar reserves. That budget was a hardcoded 900px sized for the
old single-segment badge; bump it to 960px to match the wider badge
instead of letting it overflow onto its own row.
2026-07-29 01:16:47 -05:00
hz 845907e825 Show rank tier and ELO alongside RZ points in the coins badge
Adds a server-computed tier/division (Bronze..Diamond, III-I) to the
ratings profile endpoint and surfaces it next to the numeric ELO in the
shared header badge, matching the coin balance display already there.
2026-07-29 00:25:26 -05:00
hz 89dbe3e31a Initialize credit earning ledger arrays 2026-07-28 20:52:20 -05:00
hz dd203b24f9 Allow SUPER accounts to earn Rz Coins 2026-07-28 20:36:15 -05:00
hzandClaude Sonnet 5 6f41906101 Gate Rz Coins on correct-first-try-within-time-limit; match coin-flight animation to it
Credits were previously awarded on any first attempt regardless of
correctness or timing. The award now requires the first-ever attempt
to be correct (record.Attempt.IsCorrect) and submitted before the
adaptive per-question timer expired (new MetTimeLimit request field,
computed client-side from the same elapsed/target comparison that
already drives the visible countdown bar). The coin-flight animation
in question_player.gd now only plays when both conditions are met
(BlockSessionService.submit_core_attempt's existing "mastered" flag
plus met_time_limit), so the effect never promises a coin that isn't
actually earned.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-28 20:17:07 -05:00
hzandClaude Sonnet 5 efae7e0b2b Add live RZ Points balance widget to question and shell headers
Wires the recently-merged question-completion-credits backend
(GET /api/credits) into the client for the first time: a new
CreditsService autoload fetches/caches the balance, and the embedded
credits field on first-answer responses updates it instantly with no
extra round trip. A shared CreditsBadge widget (star icon, balance,
decorative chevron per mockup) surfaces it in both the question
screen toolbar and the main shell header.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-28 19:33:33 -05:00
hzandClaude Sonnet 5 05a5298ed1 Add home button to question screen toolbar to exit the run
Previously the only way out of an active question run was Escape to
open the pause overlay, then click Exit Run. Exposes main.gd's exit
logic publicly (_exit_run -> exit_current_run) so a house-icon button
in the question toolbar can trigger it directly.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-28 19:06:12 -05:00
hz b511d27a20 Merge question completion credits 2026-07-28 18:58:21 -05:00
hz 22b0df7aed Add isolated question completion credits 2026-07-28 18:40:49 -05:00
hz 63fa073530 Merge pull request 'Enable HTML5 virtual keyboard for text field input on mobile Safari' (#12) from main into production
Reviewed-on: admin/step3#12
2026-07-27 21:58:41 -05:00
hzandClaude Sonnet 5 41731f5804 Enable HTML5 virtual keyboard for text field input on mobile Safari
iPhone users hit the Web build via Safari, but experimental_virtual_keyboard
was disabled, so focusing a LineEdit/TextEdit never summoned the on-screen
keyboard.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-27 21:42:12 -05:00
hz e3e18f6415 Merge pull request 'Fix login/calculator theme bugs, gate flashcard button, hide system label pre-answer' (#11) from main into production
Reviewed-on: admin/step3#11
2026-07-27 01:03:50 -05:00
hz 9f836c8765 Fix login/calculator theme bugs, gate flashcard button, hide system label pre-answer
- Steam-linked login no longer writes literal "<null>" into the email field
  (and no longer locks it read-only) when the verified identity has no email
- Login screen releases focus on app focus-out so the username caret stops
  blinking while the window is inactive
- Calculator display text stays legible in light mode; the LCD-style panel
  background is always dark, so its text should not follow the app theme
- Question player: gate the "save as flashcard" toolbar button behind
  Brand.is_uat_environment(), matching the nav item, so it's hidden in prod
- Hide the discipline/system label while a question is unanswered so it
  can't hint at the answer; reveal it once the explanation is shown
- Top-align question content instead of vertically centering it, with a
  20px top margin, so it sits closer to the progress indicator
2026-07-27 00:43:18 -05:00
hz 86ae650272 Merge pull request 'Main' (#10) from main into production
Reviewed-on: admin/step3#10
2026-07-26 18:37:53 -05:00
hz 23d573e221 Fix GDScript ternary and shadowed-identifier warnings in dashboard scripts
Godot's static checker can't unify a float branch with a null branch in
a ternary, so point_value() now uses explicit if/return; renaming the
local sign variable to sign_prefix avoids shadowing the builtin sign().
2026-07-26 18:28:55 -05:00
hzandClaude Sonnet 5 548b519d44 Vertically center the login screen wordmark against the logo
The RanaZe/STEP 3 TRAINER text block was top-aligned inside the
brand row's HBoxContainer, which is taller than the text due to the
190x190 icon beside it, so it hugged the top instead of centering
against the shield mark.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 18:08:36 -05:00
hz 1ff2d612a4 Merge pull request 'Main' (#9) from main into production
Reviewed-on: admin/step3#9
2026-07-26 16:53:15 -05:00
hz 1d5d03a4f6 Add difficulty 4 question gap batch 2026-07-26 16:32:41 -05:00
hzandClaude Sonnet 5 efe60f36c5 Fix dashboard info-icon rendering and profile <null> display
Info badge: the icon was a PanelContainer using StyleBoxFlat corner
radius to fake a circle, with the radius exceeding half the box size,
causing visible artifacts. Replaced with a custom-drawn Control
(draw_arc) for a clean ring at any size, and added a hover tooltip
explaining what each stat card measures.

Profile: Dictionary.get(key, default) only falls back when the key is
missing, not when it's present with a null value, so a null
displayName rendered as the literal string "<null>" (str(null) in
GDScript) - including pre-filling the edit-profile dialog with it.
Added a shared string_field() helper that treats null the same as
missing, applied everywhere an account/preference string field is
displayed or edited.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 16:27:46 -05:00
hzandClaude Sonnet 5 f680024dc3 Rewrite the donation screen's mission statement
Repositions the copy around being an independent, at-cost alternative
to the Step 3 prep industry's subscription funnels, and broadens the
audience from residents specifically to any Step 3 test taker.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 16:19:21 -05:00
hz 94e0e5dd70 Confirm passwords for external account creation 2026-07-26 14:52:41 -05:00
hzandClaude Sonnet 5 74398133f7 Exclude sample exam simulations from the dashboard's readiness score
The SUPER-role "preview results" QA tooling seeds a completed
ExamSimulation with scripted (not real) answers, flagged IsSample.
The dashboard's readiness lookup picked whichever completed
simulation was most recent regardless of that flag, so running the
QA preview on an account could override its real Exam Readiness card
with a synthetic pass probability. Recent-activity/weekly-activity
queries got the same exclusion since they read from the same
collection for the same reason.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 14:47:07 -05:00
hz 447f080552 Add ready question production promotion 2026-07-26 01:17:55 -05:00
hzandClaude Sonnet 5 ef44347bf6 Fix loader tagline centering and dashboard card icons
Centers the loader's tagline under the logo, replaces the broken
info-glyph (a Unicode character missing from the Inter font, which
rendered as a tofu box) with a drawn circle-and-i badge, and swaps
the stat-card icons to match the mockup: book for Question Bank
Rating, bar chart for Exam Readiness, star for Average Score.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-26 01:06:05 -05:00
hz 4251885a73 Redesign dashboard top-4 stats as separate cards
Frontend half of the dashboard stat-card redesign, matching the
provided mockup. The 4 metrics used to be plain rows sharing one
bordered box (_add_metric -> a single metric_grid inside one _card).
Replaced with 4 independently-_card-wrapped panels in a responsive
GridContainer (4 cols / 2 compact, same threshold _update_responsive
already used).

Each card now has: a colored icon badge, title + info glyph, a big
value, a colored trend/status line, a small visual, and a footer
caption:
- Question Bank Rating / CCS Rating: a new minimal DashboardSparkline
  (dashboard_sparkline.gd) fed from the current rating and the
  week-ago snapshot (reconstructed client-side from the delta the API
  already returns - no separate history endpoint needed). Trend line
  and caption come from the new delta/percentile fields; both degrade
  to a plain single-point sparkline and a rated-count caption when no
  snapshot exists yet.
- Exam Readiness: a new DashboardSegmentBar (dashboard_segment_bar.gd -
  nothing like a segmented pill progress indicator existed anywhere in
  the codebase) instead of a sparkline, colored by the High/Medium/Low
  label; shows a neutral "complete a simulation" state when the user
  has none yet rather than a fabricated readiness.
- Average Score: reuses the existing ProgressBar/_style_progress,
  relabeled from "First-Attempt Accuracy".

Dropped the old "Study Time" card (not part of the mockup - the
underlying data still drives the weekly activity chart below, it just
no longer gets a dedicated top-line number) and the now-fully-unused
_format_duration.

Verified headlessly: instantiated the real screen against fake
DashboardService data, confirmed every card's value/trend/caption text
and the segment-bar/progress-bar values match the mockup exactly, and
rendered actual screenshots of both the populated and "no data yet"
fallback states.
2026-07-26 00:43:39 -05:00
hz 34d283e213 Add rating snapshots, percentile, and exam readiness to the dashboard API
Backend half of the dashboard stat-card redesign. Three new data
points the old DashboardResponse had no way to compute:

- Week-over-week rating deltas: UserRating/CcsUserRating only ever
  stored the current Elo (mutated in place, UpdatedUtc just means
  "last touched"), so there was no way to know what a rating was 7
  days ago. Added UserRatingSnapshot (new collection, unique on
  UserId+DateUtc) and UserRatingSnapshotService, a BackgroundService
  following AnswerStatisticsMaintenanceService's exact shape (a
  PeriodicTimer plus a Mongo lease so replicas behind the HPA don't
  race), writing one snapshot per user per day. No historical
  backfill - deltas are simply null for a user's first ~7 days after
  this ships, which the DTO represents honestly via nullable ints
  rather than a fake "+0".
- Percentile rank: DashboardStore now counts how many users have a
  higher OverallElo/Elo than the current user out of the total.
- Exam Readiness + projected pass rate: reuses the existing, tested
  IRT-based ExamSimulationScore.PassProbability (a real normal-CDF
  estimate of scoring >=200, computed in ExamSimulationScoring.Calculate)
  rather than inventing a new prediction model. DashboardStore pulls
  the latest completed simulation's score directly and maps
  PassProbability to a High/Medium/Low label via tunable thresholds.
- Average Score's "+6% vs last 100" needed no new data source at all -
  just one more slice (Skip(100).Take(100)) over the QuestionAttempts
  already loaded for FirstAttemptAccuracy.

Added DashboardStore.Percentile/ReadinessLabel as pure static helpers
(matching the existing Percentage/CalculateCurrentStreak pattern) and
unit tests for both. Full backend suite: 165 passed, 0 failed.
2026-07-26 00:39:11 -05:00
hz 500477b612 Add dashboard performance trends 2026-07-25 22:43:34 -05:00
hz 46953ee71a Reach Settings/Profile from the pause menu, with a way back
The pause menu (Escape during a question/CCS/flashcards run) only had
Resume/UI Sounds/Exit Run/Log Out. Settings already has an equivalent
"Interface sounds" toggle, so drop the duplicate from the pause menu.
More importantly, "player" and "ccs_player" hide the entire app shell
(header + sidebar), so there was no way to reach Settings or Profile
at all mid-session - add Profile/Settings buttons to the pause menu to
close that gap.

Navigating there follows the existing return_screen payload
convention (already used by simulation_history/results/review) rather
than inventing new navigation infrastructure: _navigate_from_pause()
remembers current_screen_name and passes it through show_screen's
payload. Both screens show a "Back to session" button when that's
set, and hide it otherwise (including on ordinary non-paused visits).

show_screen() unconditionally resumes the question timer via
_close_app_menu() as a side effect of any navigation, so
_navigate_from_pause() re-pauses it immediately when leaving "player" -
the timer then resumes for real, for free, when the user actually
returns via show_screen(return_screen).

progress_profile.gd rebuilds its whole child tree on every theme
toggle (_rebuild -> _build), which would otherwise wipe the button's
visibility - added _refresh_back_to_session_visibility(), called both
after _build() and from on_show(), so it survives a theme change while
paused.

Flipped tests/web_responsive_test.gd's assertion, which previously
required the pause menu NOT to have a Profile button.

Verified end-to-end headlessly: opening the pause menu from a live
"player" session, navigating to Settings and Profile, confirming
current_question_pause_started_at is correctly paused while away and
resumed on return, confirming the back-to-session button appears only
when reached via pause (not on ordinary nav), and confirming it
survives a dark/light theme toggle on the Profile screen.
2026-07-25 22:30:03 -05:00
hz c0252a916d Merge pull request 'Main' (#7) from main into production
Reviewed-on: admin/step3#7
2026-07-25 22:12:40 -05:00
hz aa5497c516 Center the legal consent row instead of left-aligning it
Wrapped the row in a CenterContainer so the checkbox+text sit centered
under the field column instead of hugging the left edge. Had to drop
the RichTextLabel's EXPAND_FILL (meaningless inside a CenterContainer,
which never stretches its child) in favor of an explicit 480px
minimum width - without one, the label collapsed toward zero width
and wrapped character-by-character, which is what the first attempt
at this actually rendered as before I caught it.

Verified with a direct coordinate check: the row's horizontal center
now matches the confirm-password field's center exactly (delta 0.0).
2026-07-25 11:00:48 -05:00
hz 6b1e41c187 Fix loading-screen sizing shift from the Inter font swap
font-display: swap paints with the fallback font stack first, then
swaps to Inter once the 876KB variable font finishes downloading.
.loader-wordmark was already width-fixed from an earlier centering
fix, but .loader-tagline, .loader-message, and .progress-percent had
no explicit width and (more importantly) used line-height: normal,
which is derived from each font's own metrics - Inter and the fallback
stack disagree by 3-4px per line. Those three deltas summed exactly to
the .loader column's total 10px height change, which is what visibly
shifted the whole centered loading screen once the swap happened.

Gave the width-dependent elements width: 100% (matching the wordmark)
and gave all three text elements an explicit unitless line-height
(computed from font-size, not font metrics) so their box size no
longer depends on which font is currently active.

Verified empirically: forced the fallback font stack on the live page
and diffed getBoundingClientRect() against Inter for every loader
element - all width/height deltas are 0 after the fix (previously up
to 17.5px width / 10px cascaded height).
2026-07-25 10:53:53 -05:00
hz eb9e018339 Fix non-square consent checkbox with a dedicated Panel square
Root cause: CheckBox/Button reserves vertical space for a text
baseline even with an empty string, so no amount of custom_minimum_size
or stylebox content-margin tweaking produced a true square - it just
came out close-but-not-quite in some layouts and visibly elongated in
others.

Decoupled the visual square from the CheckBox entirely: a plain Panel
(exactly 20x20, toggled by direct click) now draws the border/fill,
while a real (invisible) CheckBox still holds .button_pressed for the
rest of the code to read. Also added left padding on the row and
vertically centered both the square and the consent text within it.

Verified with a direct size dump (20x20 in both checked/unchecked
states, where it previously measured non-square) and rendered
screenshots of both states.
2026-07-25 10:45:45 -05:00
hz bff16cea98 Fix oversized, borderless consent checkbox
The generic "style every Button" pass in _apply_theme also matched
CheckBox (it's a Button subtype) and ran after the checkbox-specific
styling, overwriting the intended blue border with the plain default
button box - and that box's own content margins were inflating the
checkbox well past its 26x26 minimum (actual rendered size was 60x52).
Skip CheckBox in that generic loop, zero out the checkbox stylebox's
content margins, shrink it to 22x22, and stop it stretching to the
row's full height. Also sized the consent text down (15->13) and gave
the row some vertical breathing room.

Verified with a rendered screenshot and a direct stylebox/size dump:
checkbox is now 22x24 with a real 2px blue border, matching the
provided reference.
2026-07-25 10:26:35 -05:00
hz 5a2d825181 Rework legal consent to match the provided design
Drop the bordered card - it wasn't the right direction. Instead:
- Plain small checkbox with just a blue border (no fill/icon when
  unchecked), matching the reference exactly.
- "Terms of Service"/"Privacy Policy" render as inline blue links
  within the sentence (RichTextLabel + BBCode [url], routed through
  the same _open_legal_document as the footer), instead of a duplicate
  link row underneath. meta_underlined = false to match the
  reference's plain (non-underlined) link style.

Verified with real rendered screenshots at each step, including a
pixel-level crop confirming the underline was actually gone.
2026-07-25 10:13:18 -05:00
hz 7e977e0d30 Clean up the legal consent checkbox layout
A bare CheckBox's word-wrap left-aligns every wrapped line flush with
the checkbox glyph, so "Privacy Policy." on its own line read as
unindented and sloppy next to an otherwise fully themed form. Split
the toggle from its label (a separate Label wraps within its own
column instead, so wrapped lines stay aligned under the first line)
and wrapped the row in a bordered card matching the app's field
styling. Clicking the label also toggles the checkbox now, for a
bigger hit target.

Verified with an actual rendered screenshot: wrapped text now indents
consistently, and both checked/unchecked states are legible.
2026-07-25 09:52:41 -05:00
hz ba8e0ec256 Remove duplicate Terms/Privacy links from account creation
Register and Steam-linking forms rendered their own Terms of
Service/Privacy Policy links right under the consent checkbox, on top
of the ones already pinned to the bottom of the panel. The consent
checkbox text alone is enough; the persistent footer covers the links.
2026-07-25 09:36:06 -05:00
hz 7be83661f9 Standardize password reveal to the in-field eye icon everywhere
Register, reset, and Steam-linking forms used a separate "Show
password" CheckButton row; the main login form already had the eye
icon embedded in the field itself. Generalized _add_password_reveal
(previously hardwired to a single tracked button) to support multiple
fields via password_reveal_entries, and used it for every password
field instead. Removed the now-unused _add_show_password.

Verified all 6 password fields (login, register x2, reset x2, steam)
get a working reveal button and there's no leftover "Show password"
checkbox anywhere.
2026-07-25 09:34:44 -05:00
hz bb9f59b3cc Center STEP 3 TRAINER under the RanaZe wordmark
.loader-wordmark had no explicit width or text-align, so as a flex
item (align-items: center) it shrink-wrapped to whatever width the
"RanaZe" inline spans needed. wordmark-trainer's display:block then
filled that same ambiguous width rather than the wordmark block's
true rendered box, leaving it off-center relative to "RanaZe" above
it. Giving .loader-wordmark an explicit width:100% and text-align:
center makes both lines center within the same known-width box.

Verified in a real browser: both elements now report identical
getBoundingClientRect() centers (x=960 in a 1920px viewport).
2026-07-25 09:14:27 -05:00
hz 854c887a1a Center login panel content, pin legal links to the bottom
Making the auth panel a fixed height (to kill the scrollbar) left the
shorter views (login, reset) stranded at the top with dead space below.
Split panel_content into a main_column (header, message, mode views -
expand-fill, center-aligned) and the legal footer as a sibling that
stays pinned to the panel's bottom edge regardless of which view is
showing or how tall its content is.

Verified headlessly: top/bottom whitespace above and below the active
view's content is now equal (e.g. login 174/174, steam 2/2), and the
footer's position doesn't move between modes.
2026-07-25 09:10:19 -05:00
hz ce4db7cec8 Remove the login panel scrollbar
The auth panel was a fixed 710x820 (640x790 on narrower desktop
windows) regardless of which view was showing, but Register and the
Steam linking form need up to ~990px to lay out without scrolling -
so the inner ScrollContainer was always kicking in for those views.

- Tightened form spacing (_new_form separation 16->10, field height
  68->56, panel_content separation 24->16, page margins 56->28) to
  shrink how much height each view actually needs.
- Size the panel to the tallest view's real requirement (1000/1010),
  clamped to whatever height the window actually has so a short window
  shrinks the panel - and lets the ScrollContainer take over as a
  fallback - instead of the panel overflowing off-screen.

Verified headlessly across 1920x1080, 1366x768, and a narrow
compact-desktop width: no scrollbar in any mode at the two common
resolutions, and the panel now stays within the window (falling back
to scrolling only for the two tallest forms on the 768px-tall case,
rather than clipping off both edges of the screen as a fixed height
would have).
2026-07-25 00:52:12 -05:00
hz 191aa685fa Fix shadowed category_label warning in the report-question form
A local category_label in _build_report_modal() shadowed the
class-level category_label field (the question's topic tag near the
stem). Renamed the local to report_category_label.
2026-07-25 00:52:02 -05:00
hz 81e543fdf5 Merge pull request 'Main' (#6) from main into production
Reviewed-on: admin/step3#6
2026-07-25 00:39:14 -05:00
hz 65ae99e2ce Update exe boot splash to the current shield logo
boot_splash/image was still on ranaze-loader-icon-v1.png, the mark
from before the app rebranded to the shield "RZ" logo (c91e70d). Point
it at assets/branding/ranaze-mark.png, the same mark already used for
the in-app header brand throughout the current UI.
2026-07-25 00:31:57 -05:00
hz b7b290c63b Fix deployment_environment default so the debugger reads UAT
project.godot's API URLs already pointed at UAT (us3q.ranaze.com), but
deployment_environment itself was still "development" - a leftover
label that never matched the actual origin. Since the UAT-only feature
gates (Dynamic Questions, CCS Cases, Flashcards) check for the literal
"uat" value, running from the Godot editor/debugger hid all three even
though it's UAT in every other respect.

Set it to "uat" to match reality, and update the two places that
sed-patch this value at build time (web/Dockerfile for the UAT/PRD web
builds, tools/export_steam_release.sh for the Steam build) so they
still match the source string they're replacing.
2026-07-25 00:30:14 -05:00
hz c1a71b1192 Feature-gate Flashcards to UAT only
Same treatment as Dynamic Questions and CCS Cases: hide the nav
sidebar entry and the dashboard's "flashcards due" review card unless
step3/deployment_environment is "uat".
2026-07-25 00:30:06 -05:00
hz ca218a6ae5 Merge pull request 'Main' (#5) from main into production
Reviewed-on: admin/step3#5
2026-07-24 23:46:10 -05:00
hz f3a2e65627 Skip UAT/PRD deploys for changes that don't touch the deployable
Docs, local release tooling (tools/), and the GDScript test suite
(never executed by this pipeline, and irrelevant to the API image,
the exported web build, or k8s manifests) don't need a full deploy.
workflow_dispatch still allows a manual run for any of these if ever
needed.
2026-07-24 23:36:09 -05:00
hz bca31bd142 Add a Steam release export script pointed at production
project.godot stays checked in against UAT (us3q.ranaze.com) so the
Godot editor/debugger keeps testing against UAT. This script patches a
local copy to prd/s3q.ranaze.com just for the Windows Desktop export -
the same swap web/Dockerfile already does for UAT vs PRD web builds -
runs the export, then always restores the original file.

Verified: after a run, `git diff project.godot` is clean and the
exported .pck contains s3q.ranaze.com with no trace of us3q.ranaze.com.
2026-07-24 23:32:48 -05:00
hz 0e473343d0 Fix stale web_responsive_test icon assertion
It checked for a "DynamicQuestionsIcon" mode card that no longer
exists in study_selector.gd (that mode moved to its own nav item and
screen a while back), so it always failed. CCS Cases is now also
UAT-gated and won't be present in the default test environment either,
so point the check at Question Bank, the one mode card that's always
rendered.
2026-07-24 23:17:59 -05:00
hz 6df0a88fd4 Feature-gate Dynamic Questions and CCS Cases to UAT only
Both are still in progress and shouldn't be reachable in production
(or local dev) yet - hide every entry point (nav sidebar, the
dashboard's "dynamic concepts due" review card, and the CCS Cases
mode card on the study selector) unless step3/deployment_environment
is "uat", the same setting that already drives the UAT badge. Added
Brand.is_uat_environment() as the shared check.
2026-07-24 23:12:47 -05:00
hz 118c215608 Add a setting to turn off follow-up questions
The optional "FOLLOW-UP QUESTIONS" reinforcement section shown during
answer review is now gated behind a Settings toggle (on by default),
persisted locally the same way as dark mode and interface sounds.
2026-07-24 23:01:06 -05:00
hz cd1648fe38 Shorten "Skip question" button to "Skip" 2026-07-24 22:14:24 -05:00
hz f1b290aa73 Dock the question toolbar in line with the brand row
The toolbar had followed the progress group down into the row below
the branding after it moved there - readers expect actions docked to
the top brand row, not hanging off the question count.
2026-07-24 22:10:49 -05:00
hz 50f54b1bec Polish question player header/toolbar and add configurable block size
- Fix the toolbar wrapping onto two rows: its minimum width was tuned
  for an older, narrower button set and never widened back out after
  reverting to bare icon buttons.
- Drop the "Time elapsed"/"Ctrl+Enter" readout next to Submit - it was
  clutter; the underlying timer logic keeps running unseen.
- Move the RanaZe brand mark above the question-count row instead of
  sharing a line with it.
- Progress text drops the word "Question" everywhere ("1 of 10"), and
  core/dynamic sessions now cycle against a configurable block size
  (Settings > Question block size, default 10) instead of counting up
  forever.
2026-07-24 21:57:03 -05:00
hz 62e65fd22c Add Windows Desktop export with RanaZe branding
Replace the default Godot boot splash and exe icon with the RanaZe
mark so a Windows build reads as RanaZe from first launch, not the
engine it's built on.
2026-07-24 21:56:53 -05:00
hz 5197234210 Drop toolbar text labels in favor of hover tooltips
Varying label widths (especially when a button hides itself, e.g.
"Labs" on a question with no lab results) broke even spacing between
icons. Every toolbar button already has a tooltip_text carrying the
same label as hover text, so drop the labels/wrapper containers
entirely and go back to plain, uniformly-spaced 44x44 icon buttons.
2026-07-24 21:05:34 -05:00
hz 576b48d581 Fix toolbar gaps and theme toggle shape
- A toolbar icon that hides itself at runtime (labs, notes,
  flashcard, bookmark) left its text label behind, showing as an
  orphaned label floating in a gap. The label's wrapper now tracks
  the button's own visibility.
- The theme toggle used a distinct circular pill shape; changed it
  to match the same rounded-square box every other toolbar icon uses.
2026-07-24 19:55:49 -05:00
hz d073f82a76 Fit the toolbar on a single row
The 11 labeled groups are wider than the old bare-icon buttons, so
the toolbar's allotted minimum width (tuned for the old layout) was
too narrow and forced a wrap onto two rows. Widened it and raised
the compact-mode breakpoint to match.
2026-07-24 03:23:14 -05:00
hz a94cc5188f Fix bottom-bar time caption for core sessions
Core sessions show a per-question stopwatch that counts up (no fixed
deadline, just a pacing signal), not a countdown - it was incorrectly
labeled "Time remaining" like the simulation/Timed countdown; now
reads "Time elapsed" to match what the number actually does.
2026-07-24 03:16:30 -05:00
hz 0902cc238a Un-collapse the question toolbar - show every action directly
The 6-item + "More" overflow popup made the toolbar feel sparse and
hid frequently-used actions (flag, report, flashcard, theme) behind
an extra click. Reverting to a flat toolbar where every action is
directly visible and labeled, including text size (grouped A-/A+
under one "Text size" caption instead of behind a popup).
2026-07-24 03:03:40 -05:00
hz 974de67032 Redesign question bank screen to match new mockup
Adapts the quiz screen's header, toolbar, choice cards, and bottom bar
to the designer's new layout:

- Header: adds the RanaZe logo/wordmark and a question-progress bar +
  percentage alongside the existing "Question N of M" text.
- Toolbar: consolidates 12 icon-only/chip buttons into 6 labeled items
  (Text size, Calculator, Clear highlight, Notes, Bookmark, More), with
  a new "More" popup holding the rest (labs, flag, report, flashcard,
  theme toggle, feedback). The underlying buttons are reparented, not
  duplicated, so every existing theming/visibility/state call site
  keeps working unmodified.
- Category tag: shows the question's discipline (falling back to
  system) above the stem.
- Choice cards: every choice now renders as a subtly-bordered rounded
  card even before answering, not just a flat divider row; the
  currently-selected pre-submit choice shows a checkmark.
- Bottom bar: merges the Skip/Submit row and the timer row into one -
  Skip as a plain link, a centered "X of Y" progress bar, and a
  time slot that shows the existing countdown when a limit applies or
  a new count-up stopwatch ("Time elapsed") otherwise, plus a
  Ctrl+Enter/Cmd+Enter submit shortcut with a visible hint.
2026-07-24 02:28:22 -05:00
hz edcc0def5d Fix CCS category order search 2026-07-23 23:14:38 -05:00
hz b9d4687b1b Fix dynamic Elo clamp updates 2026-07-23 23:01:52 -05:00
hz 0ec486f132 Stabilize UAT API pod selection 2026-07-23 17:03:02 -05:00
hz 32c455bba0 Improve dynamic question learning loop 2026-07-23 16:50:53 -05:00
hz c91e70deb5 Update app branding to the new shield "RZ" logo
Replaces the OS/window icon with the new dark-background logo design,
and derives a transparent shield-mark cutout (via chroma-key against
the flat background) for the in-app login/sidebar mark and the web
loading-screen logo. Brand.ranaze_mark_texture() no longer needs the
AtlasTexture crop since the new source asset is already tightly
cropped to the mark.
2026-07-23 16:24:49 -05:00
hz fae38d2ded Adapt dynamic question difficulty to learner Elo 2026-07-23 16:18:44 -05:00
hz 5b5741de37 Show selection percentages on unselected distractors during review
Previously only the correct choice and any choice the student actually
selected got a percentage badge once reviewing; never-selected
distractors were left blank. Now every choice shows its selection
percentage as soon as the question is answered, matching the earlier
change that made unselected distractors render red like the rest of
the review UI.
2026-07-23 15:54:39 -05:00
hz b1b83dae05 Fix dynamic mastery first-attempt upsert 2026-07-23 15:25:06 -05:00
hz 332da7c181 Fix stale-node crash in UI sound auto-wiring
node_added already fires once per node as it enters the tree (including
every descendant of a newly-added subtree), so the recursive call_deferred
in _on_node_added was redundant and held a raw Node reference across a
frame boundary. If that node got freed before the deferred call fired
(e.g. a container torn down and rebuilt the same frame), the engine's
message queue couldn't marshal the argument and threw "Cannot convert
argument 1 from Object to Object". Wiring synchronously removes the
window entirely.

Also silences the intentional integer division in _duration_text,
matching the same annotation already applied to _elapsed_simulated_text.
2026-07-23 15:13:56 -05:00
hz 48f404518f Fix GDScript editor warnings (shadowed vars, unused param, integer division)
Renames locals/params that shadowed base-class properties (Node.name,
Control.size, BoxContainer.alignment), prefixes an intentionally-unused
parameter, and annotates a deliberate integer division so the console
stays clean during development.
2026-07-23 14:30:48 -05:00
hz 4a4bed2ac5 Add missing lucide/link.svg icon
The connected-accounts "Connect" button in progress_profile.gd
referenced res://assets/icons/lucide/link.svg, which didn't exist,
causing a resource-load error whenever the Steam/Google linking row
rendered.
2026-07-23 14:26:26 -05:00
hz 88688d3eb0 Add Steam and Google brand icons to login OAuth buttons
Steam uses a theme-aware monochrome glyph (light/dark variants) since
the button background flips between navy and white; Google keeps its
official 4-color mark, which reads fine on either background.
2026-07-23 14:19:17 -05:00
hz 92c5688b55 Upgrade dynamic questions to authored parity 2026-07-23 14:11:18 -05:00
hz e174b5db15 Return dynamic question runs to dashboard 2026-07-23 13:52:24 -05:00
hz 403944b249 Assign IDs when promoting dynamic blueprints 2026-07-23 13:40:53 -05:00
hz 8390d795e5 Publish dynamic questions during UAT deploy 2026-07-23 13:30:22 -05:00
hz 6a6e703aa4 Fix dynamic questions sidebar icon 2026-07-23 13:19:32 -05:00
hz fcbc288a49 Add data-driven dynamic question mode 2026-07-23 12:33:23 -05:00
hz 50f07a77a5 Add exam-style question calculator 2026-07-23 05:19:12 -05:00
hz ba155fd38a Fix distractor explanations rendering on unanswered questions
Guarded the answer-review render path (teaching cards, review
follow-ups, distractor explanations) on current_answer/pending_correct_advance
directly rather than solely on correct_review_panel.visible, and added
the same guard inside _render_unselected_distractor_explanations
itself. This closes a path where a fresh, never-answered question
could show wrong-answer explanations immediately, before any
selection or submission.

Also: unselected distractors now render in the same red "wrong" card
style as selected/locked-wrong choices once a question is being
reviewed, instead of staying in the plain default row style. And
removed the redundant percentage badge that was floating above the
in-progress follow-up quiz prompt, since the choice button above it
already shows the same percentage.
2026-07-23 05:07:27 -05:00
hz 8e216eac32 Rebuild CCS case workflow 2026-07-23 04:43:02 -05:00
hz 489b9c3d7c Fix text zoom not actually syncing, and expose it in Settings
Two real gaps meant the account-synced zoom (from the previous
"sync through the account" change) never reached the screen:
_load_cache() updated AccountProfileService.preferences but never
emitted profile_changed, so question_player.gd/ccs_service.gd (already
built and listening before login even happens) never found out a
cached value had loaded. And nothing forced a server refresh
right after login - it only fired if the user happened to open the
profile/progress screen, so a fresh device/session had no path to
the true synced value at all.

main.gd now calls AccountProfileService.refresh() once per login
(not on every silent token refresh) so the real preferences GET
always runs right after signing in, on any device.

Also added Question bank/CCS case text size steppers to the Settings
screen next to dark mode, per user request, reusing the same
AccountProfileService/CCSService plumbing as the in-screen A-/A+
buttons.
2026-07-23 04:37:45 -05:00
hz b3a8dec379 Simplify resolved wrong-answer explanation cards per design feedback
Drop the redundant header row (duplicate percentage badge + collapse
chevron), the "Why this is appropriate/less appropriate:"/"Why this
is incorrect:" heading labels, and the nested sub-box - now that
these cards live inside their own choice's colored border, all of
that was repeating what the color and position already say. Both the
selected-wrong-choice resolved follow-up and the never-selected
distractor cards now just show the plain explanation sentence, with
the leading "Correct."/"Incorrect." verdict word stripped too.

Removing the toggle chevron removes the only way to reach the
collapse/expand behavior, so that machinery (_collapse_resolved_followups,
_toggle_followup_state, the "collapsed" state field) comes out too
rather than linger unreachable.
2026-07-23 04:19:10 -05:00
hz d69a1fd0e3 Sync text zoom preference through the account instead of local settings
Local user://settings.cfg meant zoom level didn't follow a student
across devices/browsers. Question bank and CCS text scale now live on
the user's study profile in Mongo (QuestionTextScale/CcsTextScale),
synced through a new PUT /api/profile/me/text-scale endpoint and the
existing preferences GET/cache already used for study preferences.

Both question_player.gd and ccs_service.gd read the synced value at
startup and react to AccountProfileService.profile_changed so a value
pulled down after login (or changed from another session) applies
without a restart. Backend validates the 0.85-1.35 range and updates
only the field(s) provided via a targeted $set, so it can't clobber
the other scale or the unrelated study-profile preferences.
2026-07-23 03:59:02 -05:00
hz b88f38f29b Persist text zoom level across sessions
Question bank and CCS case text-size (A-/A+) previously reset to 1.0
on every app launch. Both now load their saved scale from
user://settings.cfg at startup and save on every adjustment, mirroring
the existing pattern used for dark/light mode and sound preferences.
2026-07-23 03:42:17 -05:00
hz c62eff8846 Auto-flag questions with media at write time, not just via one-time sweep
The flag-media-questions CLI command only caught questions that
existed in the DB when it ran once. Any question created, imported,
or edited afterward could add media without ever being flagged for
review, since nothing gated new writes going forward. CreateAsync,
UpdateAsync, and UpsertManyByExternalIdAsync now all run new/updated
questions through ApplyMediaReviewGate, which flags any question that
has media and isn't already flagged.
2026-07-23 03:29:57 -05:00
hz 59ffaf4376 Move distractor explanations inline instead of a repeated bottom section
The "WHY OTHER OPTIONS ARE INCORRECT" aggregate section duplicated
explanations that selected wrong choices already showed inside their
own resolved follow-up quiz. Removed that section entirely; choices
the user never picked now get their "why incorrect" explanation
docked directly inside their own card (reusing the same followup
panel/attached-card styling), so every distractor's explanation lives
with its choice instead of in a separate list at the bottom.
2026-07-23 02:01:29 -05:00
hz 4db5b9d374 Drop the redundant status icon and "Review: B..." label from follow-up panels
The choice button above the panel already shows the letter badge,
choice text, and percentage, so repeating them in the panel header
was redundant now that the panel is docked inside the choice's card.
Keeps the collapse toggle, now named FollowupToggle since the old
FollowupSummary button it lived on is gone.
2026-07-23 01:47:11 -05:00
hz 2a2ff29389 Remove redundant "Correct Answer: X" heading from the review panel
Now that the explanation panel is docked inside the correct choice's
own green card, the heading was redundant with the badge/border color
already conveying it's the correct answer.
2026-07-23 01:42:31 -05:00
hz 5ba7af9d3e Redesign flag/report icons as vector assets and dock review panels inside their choice card
Flag question now uses a clean vector flag (dark/light SVG variants,
replacing a hand-drawn pixel-art icon that got muddied by the shared
toolbar icon-tint override). Report question is now a megaphone
instead of a second flag glyph, since both toolbar buttons looked
identical before.

The correct-answer explanation and the wrong-answer follow-up quiz
previously rendered as separate bordered boxes sitting below the
entire choice list. Both now dock inside their own choice's card:
the explanation panel reparents onto the correct choice's row, the
follow-up panel was already there but visually detached. A shared
_attached_panel_box style drops the shared border/corner between the
choice button and its panel so they read as one continuous card.
2026-07-23 01:23:53 -05:00
hz b822fe5314 Redesign clear-highlight icon as an eraser with sparkles
Also drop a stale custom_minimum_size(0, 54) reset left over in
_set_choice_button_text from before the answer row redesign - it was
silently overriding the button's real 68px minimum height on every
question load and on every elimination/strikethrough toggle, which is
why choice rows never actually got taller and why striking a choice
through visibly shrank its card.
2026-07-23 00:42:50 -05:00
hz 5f3289bb8e Vertically center the question+choices block before an answer is submitted
Fixes the large empty gap below the answer choices reported after the
answer-row redesign - the stem+choices VBox was pinned to the top of an
expand-filled scroll panel with all the leftover height sitting unused
below choice E. Now centers that block when unanswered and reverts to
top alignment once the explanation/review content grows underneath it.
2026-07-23 00:33:52 -05:00
hz 9d18704ad0 Render answer-choice badges as SVGs and give rows more breathing room
StyleBoxFlat-drawn circles kept looking rough at this size even after
tuning corner detail/antialiasing, so the badges are now two small SVG
assets (outline and filled) tinted per-state via modulate instead -
crisp regardless of size. Also increased row height, badge size, and
margins across the board; the previous pass's padding bump was too
timid and the rows still read as cramped.
2026-07-23 00:14:49 -05:00
hz aef287ac26 Fix pixelated circle badges with a seam through the center
icon_circle_box's perfectly-circular StyleBoxFlat (corner radius equal
to half the box size, no straight edges between corners) is a known
Godot degenerate case that draws a faint seam through the middle when
antialiasing is on and looks faceted at low corner_detail. Bumping
corner_detail and disabling antialiasing renders a clean circle at
these small badge/icon sizes instead - affects every icon_circle_box
call site (answer badges, stat tiles, avatars, etc.), not just the
question player.
2026-07-22 23:48:00 -05:00
hz 6c388dfa5e Redesign answer choice rows into a cleaner divided list
Unselected/eliminated choices now sit in a flat list with a thin
bottom divider instead of each getting its own bordered box, and only
the selected/correct/wrong choice pops out as a rounded, tinted card -
closer to the reference mockup and less visually busy than every
option looking like an separate boxed card.
2026-07-22 23:18:13 -05:00
hz b56d48d570 Fix eliminated-choice strikethrough disappearing on wrong/correct reveal
A choice that was crossed out (right-click) and also happened to be
locked as the submitted-wrong choice, or revealed as the correct
answer, silently lost its strikethrough because the percentage-badge
branches took precedence over the elimination check in the same
if/elif chain. Decoupled the two so a choice can show both the
strikethrough and its reveal percentage at once.
2026-07-22 23:10:41 -05:00
hz f17797becc Don't show the session-expiry warning to actively-used sessions
The warning was purely a token-expiry countdown with no concept of
user activity, so it interrupted people who had been clicking through
questions the whole time. AuthState now tracks real input activity
globally and silently refreshes the session in the background when
the user has interacted recently, only surfacing the "Stay logged in?"
dialog for sessions that are genuinely idle. A failed background
refresh still surfaces the dialog rather than silently logging the
user out.
2026-07-22 22:52:38 -05:00
hz 9f62444a63 Add a review-flag state for questions, independent of publish status
Flagged questions disappear from every student-facing surface (search,
exam simulation sampling, the missed-question review queue, bookmarks,
direct fetch) while staying published - flagging and unpublishing are
now separate, reversible actions with their own audit trail (who
flagged it, when, why). Backend-only for now: SUPER-only flag/unflag/
list endpoints, no client UI. Includes a one-time CLI sweep
(flag-media-questions) to flag every existing question with an image
so they can be reviewed.
2026-07-22 22:42:19 -05:00
hz e8d615985b Reveal the flashcard answer by tapping the card, not a separate button
Matches the mockup: the "Show Answer" button is replaced by an in-card
"Tap card to reveal answer" hint, and clicking anywhere on the card
reveals it (guarded the same way Space already was - only while at the
live card and not yet revealed).
2026-07-22 17:21:23 -05:00
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
hz 3a01e9156c Keep the app sidebar visible on the flashcards study screen
The study screen was still treated as a fullscreen immersive view (like
the question player), hiding the main nav sidebar entirely. The mockup
and the user's request both call for keeping the app's left menu while
studying, with the deck panel living inside the screen alongside it.
2026-07-22 01:20:48 -05:00
hz 09b670d5d4 Close the gap between the flashcards study screen and the mockup
Add real session-history navigation (Previous/Next/Shuffle), compact
side panels, and a segmented progress row to match the mockup layout.
Flashcards now opens straight into Study, with library management
moved into the Options menu ("Manage Flashcards") and Export hidden
from the UI (Import stays). Shuffle is backed by a real, due-before-new
randomized pick on the backend, not a decorative toggle.
2026-07-22 00:43:52 -05:00
hz 70aa425fde Rebuild flashcard study screen from mockup, with real backend-backed stats
Study screen now matches the provided mockup's layout: a "Current
Deck" panel with a circular mastery-progress ring, and a "Spaced
Repetition" panel (New/Due/Learning/Review counts, streak, daily
goal) on either side of the card. Kept the app's real global sidebar
rather than the mockup's own nav, per direction.

Mastery/Streak/Daily Goal weren't tracked anywhere, so this adds real
backend support rather than showing fabricated numbers:
- Mastered = graduated to review state AND FSRS stability >= 21 days
  (FlashcardScheduleStore.MasteryStabilityDaysThreshold), stricter
  than the plain Review-state count
- CurrentStreakDays = consecutive UTC days with a review, computed
  from review-log timestamps in the new pure FlashcardScheduling
  .ComputeStreakDays (unit tested, habit-streak semantics: doesn't
  reset to 0 the instant a new day starts before today's first review)
- DailyGoal/ReviewsToday reuse the account's existing ReviewsPerDay
  setting rather than inventing a new one
Also removes the exit "X" button in favor of the same Escape/pause
menu the question-player flow uses (main.gd's shared app_menu, with
an "Exit Study Session" action), and adds a new circular_progress.gd
reusable ring control plus a real "Options" menu (bury/suspend the
current card) wired to schedule endpoints that had no UI before.
2026-07-21 23:20:14 -05:00
hz 72fb6e4776 Rework flashcards library to match the rest of the app's visual language
Compared side-by-side against Dashboard, the library screen used
entirely different visual primitives: a gradient primary button
Dashboard doesn't use anywhere, per-row bordered cards with low
contrast against the dark background, and a translucent pill badge
instead of the icon-circle pattern used everywhere else (stat tiles,
review queue, recent activity).

- Study/Save/Commit import now use Dashboard's actual primary button
  style (flat ROYAL_BLUE, not the login/session-dialog gradient)
- Added a stat-tile row (Due today / New available / Decks) using
  Dashboard's exact icon-circle + value + label construction, replacing
  the due/new counts previously crammed into the deck dropdown's text
- The note list now lives in one bordered card with a section title
  (matching Dashboard's chart/list panels) instead of each row being
  its own separate low-contrast card
- Per-row card-type indicator is now an icon circle (clipboard-list /
  notebook-pen) matching the Recent Activity list pattern, instead of
  a translucent color-coded pill badge
2026-07-21 22:47:21 -05:00
hz 109581874d Fix invisible divider/caption colors on the study card's first paint
refresh_theme_controls() colored the divider and the ANSWER/KEY
TAKEAWAY captions, but it only ran on an actual theme toggle - never
at initial build. Confirmed live: the divider was invisible (default
white on a white card) and the captions rendered as plain body text
instead of the intended accent color until you toggled dark mode
once. Call refresh_theme_controls() once at the end of _build() so
the correct colors apply immediately, which also fixes the same
latent gap in empty_label's color.
2026-07-21 22:18:24 -05:00
hz 7e19366dcb Redesign flashcards for visual hierarchy and button consistency
Study card: label the answer and "key takeaway" as distinct sections
with a real visible divider (previously an all-but-invisible
HSeparator) instead of one undifferentiated block of text. Also fixes
a real gap where a cloze card's extra field was silently dropped,
since only basic notes combined answer+extra into one label. Bigger
card, gradient Show-Answer button matching the app's signature CTA
style instead of a duller flat teal, and the Undo button now hides
itself when there's nothing to undo instead of sitting there disabled
looking identical to a real action.

Library: colored type badge per card (Basic/Cloze) instead of plain
gray text, more breathing room inside each row, Study promoted to the
same gradient CTA style, and Edit/Archive given a real secondary
button treatment instead of default theme buttons. Note editor fields
renamed ("Back / Extra takeaway" + a separate "Extra" field was
genuinely confusing) and now show which deck a card is being saved to.
2026-07-21 22:05:36 -05:00
hz b00b321d6f Fix Import/Export dropdowns not dismissing
They only closed via their own trigger button or a menu item -
clicking outside, pressing Escape, or opening the other dropdown
while one was open left it stuck on screen (confirmed opening both
stacked on top of each other). Add a shared invisible backdrop
(mirroring main.gd's drawer-backdrop pattern) that closes whichever
dropdown is open on outside click or Escape, and have opening one
close the other first.
2026-07-21 17:37:51 -05:00
hz 15e9f9779e Fix flashcard study/next 400s from a malformed studyDayStartUtc
current_study_day_start_utc() passed use_space=true to
Time.get_datetime_string_from_unix_time(), producing "2026-07-21
00:00:00Z" (space separator) instead of ISO-8601. The server's
System.Text.Json DateTime converter rejects that with a 400, which
the client surfaces as a generic "could not be loaded" error -
blocking every study session. Confirmed against the live API: the
space-separated value 400s, the corrected T-separated value returns
a card.
2026-07-21 17:02:51 -05:00
hz ace04ab268 Fix low-contrast session-expiry dialog in dark mode
The panel's shared Brand.panel_box background is ~80% opaque, which
compounded with the dialog's own dim backdrop let the screen behind
show through and washed out the card against it. Force full opacity
on this dialog's panel specifically, since other panel_box overlays
still rely on the shared translucency.
2026-07-21 16:47:54 -05:00
hz 4dfbd811b9 Wire flashcard CSV/JSON import/export and fix theme/responsive gaps
Import and export were stubbed with status-text placeholders; wire them
to the existing backend endpoints with a real file picker (web JS
bridge or native FileDialog) and browser/desktop download, plus an
import preview with create/update/skip/error counts before commit.

Also fix the library and study screens' theme-refresh handlers, which
were no-op stubs so cards never restyled on a theme toggle, and add
responsive layout handling for narrow viewports.
2026-07-21 16:41:26 -05:00
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
hz 29b89dc404 Remove redundant follow-up retry action 2026-07-20 23:59:07 -05:00
hz c2d000cc48 Add automated UAT smoke tests 2026-07-20 23:54:11 -05:00
224 changed files with 17110 additions and 1410 deletions
+42 -1
View File
@@ -34,7 +34,7 @@ docker system df
df -h
docker build -f api/QuestionsApi.Tests/Dockerfile -t "${TEST_IMAGE}" .
docker build --build-arg BUILD_REVISION="${BUILD_TAG}" -t "${API_IMAGE}" api/QuestionsApi
docker build --build-arg BUILD_REVISION="${BUILD_TAG}" -f api/QuestionsApi/Dockerfile -t "${API_IMAGE}" .
docker build \
--build-arg BUILD_REVISION="${BUILD_TAG}" \
--build-arg DEPLOYMENT_ENVIRONMENT="${DEPLOYMENT_ENVIRONMENT}" \
@@ -70,7 +70,48 @@ kubectl kustomize "k8s/overlays/${DEPLOYMENT_ENVIRONMENT}" \
kubectl rollout restart deployment/step3-api -n "${KUBE_NAMESPACE}"
kubectl rollout status deployment/step3-api -n "${KUBE_NAMESPACE}" --timeout=240s
kubectl get --raw "/api/v1/namespaces/${KUBE_NAMESPACE}/services/step3-api:http/proxy/readyz" >/dev/null
if [ "${DEPLOYMENT_ENVIRONMENT}" = "uat" ]; then
# A completed old replica can briefly remain visible immediately after a
# rollout. Select the newest pod that is both Running and container-ready
# instead of relying on the first item returned by Kubernetes.
PROMOTION_SUCCEEDED=0
for attempt in 1 2 3; do
API_POD="$(kubectl get pods -n "${KUBE_NAMESPACE}" -l app=step3-api \
--field-selector=status.phase=Running \
--sort-by=.metadata.creationTimestamp \
-o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.status.containerStatuses[0].ready}{"\n"}{end}' \
| awk '$2 == "true" { name = $1 } END { print name }')"
if [ -n "${API_POD}" ] \
&& kubectl wait -n "${KUBE_NAMESPACE}" --for=condition=Ready "pod/${API_POD}" --timeout=60s \
&& kubectl exec -n "${KUBE_NAMESPACE}" "${API_POD}" -- \
dotnet QuestionsApi.dll promote-dynamic-questions \
--source /app/content/dynamic_questions \
--apply; then
PROMOTION_SUCCEEDED=1
break
fi
echo "Dynamic question promotion attempt ${attempt} did not find a stable ready API pod; retrying." >&2
sleep 2
done
if [ "${PROMOTION_SUCCEEDED}" -ne 1 ]; then
echo "Dynamic question promotion failed after 3 attempts." >&2
exit 1
fi
fi
kubectl rollout status deployment/step3-web -n "${KUBE_NAMESPACE}" --timeout=240s
kubectl get --raw "/api/v1/namespaces/${KUBE_NAMESPACE}/services/step3-web:http/proxy/healthz" >/dev/null
if [ "${DEPLOYMENT_ENVIRONMENT}" = "uat" ]; then
# The images are already imported on every Kubernetes node. Reclaim runner
# space before pulling the Playwright browser image.
docker image rm "${API_IMAGE}" "${WEB_IMAGE}" "${TEST_IMAGE}" 2>/dev/null || true
docker builder prune -af >/dev/null || true
UAT_API_ORIGIN="${PUBLIC_API_ORIGIN}" \
UAT_WEB_ORIGIN="${PUBLIC_WEB_ORIGIN:-https://ustep3.ranaze.com}" \
EXPECTED_REVISION="${BUILD_TAG}" \
bash .gitea/scripts/run-uat-smoke.sh
fi
echo "Deployed ${DEPLOYMENT_ENVIRONMENT} revision ${BUILD_TAG}."
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
: "${UAT_API_ORIGIN:=https://us3q.ranaze.com}"
: "${UAT_WEB_ORIGIN:=https://ustep3.ranaze.com}"
IMAGE_TAG="step3-uat-smoke:${GITHUB_RUN_ID:-manual}-${GITHUB_RUN_NUMBER:-0}"
ARTIFACT_DIR="${SMOKE_ARTIFACT_DIR:-artifacts/uat-smoke}"
ARTIFACT_ABS="$(pwd)/${ARTIFACT_DIR}"
cleanup() {
docker image rm "${IMAGE_TAG}" >/dev/null 2>&1 || true
}
trap cleanup EXIT
rm -rf "${ARTIFACT_DIR}"
mkdir -p "${ARTIFACT_DIR}"
docker build -f tests/smoke/Dockerfile -t "${IMAGE_TAG}" tests/smoke
docker run --rm --ipc=host \
-e UAT_API_ORIGIN="${UAT_API_ORIGIN}" \
-e UAT_WEB_ORIGIN="${UAT_WEB_ORIGIN}" \
-e EXPECTED_REVISION="${EXPECTED_REVISION:-}" \
-v "${ARTIFACT_ABS}:/work/test-results" \
"${IMAGE_TAG}"
+4
View File
@@ -4,6 +4,10 @@ on:
push:
branches:
- production
paths-ignore:
- '**.md'
- 'tools/**'
- 'tests/**'
workflow_dispatch: {}
concurrency:
+14
View File
@@ -4,6 +4,10 @@ on:
push:
branches:
- main
paths-ignore:
- '**.md'
- 'tools/**'
- 'tests/**'
workflow_dispatch: {}
concurrency:
@@ -32,6 +36,7 @@ jobs:
DEPLOYMENT_ENVIRONMENT: uat
KUBE_NAMESPACE: step3-uat
PUBLIC_API_ORIGIN: https://us3q.ranaze.com
PUBLIC_WEB_ORIGIN: https://ustep3.ranaze.com
MONGODB_CONNECTION_STRING: ${{ secrets.UAT_MONGODB_CONNECTION_STRING }}
AUTH_JWT_KEY: ${{ secrets.UAT_AUTH_JWT_KEY }}
INTERNAL_PURGE_SECRET: ${{ secrets.UAT_INTERNAL_PURGE_SECRET }}
@@ -40,3 +45,12 @@ jobs:
GOOGLE_CLIENT_ID: ${{ secrets.UAT_GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.UAT_GOOGLE_CLIENT_SECRET }}
run: bash .gitea/scripts/deploy-environment.sh
- name: Upload UAT smoke-test artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: uat-smoke-${{ github.run_number }}-${{ github.run_attempt }}
path: artifacts/uat-smoke
if-no-files-found: warn
retention-days: 14
+12 -13
View File
@@ -1,4 +1,4 @@
name: Promote UAT content to PRD
name: Promote ready UAT questions to PRD
on:
workflow_dispatch:
@@ -13,7 +13,7 @@ on:
default: ""
concurrency:
group: step3-content-promotion
group: step3-question-promotion
cancel-in-progress: false
jobs:
@@ -45,26 +45,26 @@ jobs:
test "$CONFIRMATION" = "PROMOTE_TO_PRD"
fi
- name: Configure promotion credentials
- name: Configure question promotion credentials
env:
SOURCE_CONNECTION: ${{ secrets.UAT_MONGODB_CONNECTION_STRING }}
TARGET_CONNECTION: ${{ secrets.PRD_MONGODB_CONNECTION_STRING }}
run: |
test -n "$SOURCE_CONNECTION"
test -n "$TARGET_CONNECTION"
kubectl create secret generic step3-content-promotion \
kubectl create secret generic step3-question-promotion \
--from-literal=source-connection="$SOURCE_CONNECTION" \
--from-literal=target-connection="$TARGET_CONNECTION" \
--dry-run=client -o yaml | kubectl apply -n step3-prd -f -
- name: Run promotion
- name: Run ready-question promotion
env:
MODE: ${{ inputs.mode }}
CONFIRMATION: ${{ inputs.confirmation }}
run: |
IMAGE="$(kubectl get deployment step3-api -n step3-prd -o jsonpath='{.spec.template.spec.containers[0].image}')"
REVISION="${IMAGE##*:}"
POD="content-promotion-${GITHUB_RUN_NUMBER}"
POD="question-promotion-${GITHUB_RUN_NUMBER}"
APPLY_ARGS=""
if [ "$MODE" = "apply" ]; then
APPLY_ARGS="--apply --confirm $CONFIRMATION"
@@ -76,7 +76,7 @@ jobs:
name: ${POD}
namespace: step3-prd
labels:
app: step3-content-promotion
app: step3-question-promotion
spec:
restartPolicy: Never
containers:
@@ -86,7 +86,7 @@ jobs:
command: ["/bin/sh", "-c"]
args:
- >-
dotnet QuestionsApi.dll promote-content
dotnet QuestionsApi.dll promote-questions
--source-database ranaze_step3_uat
--target-database ranaze_step3_prd
--operator "${GITHUB_ACTOR:-gitea-operator}"
@@ -96,12 +96,12 @@ jobs:
- name: SOURCE_MONGODB_CONNECTION_STRING
valueFrom:
secretKeyRef:
name: step3-content-promotion
name: step3-question-promotion
key: source-connection
- name: TARGET_MONGODB_CONNECTION_STRING
valueFrom:
secretKeyRef:
name: step3-content-promotion
name: step3-question-promotion
key: target-connection
EOF
for attempt in $(seq 1 120); do
@@ -115,6 +115,5 @@ jobs:
- name: Remove promotion credentials and pod
if: always()
run: |
kubectl delete pod "content-promotion-${GITHUB_RUN_NUMBER}" -n step3-prd --ignore-not-found
kubectl delete secret step3-content-promotion -n step3-prd --ignore-not-found
kubectl delete pod "question-promotion-${GITHUB_RUN_NUMBER}" -n step3-prd --ignore-not-found
kubectl delete secret step3-question-promotion -n step3-prd --ignore-not-found
+30
View File
@@ -0,0 +1,30 @@
name: Smoke Test STEP3 UAT
on:
workflow_dispatch: {}
concurrency:
group: step3-uat-smoke
cancel-in-progress: false
jobs:
smoke:
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run UAT smoke tests
env:
UAT_API_ORIGIN: https://us3q.ranaze.com
UAT_WEB_ORIGIN: https://ustep3.ranaze.com
run: bash .gitea/scripts/run-uat-smoke.sh
- name: Upload smoke-test artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: uat-smoke-${{ github.run_number }}-${{ github.run_attempt }}
path: artifacts/uat-smoke
if-no-files-found: warn
retention-days: 14
+5
View File
@@ -10,3 +10,8 @@
# Local editor/user files
*.user
*.suo
# UAT smoke-test output
/artifacts/uat-smoke/
**/node_modules/
/tests/smoke/local-results/
+5 -5
View File
@@ -57,9 +57,9 @@ The hostname change creates a new browser origin, so testers should expect to si
1. Create and protect the `production` branch, then merge the tested `main` revision.
2. Route `step3.ranaze.com` and `s3q.ranaze.com` to PRD NodePorts.
3. Run **Promote UAT content to PRD** in `dry-run` mode and inspect the counts and hash.
3. Run **Promote ready UAT questions to PRD** in `dry-run` mode and inspect the insert, update, unchanged, extra, media, and hash values.
4. Run it again in `apply` mode with confirmation `PROMOTE_TO_PRD`.
5. Verify `ranaze_step3_prd` contains published content and has no user/activity documents.
5. Verify `ranaze_step3_prd` contains the published, unflagged UAT questions and has no user/activity documents.
6. Register the initial PRD account normally, then promote it manually:
```javascript
@@ -69,11 +69,11 @@ The hostname change creates a new browser origin, so testers should expect to si
7. Configure and test the live Stripe webhook and Steam callback before announcing PRD.
## Content promotion behavior
## Question promotion behavior
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only published questions, referenced media, current published CCS revisions, and the CCS order catalog. It never copies users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. Any promoted question containing media is flagged again in PRD and remains hidden from students until a SUPER user explicitly approves it in production. UAT media approval is not treated as production approval. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Every applied promotion writes a `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Its dry run reports inserts, updates, unchanged questions, and production extras. Apply mode verifies the promoted target records and writes a scoped `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
## Verification and rollback
+54
View File
@@ -8,6 +8,41 @@ namespace QuestionsApi.Tests;
public sealed class CcsCaseTests
{
[Fact]
public void NewCcsAttemptsAreTimedOnly()
{
Assert.Null(CcsCaseValidator.ValidateCreate(new CreateCcsAttemptRequest()));
Assert.Equal("new CCS attempts must be timed",
CcsCaseValidator.ValidateCreate(new CreateCcsAttemptRequest { Mode = CcsAttemptModes.Practice }));
}
[Fact]
public void CcsBatchCommandsAreBoundedAndHomogeneous()
{
var valid = new CcsBatchCommandRequest
{
CommandId = "batch-1",
Commands =
[
new() { Type = "exam", Code = "general" },
new() { Type = "exam", Code = "cardiac" }
]
};
Assert.Null(CcsCaseValidator.ValidateBatch(valid));
valid.Commands.Add(new() { Type = "order", Code = "bmp" });
Assert.Contains("cannot be mixed", CcsCaseValidator.ValidateBatch(valid), StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void CcsTimingReservesTwoMinutesWithoutExtendingTheHardDeadline()
{
Assert.Equal(18, CcsStore.ActivePhaseMinutes(20));
Assert.Equal(8, CcsStore.ActivePhaseMinutes(10));
var now = new DateTime(2026, 7, 23, 12, 0, 0, DateTimeKind.Utc);
Assert.Equal(now.AddMinutes(2), CcsStore.FinalOrdersDeadline(now, now.AddMinutes(5)));
Assert.Equal(now.AddMinutes(1), CcsStore.FinalOrdersDeadline(now, now.AddMinutes(1)));
}
[Fact]
public void CcsHistoryCursorRoundTripsTimestampAndId()
{
@@ -108,6 +143,25 @@ public sealed class CcsCaseTests
Assert.True(CcsRatingStore.ExpectedScore(800, 1000) < 0.25);
}
[Theory]
[InlineData("medication", "medication")]
[InlineData("medication", "medications")]
[InlineData("medication", "treatment")]
[InlineData("procedure", "procedures")]
[InlineData("laboratory", "tests")]
[InlineData("diagnostic", "study")]
public void CcsOrderSearchMatchesClinicalCategories(string category, string query)
{
var order = new CcsOrderDefinition
{
Code = "order",
CanonicalName = "Unrelated canonical name",
Category = category
};
Assert.True(CcsStore.Rank(order, query) < 1000);
}
[Fact]
public void ScoringReviewIncludesExpectedAndObservedEvidence()
{
+39
View File
@@ -148,6 +148,45 @@ public sealed class ChoicePoolTests
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void PublishedQuestionRequiresCompleteLabReferenceRanges()
{
var request = BuildRequest(5);
request.Labs =
[
new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" }
];
Assert.Contains("requires a reference range", QuestionValidator.Validate(request));
request.Labs[0].Reference = "136–145 mEq/L";
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void DraftQuestionMaySaveLabWithoutReferenceRange()
{
var request = BuildRequest(5);
request.IsPublished = false;
request.Labs =
[
new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" }
];
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void LabEntriesAlwaysRequireNamesAndValues()
{
var request = BuildRequest(5);
request.Labs = [new QuestionLab { Name = "", Value = "128", Reference = "136–145 mEq/L" }];
Assert.Contains("lab name and value", QuestionValidator.Validate(request));
request.Labs = [new QuestionLab { Name = "Sodium", Value = "", Reference = "136–145 mEq/L" }];
Assert.Contains("lab name and value", QuestionValidator.Validate(request));
}
[Theory]
[InlineData("http://example.org/guideline", "HTTPS")]
[InlineData("https://example.org/guideline", null)]
@@ -31,4 +31,167 @@ public sealed class ContentPromotionTests
ContentPromotionCommand.HashDocuments([first, second]),
ContentPromotionCommand.HashDocuments([second, first]));
}
[Fact]
public void ReadyQuestionEligibilityRequiresPublishedAndUnflagged()
{
Assert.True(QuestionPromotionCommand.IsEligible(new BsonDocument
{
["IsPublished"] = true,
["FlaggedForReviewUtc"] = BsonNull.Value
}));
Assert.True(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", true)));
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument
{
["IsPublished"] = true,
["FlaggedForReviewUtc"] = DateTime.UtcNow
}));
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", false)));
}
[Fact]
public void ProductionPromotionFlagsMediaForSeparateApproval()
{
var updated = new DateTime(2026, 7, 30, 12, 0, 0, DateTimeKind.Utc);
var source = new BsonDocument
{
["ExternalId"] = "media-question",
["IsPublished"] = true,
["FlaggedForReviewUtc"] = BsonNull.Value,
["UpdatedUtc"] = updated,
["Media"] = new BsonArray { ObjectId.GenerateNewId().ToString() }
};
var prepared = QuestionPromotionCommand.PrepareForProduction(source);
Assert.Equal(updated, prepared["FlaggedForReviewUtc"].ToUniversalTime());
Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewReason, prepared["FlagReason"].AsString);
Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewActor, prepared["FlaggedByUserId"].AsString);
Assert.True(source["FlaggedForReviewUtc"].IsBsonNull);
}
[Fact]
public void ProductionPromotionLeavesMediaFreeQuestionApprovalStateAlone()
{
var source = new BsonDocument
{
["ExternalId"] = "text-question",
["IsPublished"] = true,
["FlaggedForReviewUtc"] = BsonNull.Value,
["Media"] = new BsonArray()
};
var prepared = QuestionPromotionCommand.PrepareForProduction(source);
Assert.True(prepared["FlaggedForReviewUtc"].IsBsonNull);
Assert.False(prepared.Contains("FlagReason"));
}
[Fact]
public void QuestionMediaIdsSupportStoredApiUrlsAndRawIds()
{
var first = ObjectId.GenerateNewId();
var second = ObjectId.GenerateNewId();
var questions = new[]
{
new BsonDocument
{
["ExternalId"] = "one",
["Media"] = new BsonArray { $"/api/questions/media/{first}", second.ToString() }
},
new BsonDocument
{
["ExternalId"] = "two",
["Media"] = new BsonArray { $"https://example.test/api/questions/media/{first}" }
}
};
var result = QuestionPromotionCommand.CollectMediaIds(questions);
Assert.Equal(new HashSet<ObjectId> { first, second }, result);
}
[Fact]
public void InvalidLocalQuestionMediaReferenceFailsValidation()
{
var question = new BsonDocument
{
["ExternalId"] = "bad-media",
["Media"] = new BsonArray { "/api/questions/media/not-an-id" }
};
var exception = Assert.Throws<InvalidOperationException>(
() => QuestionPromotionCommand.CollectMediaIds([question]));
Assert.Contains("bad-media", exception.Message);
}
[Fact]
public void QuestionComparisonClassifiesInsertUpdateAndUnchanged()
{
var source = new[]
{
Question("insert", "new"),
Question("update", "new"),
Question("same", "same")
};
var target = new[]
{
Question("update", "old"),
new BsonDocument
{
["Stem"] = "same",
["ExternalId"] = "same",
["_id"] = ObjectId.GenerateNewId()
}
};
var result = QuestionPromotionCommand.ClassifyQuestions(source, target);
Assert.Equal(1, result.Inserted);
Assert.Equal(1, result.Updated);
Assert.Equal(1, result.Unchanged);
}
[Fact]
public void QuestionHashIgnoresMongoIdsFieldOrderAndInputOrder()
{
var first = Question("a", "one");
first["_id"] = ObjectId.GenerateNewId();
var second = Question("b", "two");
var equivalentFirst = new BsonDocument
{
["Stem"] = "one",
["_id"] = ObjectId.GenerateNewId(),
["ExternalId"] = "a"
};
Assert.Equal(
QuestionPromotionCommand.HashQuestions([first, second]),
QuestionPromotionCommand.HashQuestions([second, equivalentFirst]));
}
[Fact]
public async Task ReadyQuestionApplyRequiresExplicitProductionConfirmation()
{
using var output = new StringWriter();
var result = await QuestionPromotionCommand.RunAsync(
[
"--source-connection", "mongodb://source.invalid",
"--source-database", "uat",
"--target-connection", "mongodb://target.invalid",
"--target-database", "prd",
"--apply"
], output);
Assert.Equal(1, result);
Assert.Contains("--confirm PROMOTE_TO_PRD is required", output.ToString());
}
private static BsonDocument Question(string externalId, string stem) => new()
{
["ExternalId"] = externalId,
["Stem"] = stem
};
}
@@ -0,0 +1,21 @@
using QuestionsApi.Models;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class CreditEconomyTests
{
[Fact]
public void EarningPolicy_UsesTheAgreedRollingLimit()
{
Assert.Equal(20, CreditEconomy.RollingEarningLimit);
Assert.Equal(TimeSpan.FromHours(24), CreditEconomy.EarningWindow);
}
[Fact]
public void CreditStatus_DoesNotExposeUserOrOperationIdentifiers()
{
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.UserId)));
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.RecentEarnings)));
}
}
+203
View File
@@ -1,3 +1,4 @@
using QuestionsApi.Models;
using QuestionsApi.Services;
using Xunit;
@@ -27,4 +28,206 @@ public sealed class DashboardTests
var dates = new[] { today, today.AddDays(-1), today.AddDays(-3) };
Assert.Equal(2, DashboardStore.CalculateCurrentStreak(dates, today));
}
[Fact]
public void SystemPerformanceReturnsEveryOfficialSystemInOrder()
{
var attempts = new[]
{
Attempt("cardio-1", true),
Attempt("cardio-2", false),
Attempt("ethics-1", true),
Attempt("ignored", true),
Attempt("skipped-cardio", false, true)
};
var questions = new Dictionary<string, Question>
{
["cardio-1"] = Question("Cardiology"),
["cardio-2"] = Question("Cardiovascular System"),
["ethics-1"] = Question("Medical Ethics"),
["ignored"] = Question("Unmapped Specialty"),
["skipped-cardio"] = Question("Cardiology")
};
var result = DashboardStore.BuildSystemPerformance(attempts, questions);
Assert.Equal(14, result.Count);
Assert.Equal(DashboardStore.Step3Systems, result.Select(value => value.System));
var cardiovascular = result.Single(value => value.System == "Cardiovascular System");
Assert.Equal(1, cardiovascular.Correct);
Assert.Equal(2, cardiovascular.Total);
Assert.Equal(50, cardiovascular.Accuracy);
var social = result.Single(value => value.System.StartsWith("Social Sciences"));
Assert.Equal(1, social.Correct);
Assert.Equal(1, social.Total);
Assert.All(result.Where(value => value.System != cardiovascular.System && value.System != social.System),
value => Assert.Equal(0, value.Total));
}
[Theory]
[InlineData("Preventive Medicine", "Human Development")]
[InlineData("Infectious Disease", "Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders")]
[InlineData("Dermatology", "Skin & Subcutaneous Tissue")]
[InlineData("Pulmonary", "Respiratory System")]
[InlineData("Pregnancy", "Pregnancy/Childbirth & Female Reproductive System & Breast")]
[InlineData("Biostatistics", "Biostatistics & Epidemiology/Population Health & Interpretation of the Medical Literature")]
[InlineData("Medical Ethics", "Social Sciences: Communication Skills/Ethics/Patient Safety")]
public void SystemAliasesMapToOfficialCategories(string source, string expected)
{
Assert.Equal(expected, DashboardStore.NormalizeStep3System(source));
}
[Fact]
public void TrendCarriesRatingForwardAndLeavesInactiveAccuracyAsGapData()
{
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
var baselineDate = today.AddDays(-2).AddHours(8);
var attempts = new[]
{
Attempt("one", true, createdUtc: today.AddDays(-2).AddHours(9),
rating: 1018, ratingUtc: today.AddDays(-2).AddHours(9)),
Attempt("two", false, createdUtc: today.AddDays(-2).AddHours(10),
rating: 1002, ratingUtc: today.AddDays(-2).AddHours(10)),
Attempt("three", true, createdUtc: today.AddHours(11),
rating: 1020, ratingUtc: today.AddHours(11)),
Attempt("skip", false, true, today.AddDays(-1).AddHours(4),
990, today.AddDays(-1).AddHours(4))
};
var rating = new UserRating
{
OverallElo = 1020,
HistoryBaselineElo = 1000,
HistoryStartedUtc = baselineDate
};
var result = DashboardStore.BuildPerformanceTrend(
attempts, rating, Array.Empty<CcsAttempt>(), new CcsUserRating(), today);
Assert.Equal(30, result.Count);
Assert.Null(result[^4].QuestionBankRating);
Assert.Equal(1002, result[^3].QuestionBankRating);
Assert.Equal(50, result[^3].Accuracy);
Assert.Equal(2, result[^3].Total);
Assert.Equal(990, result[^2].QuestionBankRating);
Assert.Equal(0, result[^2].Total);
Assert.Equal(1020, result[^1].QuestionBankRating);
Assert.Equal(100, result[^1].Accuracy);
}
[Fact]
public void TrendCarriesCcsRatingForwardIndependentlyOfQuestionBankRating()
{
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
var baselineDate = today.AddDays(-2).AddHours(8);
var rating = new UserRating();
var ccsRating = new CcsUserRating
{
Elo = 1050,
HistoryBaselineElo = 1000,
HistoryStartedUtc = baselineDate
};
var ccsAttempts = new[]
{
RatedCcsAttempt("one", today.AddDays(-1).AddHours(9), 1030),
RatedCcsAttempt("two", today.AddHours(10), 1050)
};
var result = DashboardStore.BuildPerformanceTrend(
Array.Empty<QuestionAttempt>(), rating, ccsAttempts, ccsRating, today);
Assert.Equal(30, result.Count);
Assert.Equal(1000, result[^3].CcsRating);
Assert.Equal(1030, result[^2].CcsRating);
Assert.Equal(1050, result[^1].CcsRating);
}
private static CcsAttempt RatedCcsAttempt(string caseExternalId, DateTime appliedUtc, int userElo) => new()
{
CaseExternalId = caseExternalId,
Rating = new CcsRatingChange { Rated = true, UserElo = userElo, AppliedUtc = appliedUtc }
};
private static QuestionAttempt Attempt(
string questionId,
bool correct,
bool skip = false,
DateTime? createdUtc = null,
int? rating = null,
DateTime? ratingUtc = null) => new()
{
QuestionExternalId = questionId,
IsCorrect = correct,
IsSkip = skip,
CreatedUtc = createdUtc ?? DateTime.UtcNow,
UserOverallEloAfter = rating,
RatingAppliedUtc = ratingUtc
};
private static Question Question(string system) => new()
{
ExternalId = system,
System = system
};
[Fact]
public void PercentileReturnsNullWithNoUsers()
{
Assert.Null(DashboardStore.Percentile(0, 0));
}
[Fact]
public void PercentileComputesTopPercentFromUsersRatedHigher()
{
// 18 of 100 users rate higher than this user - "top 18%"
Assert.Equal(18, DashboardStore.Percentile(18, 100));
}
[Fact]
public void PercentileNeverReturnsZero()
{
// Even the single best-rated user should read as "top 1%", not "top 0%"
Assert.Equal(1, DashboardStore.Percentile(0, 100));
}
[Theory]
[InlineData(0.85, "High")]
[InlineData(0.7, "High")]
[InlineData(0.55, "Medium")]
[InlineData(0.4, "Medium")]
[InlineData(0.1, "Low")]
public void ReadinessLabelUsesPassProbabilityThresholds(double passProbability, string expected)
{
Assert.Equal(expected, DashboardStore.ReadinessLabel(passProbability));
}
[Fact]
public void SelectLatestReadinessScoreSkipsSampleSimulationsEvenWhenMostRecent()
{
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
var simulations = new[]
{
CompletedSimulation(isSample: true, completedUtc: today, passProbability: 0.98),
CompletedSimulation(isSample: false, completedUtc: today.AddDays(-3), passProbability: 0.12)
};
var score = DashboardStore.SelectLatestReadinessScore(simulations);
Assert.NotNull(score);
Assert.Equal(0.12, score.PassProbability);
}
[Fact]
public void SelectLatestReadinessScoreReturnsNullWhenOnlySamplesExist()
{
var simulations = new[] { CompletedSimulation(isSample: true, completedUtc: DateTime.UtcNow, passProbability: 0.98) };
Assert.Null(DashboardStore.SelectLatestReadinessScore(simulations));
}
private static ExamSimulation CompletedSimulation(bool isSample, DateTime completedUtc, double passProbability) => new()
{
IsSample = isSample,
CompletedUtc = completedUtc,
Score = new ExamSimulationScore { PassProbability = passProbability }
};
}
@@ -0,0 +1,271 @@
using Microsoft.Extensions.Configuration;
using QuestionsApi.Models;
using QuestionsApi.Services;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class DynamicQuestionTests
{
[Fact]
public void Generator_IsDeterministic_AndPreservesCorrectAnswer()
{
var blueprint = Blueprint();
var generator = new DynamicQuestionGenerator();
var first = generator.Generate(blueprint, "same-seed", "one").Question;
var second = generator.Generate(blueprint, "same-seed", "two").Question;
Assert.Equal(first.Stem, second.Stem);
Assert.Equal(first.Choices.Select(value => value.Text), second.Choices.Select(value => value.Text));
Assert.Equal("correct", first.CorrectChoiceId);
Assert.Null(DynamicQuestionGenerator.Validate(blueprint));
}
[Fact]
public void SignedTokens_AreUserBound_AndRejectTampering()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?> { ["DynamicQuestions:SigningKey"] = "unit-test-key" })
.Build();
var service = new DynamicQuestionTokenService(configuration);
var claims = new DynamicGenerationClaims
{
UserId = "user-one",
BlueprintExternalId = "test",
BlueprintVersion = 1,
ObjectiveId = "objective",
Seed = "seed",
Nonce = "nonce",
GeneratorVersion = 1,
ExpiresUtc = DateTime.UtcNow.AddMinutes(5)
};
var token = service.SignGeneration(claims);
Assert.Equal("seed", service.ReadGeneration(token, "user-one").Seed);
Assert.Throws<ArgumentException>(() => service.ReadGeneration(token, "user-two"));
Assert.ThrowsAny<Exception>(() => service.ReadGeneration(token + "x", "user-one"));
}
[Fact]
public void GoldGenerator_UsesCaseFamilies_AndProducesAuthoredTeachingShape()
{
var blueprint = GoldBlueprint();
var generator = new DynamicQuestionGenerator();
var generated = Enumerable.Range(0, 100)
.Select(seed => generator.Generate(blueprint, seed.ToString(), seed.ToString()))
.ToList();
Assert.Equal(3, generated.Select(value => value.CaseFamilyId).Distinct().Count());
Assert.All(generated, value =>
{
Assert.True(value.Question.Stem.Length >= 220);
Assert.Equal(5, value.Question.Choices.Count);
Assert.Equal(5, value.Question.ChoiceExplanations.Count);
Assert.Single(value.Question.ReviewFollowups);
Assert.Equal(4, value.Question.WrongAnswerFollowups.Count);
Assert.Contains("quality-standard:2", value.Question.Tags);
});
Assert.Null(DynamicQuestionGenerator.Validate(blueprint));
}
[Fact]
public void LegacyGeneratorVersion_RemainsDeterministic()
{
var generator = new DynamicQuestionGenerator();
var first = generator.Generate(Blueprint(), "legacy-seed", "one", 1).Question;
var regenerated = generator.Generate(Blueprint(), "legacy-seed", "two", 1).Question;
Assert.Equal(first.Stem, regenerated.Stem);
Assert.Equal(first.Choices.Select(value => value.Id), regenerated.Choices.Select(value => value.Id));
}
[Fact]
public void DifficultyPolicy_BlendsThenSpecializes_AndTargetsSixtyPercent()
{
Assert.Equal(1200, DynamicDifficultyPolicy.EffectiveAbility(1100, 1300, null));
var early = new DynamicObjectiveMastery { Elo = 1500, Attempts = 1 };
var established = new DynamicObjectiveMastery { Elo = 1500, Attempts = 10 };
Assert.Equal(1224, DynamicDifficultyPolicy.EffectiveAbility(1100, 1300, early));
Assert.Equal(1440, DynamicDifficultyPolicy.EffectiveAbility(1100, 1300, established));
Assert.Equal(1370, DynamicDifficultyPolicy.TargetFamilyElo(1440));
}
[Fact]
public void DifficultyPolicy_SelectsClosestNonrecentFamily()
{
var families = new List<DynamicCaseFamily>
{
new() { Id = "foundation", DifficultyTier = "foundation", NominalElo = 850 },
new() { Id = "standard", DifficultyTier = "standard", NominalElo = 1050 },
new() { Id = "advanced-a", DifficultyTier = "advanced", NominalElo = 1250 },
new() { Id = "advanced-b", DifficultyTier = "advanced", NominalElo = 1250 },
new() { Id = "expert", DifficultyTier = "expert", NominalElo = 1450 }
};
var selected = DynamicDifficultyPolicy.SelectFamily(
families, new Dictionary<string, DynamicFamilyRating>(), 1260,
new HashSet<string> { "advanced-a" }, 0);
Assert.Equal("advanced-b", selected.Id);
}
[Fact]
public void DifficultyPolicy_RatingsMoveInOppositeDirections()
{
Assert.True(DynamicDifficultyPolicy.LearnerDelta(1000, 1050, true, 0) > 0);
Assert.True(DynamicDifficultyPolicy.FamilyDelta(1000, 1050, true, 0) < 0);
Assert.True(DynamicDifficultyPolicy.LearnerDelta(1000, 1050, false, 0) < 0);
Assert.True(DynamicDifficultyPolicy.FamilyDelta(1000, 1050, false, 0) > 0);
Assert.Equal(600, DynamicDifficultyPolicy.Clamp(-100));
Assert.Equal(1800, DynamicDifficultyPolicy.Clamp(5000));
}
[Fact]
public void ReviewPolicy_UsesFixedIntervals_AndResetsAfterAMiss()
{
var now = new DateTime(2026, 7, 23, 12, 0, 0, DateTimeKind.Utc);
Assert.Equal(now.AddDays(1), DynamicReviewPolicy.FirstDue(now));
Assert.Equal((1, now.AddDays(3), false), DynamicReviewPolicy.AfterReview(0, true, now));
Assert.Equal((2, now.AddDays(7), false), DynamicReviewPolicy.AfterReview(1, true, now));
Assert.Equal((3, now.AddDays(21), false), DynamicReviewPolicy.AfterReview(2, true, now));
Assert.Equal((4, now.AddDays(45), false), DynamicReviewPolicy.AfterReview(3, true, now));
Assert.Equal((5, null, true), DynamicReviewPolicy.AfterReview(4, true, now));
Assert.Equal((0, now.AddDays(1), false), DynamicReviewPolicy.AfterReview(3, false, now));
}
[Fact]
public void QualityStandardFour_RequiresCoverageReviewAndMisconceptionMetadata()
{
var options = new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)
{ PropertyNameCaseInsensitive = true };
var root = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory, "..", "..", "..", "..", "..", "content", "dynamic_questions"));
var source = Directory.EnumerateFiles(root, "*.json")
.SelectMany(file => System.Text.Json.JsonSerializer.Deserialize<List<DynamicQuestionBlueprint>>(
File.ReadAllText(file), options) ?? [])
.ToList();
var blueprint = DynamicBlueprintResolver.ResolveAll(source)
.First(value => value.QualityStandardVersion == 3);
blueprint.QualityStandardVersion = 4;
blueprint.BlueprintWeight = 6;
Assert.Contains("encounterFrame", DynamicQuestionGenerator.Validate(blueprint));
blueprint.System = "Endocrine";
blueprint.PhysicianTask = "Pharmacotherapy";
blueprint.EncounterFrame = "Urgent Intervention";
blueprint.SiteOfCare = "Emergency Department";
blueprint.ConceptIds = ["test.concept"];
blueprint.MedicallyReviewedUtc = DateTime.UtcNow.AddDays(-1);
blueprint.MedicalReviewExpiresUtc = DateTime.UtcNow.AddYears(1);
blueprint.MedicalReviewer = "Medical editor";
foreach (var distractor in blueprint.Distractors)
distractor.MisconceptionId = $"misconception.{distractor.Id}";
Assert.Null(DynamicQuestionGenerator.Validate(blueprint));
}
[Fact]
public void CoveragePolicy_UsesOfficialDefaults_AndAllowsVersionedOverrides()
{
Assert.Equal(12, DynamicBlueprintCoveragePolicy.Weight(
new DynamicQuestionBlueprint { System = "Biostatistics" }));
Assert.Equal(10, DynamicBlueprintCoveragePolicy.Weight(
new DynamicQuestionBlueprint { System = "Cardiovascular" }));
Assert.Equal(2.5, DynamicBlueprintCoveragePolicy.Weight(
new DynamicQuestionBlueprint { System = "Cardiovascular", BlueprintWeight = 2.5 }));
}
[Fact]
public void AdaptiveContent_ResolvesToFourCompleteTiers()
{
var options = new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)
{ PropertyNameCaseInsensitive = true };
var root = Path.GetFullPath(Path.Combine(
AppContext.BaseDirectory, "..", "..", "..", "..", "..", "content", "dynamic_questions"));
var source = Directory.EnumerateFiles(root, "*.json")
.SelectMany(file => System.Text.Json.JsonSerializer.Deserialize<List<DynamicQuestionBlueprint>>(
File.ReadAllText(file), options) ?? [])
.ToList();
var adaptive = DynamicBlueprintResolver.ResolveAll(source)
.Where(value => value.QualityStandardVersion >= 3).ToList();
Assert.Equal(5, adaptive.Count);
Assert.All(adaptive, blueprint =>
{
Assert.Equal(8, blueprint.CaseFamilies.Count);
Assert.All(DynamicDifficultyPolicy.TierElos.Keys, tier =>
Assert.Equal(2, blueprint.CaseFamilies.Count(value => value.DifficultyTier == tier)));
Assert.All(blueprint.CaseFamilies, family =>
Assert.False(family.StemTemplate.EndsWith(family.LeadIn, StringComparison.Ordinal)));
Assert.Null(DynamicQuestionGenerator.Validate(blueprint));
});
}
private static DynamicQuestionBlueprint Blueprint() => new()
{
ExternalId = "test",
ObjectiveId = "objective",
StemTemplate = "Patient age {{age}}.",
LeadIn = "What is correct?",
Variables = new()
{
["age"] = new DynamicVariableDefinition { Minimum = 20, Maximum = 80, Step = 1 }
},
CorrectAnswer = new DynamicAnswerTemplate { Id = "correct", TextTemplate = "Correct" },
Distractors =
[
new() { Id = "a", TextTemplate = "A" },
new() { Id = "b", TextTemplate = "B" },
new() { Id = "c", TextTemplate = "C" },
new() { Id = "d", TextTemplate = "D" }
],
References = [new QuestionReference { Title = "Reference", Url = "https://example.test" }]
};
private static DynamicQuestionBlueprint GoldBlueprint()
{
var stem = string.Join(" ", Enumerable.Repeat(
"A patient presents with a carefully correlated history, examination, vital signs, and diagnostic findings that establish the clinical context without naming the diagnosis.", 3));
return new DynamicQuestionBlueprint
{
ExternalId = "gold-test",
Version = 2,
ObjectiveId = "gold.objective",
QualityStandardVersion = 2,
CorrectConceptId = "gold.correct",
CorrectAnswer = new DynamicAnswerTemplate
{ Id = "correct", TextTemplate = "Correct action", ExplanationTemplate = "This addresses the decisive finding." },
Distractors = Enumerable.Range(1, 6).Select(index => new DynamicAnswerTemplate
{
Id = $"d{index}",
TextTemplate = $"Distractor {index}",
ExplanationTemplate = $"Distractor {index} does not address the decisive finding."
}).ToList(),
ExplanationTemplate = "A complete explanation connects the presentation to the action.",
KeyTakeaway = "Use the decisive finding.",
UsmlePearl = "Sequence urgent care correctly.",
ReviewFollowups =
[
new DynamicReviewFollowupTemplate
{
PromptTemplate = "What is decisive?",
Choices =
[
new() { Id = "yes", TextTemplate = "The unstable finding" },
new() { Id = "no", TextTemplate = "An incidental finding" }
],
CorrectChoiceId = "yes",
TeachingPointTemplate = "The unstable finding determines management."
}
],
CaseFamilies =
[
new() { Id = "family-a", StemTemplate = stem, LeadIn = "What is next?" },
new() { Id = "family-b", StemTemplate = stem + " Family B.", LeadIn = "What is next?" },
new() { Id = "family-c", StemTemplate = stem + " Family C.", LeadIn = "What is next?" }
],
References = [new QuestionReference { Title = "Reference", Url = "https://example.test" }]
};
}
}
@@ -0,0 +1,94 @@
using QuestionsApi.Services.Flashcards;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class FlashcardClozeParserTests
{
[Fact]
public void Parse_ExtractsAnswerAndHint()
{
var deletions = FlashcardClozeParser.Parse("The capital of France is {{c1::Paris::country capital}}.");
var deletion = Assert.Single(deletions);
Assert.Equal(1, deletion.Number);
Assert.Equal("Paris", deletion.Answer);
Assert.Equal("country capital", deletion.Hint);
}
[Fact]
public void Parse_WithoutHint_LeavesHintNull()
{
var deletions = FlashcardClozeParser.Parse("{{c1::Paris}}");
Assert.Null(Assert.Single(deletions).Hint);
}
[Fact]
public void DistinctClozeNumbers_DeduplicatesRepeatedNumbersAndSorts()
{
var text = "{{c2::b}} some text {{c1::a}} more {{c1::a-again}} {{c3::c}}";
var numbers = FlashcardClozeParser.DistinctClozeNumbers(text);
Assert.Equal([1, 2, 3], numbers);
}
[Theory]
[InlineData("no cloze markers here")]
[InlineData("{{c0::invalid, number must be >= 1}}")]
[InlineData("{{cX::not a number}}")]
[InlineData("{{c1::}}")]
public void HasValidCloze_IsFalse_ForMalformedOrMissingMarkers(string text)
{
Assert.False(FlashcardClozeParser.HasValidCloze(text));
}
[Fact]
public void HasValidCloze_IsTrue_WhenAtLeastOneValidMarkerExists()
{
Assert.True(FlashcardClozeParser.HasValidCloze("prefix {{c1::answer}} suffix"));
}
[Fact]
public void RenderFront_HidesTargetNumber_RevealsOthers()
{
var text = "{{c1::Paris}} is the capital of {{c2::France}}";
var front1 = FlashcardClozeParser.RenderFront(text, 1);
var front2 = FlashcardClozeParser.RenderFront(text, 2);
Assert.Equal("[...] is the capital of France", front1);
Assert.Equal("Paris is the capital of [...]", front2);
}
[Fact]
public void RenderFront_UsesHintWhenPresent()
{
var text = "{{c1::Paris::capital city}} is in France";
var front = FlashcardClozeParser.RenderFront(text, 1);
Assert.Equal("[capital city] is in France", front);
}
[Fact]
public void RenderBack_RevealsAllAnswers()
{
var text = "{{c1::Paris}} is the capital of {{c2::France::hint}}";
var back = FlashcardClozeParser.RenderBack(text, 1);
Assert.Equal("Paris is the capital of France", back);
}
[Fact]
public void Parse_SupportsMultilineClozeContent()
{
var text = "Line one\n{{c1::multi\nline\nanswer}}\nLine two";
var deletion = Assert.Single(FlashcardClozeParser.Parse(text));
Assert.Equal("multi\nline\nanswer", deletion.Answer);
}
}
@@ -0,0 +1,75 @@
using QuestionsApi.Services.Flashcards;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class FlashcardCsvTests
{
[Fact]
public void ParseRows_HandlesQuotedMultilineFieldsAndEmbeddedCommas()
{
var csv = "stable_id,deck,type,prompt,answer,extra,tags,source_question_external_id\n"
+ "abc-1,Cardio,basic,\"What is the first line,\nsecond line?\",\"Answer, with comma\",,tag1;tag2,q-1\n";
var rows = FlashcardCsv.ParseRows(csv);
Assert.Equal(2, rows.Count);
var data = rows[1];
Assert.Equal("abc-1", data[0]);
Assert.Equal("What is the first line,\nsecond line?", data[3]);
Assert.Equal("Answer, with comma", data[4]);
Assert.Equal("tag1;tag2", data[6]);
}
[Fact]
public void ParseRows_HandlesEscapedDoubleQuotes()
{
var csv = "a,b\n\"she said \"\"hi\"\"\",plain\n";
var rows = FlashcardCsv.ParseRows(csv);
Assert.Equal("she said \"hi\"", rows[1][0]);
Assert.Equal("plain", rows[1][1]);
}
[Fact]
public void ParseRows_SupportsUnicodeContent()
{
var csv = "a,b\néè中文,\U0001F600\n";
var rows = FlashcardCsv.ParseRows(csv);
Assert.Equal("éè中文", rows[1][0]);
Assert.Equal("😀", rows[1][1]);
}
[Fact]
public void ParseRows_HandlesFileWithoutTrailingNewline()
{
var csv = "a,b\n1,2";
var rows = FlashcardCsv.ParseRows(csv);
Assert.Equal(2, rows.Count);
Assert.Equal(["1", "2"], rows[1]);
}
[Fact]
public void WriteRow_QuotesFieldsContainingSpecialCharacters()
{
var row = FlashcardCsv.WriteRow(["plain", "has,comma", "has\"quote", "has\nnewline"]);
Assert.Equal("plain,\"has,comma\",\"has\"\"quote\",\"has\nnewline\"", row);
}
[Fact]
public void WriteThenParse_RoundTrips()
{
var original = new[] { "id-1", "Deck, Name", "basic", "prompt with \"quotes\" and\nnewline", "answer", "", "a;b", "q-1" };
var line = FlashcardCsv.WriteRow(original);
var rows = FlashcardCsv.ParseRows("header\n" + line + "\n");
Assert.Equal(original, rows[1]);
}
}
@@ -0,0 +1,92 @@
using System.Text.Json;
using QuestionsApi.Models;
using QuestionsApi.Services.Flashcards;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class FlashcardImportExportModelTests
{
[Fact]
public void JsonExport_RoundTrips_NotesCardsAndReviewHistory()
{
var export = new FlashcardJsonExport
{
SchemaVersion = 1,
Settings = new FlashcardSettings { UserId = "user-1", NewCardsPerDay = 20, ReviewsPerDay = 200 },
Decks = [new FlashcardDeck { Id = "deck-1", UserId = "user-1", Name = "Cardio", IsDefault = true }],
Notes =
[
new FlashcardJsonExportNote
{
Note = new FlashcardNote
{
Id = "note-1", UserId = "user-1", DeckId = "deck-1", StableId = "stable-1",
Type = FlashcardTypes.Cloze, Prompt = "{{c1::Paris}} is the capital of France",
Tags = ["geography"]
},
Cards =
[
new FlashcardSchedule
{
Id = "card-1", UserId = "user-1", NoteId = "note-1", Variant = 1,
State = FlashcardScheduleStates.Review, Stability = 12.3, Difficulty = 4.5,
DueUtc = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)
}
],
ReviewLogs =
[
new FlashcardReviewLog
{
Id = "log-1", UserId = "user-1", CardId = "card-1", Rating = FlashcardRatings.Good,
ReviewedUtc = new DateTime(2025, 12, 31, 0, 0, 0, DateTimeKind.Utc)
}
],
Images = [new FlashcardJsonExportImage { AssetId = "asset-1", FileName = "x.png", ContentType = "image/png", Base64Bytes = Convert.ToBase64String([1, 2, 3]) }]
}
]
};
var json = JsonSerializer.Serialize(export);
var roundTripped = JsonSerializer.Deserialize<FlashcardJsonExport>(json);
Assert.NotNull(roundTripped);
var note = Assert.Single(roundTripped!.Notes);
Assert.Equal("stable-1", note.Note.StableId);
Assert.Equal(FlashcardTypes.Cloze, note.Note.Type);
var card = Assert.Single(note.Cards);
Assert.Equal(1, card.Variant);
Assert.Equal(12.3, card.Stability);
var log = Assert.Single(note.ReviewLogs);
Assert.Equal(FlashcardRatings.Good, log.Rating);
var image = Assert.Single(note.Images);
Assert.Equal([1, 2, 3], Convert.FromBase64String(image.Base64Bytes));
}
[Fact]
public void Fingerprint_IsStable_ForSameTypePromptAndAnswer_IgnoringSurroundingWhitespace()
{
var a = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, " What is 2+2? ", "4");
var b = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "What is 2+2?", "4");
Assert.Equal(a, b);
}
[Fact]
public void Fingerprint_DiffersWhenAnswerDiffers()
{
var a = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "What is 2+2?", "4");
var b = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "What is 2+2?", "five");
Assert.NotEqual(a, b);
}
[Fact]
public void Fingerprint_DiffersByType_ForSamePromptAndAnswer()
{
var basic = FlashcardNoteStore.Fingerprint(FlashcardTypes.Basic, "text", "text");
var cloze = FlashcardNoteStore.Fingerprint(FlashcardTypes.Cloze, "text", "text");
Assert.NotEqual(basic, cloze);
}
}
@@ -0,0 +1,32 @@
using QuestionsApi.Models;
using QuestionsApi.Services.Flashcards;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class FlashcardOwnershipTests
{
[Theory]
[InlineData(false, false, true)]
[InlineData(false, true, false)]
[InlineData(true, false, true)]
[InlineData(true, true, true)]
public void CanMutate_MatchesTruthTable(bool isSuper, bool targetIsSystem, bool expected)
{
Assert.Equal(expected, FlashcardOwnershipGuard.CanMutate(isSuper, targetIsSystem));
}
[Fact]
public void FlashcardDeck_IsSystem_TrueOnlyForTheReservedOwner()
{
Assert.True(new FlashcardDeck { UserId = FlashcardOwners.System }.IsSystem);
Assert.False(new FlashcardDeck { UserId = "a-real-user-id" }.IsSystem);
}
[Fact]
public void FlashcardNote_IsSystem_TrueOnlyForTheReservedOwner()
{
Assert.True(new FlashcardNote { UserId = FlashcardOwners.System }.IsSystem);
Assert.False(new FlashcardNote { UserId = "a-real-user-id" }.IsSystem);
}
}
@@ -0,0 +1,152 @@
using FSRS.Core.Constants;
using QuestionsApi.Models;
using QuestionsApi.Services.Flashcards;
using Xunit;
namespace QuestionsApi.Tests;
// Validates FsrsFlashcardScheduler against the official FSRS formulas (not just against the
// library's own outputs), using the exact weights FSRS.Core 1.0.7 ships via FsrsConstants so
// this keeps validating the formula even if the shipped defaults are ever bumped upstream.
//
// S0(rating) = w[(int)rating - 1] (initial stability)
// D0(rating) = clamp(w4 - (e^(w5*((int)rating-1)) - 1), 1, 10) (initial difficulty)
// I(r, S) = round(S) when desired_retention == the FSRS reference retention (0.9), because
// the interval formula collapses to the stability itself at that specific retention.
public sealed class FlashcardSchedulerTests
{
private static readonly double[] W = FsrsConstants.DefaultParameters;
private static readonly DateTime Start = new(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
private static FlashcardSchedule NewSchedule() => new()
{
UserId = "user-1",
NoteId = "note-1",
State = FlashcardScheduleStates.New,
DueUtc = Start,
LastReviewUtc = null
};
[Theory]
[InlineData(FlashcardRatings.Again, 0)]
[InlineData(FlashcardRatings.Hard, 1)]
[InlineData(FlashcardRatings.Good, 2)]
[InlineData(FlashcardRatings.Easy, 3)]
public void InitialStability_MatchesFsrsFormula(string rating, int weightIndex)
{
var scheduler = new FsrsFlashcardScheduler();
var result = scheduler.Review(NewSchedule(), rating, Start, 0);
Assert.Equal(W[weightIndex], result.Stability, precision: 6);
}
[Theory]
[InlineData(FlashcardRatings.Again, 1)]
[InlineData(FlashcardRatings.Hard, 2)]
[InlineData(FlashcardRatings.Good, 3)]
[InlineData(FlashcardRatings.Easy, 4)]
public void InitialDifficulty_MatchesFsrsFormula(string rating, int fsrsRatingValue)
{
var scheduler = new FsrsFlashcardScheduler();
var result = scheduler.Review(NewSchedule(), rating, Start, 0);
var expected = Math.Clamp(W[4] - (Math.Exp(W[5] * (fsrsRatingValue - 1)) - 1), 1, 10);
Assert.Equal(expected, result.Difficulty, precision: 6);
}
[Fact]
public void Interval_EqualsRoundedNewStability_AtPinnedDesiredRetention()
{
// At desired_retention == 0.9 (the reference retention baked into the FSRS interval
// formula), I(0.9, S) reduces to S regardless of the decay parameter - this holds for
// any state, so it's a strong end-to-end check that both the stability update and the
// interval formula are wired correctly, without hand-deriving decay/factor constants.
var scheduler = new FsrsFlashcardScheduler();
var schedule = NewSchedule();
var t = Start;
foreach (var rating in new[] { FlashcardRatings.Good, FlashcardRatings.Good, FlashcardRatings.Good })
{
var result = scheduler.Review(schedule, rating, t, 0);
schedule = ToSchedule(schedule, result);
t = result.DueUtc;
}
var final = scheduler.Review(schedule, FlashcardRatings.Good, t, 0);
var intervalDays = (final.DueUtc - t).TotalDays;
Assert.Equal(Math.Round(final.Stability, MidpointRounding.AwayFromZero), Math.Round(intervalDays), precision: 6);
}
[Fact]
public void MaximumInterval_NeverExceeds36500Days()
{
var scheduler = new FsrsFlashcardScheduler();
var schedule = NewSchedule();
schedule.State = FlashcardScheduleStates.Review;
schedule.Stability = 1_000_000;
schedule.Difficulty = 5;
schedule.DueUtc = Start;
schedule.LastReviewUtc = Start.AddDays(-1);
var result = scheduler.Review(schedule, FlashcardRatings.Good, Start, 0);
Assert.True((result.DueUtc - Start).TotalDays <= FsrsFlashcardScheduler.MaximumIntervalDays);
}
[Fact]
public void Again_MovesToRelearning_AndReducesStabilityBelowPriorReview()
{
var scheduler = new FsrsFlashcardScheduler();
var schedule = NewSchedule();
var t = Start;
FlashcardScheduleResult result = null!;
foreach (var rating in new[] { FlashcardRatings.Good, FlashcardRatings.Good })
{
result = scheduler.Review(schedule, rating, t, 0);
schedule = ToSchedule(schedule, result);
t = result.DueUtc;
}
var stabilityBeforeLapse = schedule.Stability;
var lapsed = scheduler.Review(schedule, FlashcardRatings.Again, t, 0);
Assert.Equal(FlashcardScheduleStates.Relearning, lapsed.State);
Assert.True(lapsed.Stability < stabilityBeforeLapse);
}
[Fact]
public void PreviewRatingOptions_ReturnsAllFourRatings_WithNonDecreasingDueDates()
{
var scheduler = new FsrsFlashcardScheduler();
var schedule = NewSchedule();
schedule.State = FlashcardScheduleStates.Review;
schedule.Stability = 10;
schedule.Difficulty = 5;
schedule.LastReviewUtc = Start.AddDays(-10);
schedule.DueUtc = Start;
var options = scheduler.PreviewRatingOptions(schedule, Start);
Assert.Equal(FlashcardRatings.All.Length, options.Count);
Assert.Equal(FlashcardRatings.All.ToHashSet(), options.Select(o => o.Rating).ToHashSet());
var again = options.Single(o => o.Rating == FlashcardRatings.Again).DueUtc;
var hard = options.Single(o => o.Rating == FlashcardRatings.Hard).DueUtc;
var good = options.Single(o => o.Rating == FlashcardRatings.Good).DueUtc;
var easy = options.Single(o => o.Rating == FlashcardRatings.Easy).DueUtc;
Assert.True(again <= hard);
Assert.True(hard <= good);
Assert.True(good <= easy);
}
private static FlashcardSchedule ToSchedule(FlashcardSchedule prior, FlashcardScheduleResult result) => new()
{
UserId = prior.UserId,
NoteId = prior.NoteId,
Variant = prior.Variant,
State = result.State,
Step = result.Step,
Stability = result.Stability,
Difficulty = result.Difficulty,
DueUtc = result.DueUtc,
LastReviewUtc = result.LastReviewUtc
};
}
@@ -0,0 +1,177 @@
using QuestionsApi.Services.Flashcards;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class FlashcardSchedulingTests
{
[Fact]
public void NextStudyDayStart_IsMidnightOfTheFollowingDay()
{
var studyDayStart = new DateTime(2026, 3, 5, 0, 0, 0, DateTimeKind.Utc);
var next = FlashcardScheduling.NextStudyDayStart(studyDayStart);
Assert.Equal(new DateTime(2026, 3, 6, 0, 0, 0, DateTimeKind.Utc), next);
}
[Fact]
public void NextStudyDayStart_TruncatesTimeOfDayBeforeAdvancing()
{
// A session that started mid-day still buries siblings until the *next calendar day*,
// not exactly 24h later.
var studyDayStart = new DateTime(2026, 3, 5, 14, 30, 0, DateTimeKind.Utc);
var next = FlashcardScheduling.NextStudyDayStart(studyDayStart);
Assert.Equal(new DateTime(2026, 3, 6, 0, 0, 0, DateTimeKind.Utc), next);
}
[Theory]
[InlineData(20, null, 0, 20)]
[InlineData(20, null, 15, 5)]
[InlineData(20, null, 25, 0)]
[InlineData(20, 5, 0, 5)]
[InlineData(20, 0, 0, 0)]
public void ResolveDailyBudget_DeckOverrideWinsOverGlobal_ClampedAtZero(
int globalLimit, int? deckOverride, int consumedToday, int expected)
{
Assert.Equal(expected, FlashcardScheduling.ResolveDailyBudget(globalLimit, deckOverride, consumedToday));
}
private static readonly DateTime Today = new(2026, 3, 10, 0, 0, 0, DateTimeKind.Utc);
[Fact]
public void ComputeStreakDays_NoReviews_IsZero()
{
Assert.Equal(0, FlashcardScheduling.ComputeStreakDays([], Today));
}
[Fact]
public void ComputeStreakDays_ReviewedTodayOnly_IsOne()
{
var timestamps = new List<DateTime> { Today.AddHours(9) };
Assert.Equal(1, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
}
[Fact]
public void ComputeStreakDays_ThreeConsecutiveDaysEndingToday_IsThree()
{
var timestamps = new List<DateTime>
{
Today.AddDays(-2).AddHours(8), Today.AddDays(-1).AddHours(20), Today.AddHours(7)
};
Assert.Equal(3, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
}
[Fact]
public void ComputeStreakDays_NotYetReviewedToday_StillCountsThroughYesterday()
{
// The streak shouldn't visibly reset to 0 the instant a new calendar day begins, before
// the user has had a chance to study today.
var timestamps = new List<DateTime> { Today.AddDays(-2).AddHours(8), Today.AddDays(-1).AddHours(20) };
Assert.Equal(2, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
}
[Fact]
public void ComputeStreakDays_GapBeforeYesterday_BreaksTheStreak()
{
var timestamps = new List<DateTime>
{
Today.AddDays(-10).AddHours(8), Today.AddDays(-1).AddHours(20), Today.AddHours(7)
};
Assert.Equal(2, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
}
[Fact]
public void ComputeStreakDays_MissedYesterdayAndToday_IsZero()
{
var timestamps = new List<DateTime> { Today.AddDays(-3).AddHours(8) };
Assert.Equal(0, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
}
[Fact]
public void ComputeStreakDays_MultipleReviewsSameDay_CountOnceTowardTheStreak()
{
var timestamps = new List<DateTime> { Today.AddHours(7), Today.AddHours(8), Today.AddHours(9) };
Assert.Equal(1, FlashcardScheduling.ComputeStreakDays(timestamps, Today));
}
[Fact]
public void PickIndex_NonShuffle_ReturnsFirstNonExcluded()
{
var pool = new List<string> { "a", "b", "c" };
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "a" }, shuffle: false, n => throw new InvalidOperationException("should not randomize"));
Assert.Equal(1, picked);
}
[Fact]
public void PickIndex_NonShuffle_EverythingExcluded_ReturnsNull()
{
var pool = new List<string> { "a", "b" };
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "a", "b" }, shuffle: false, n => 0);
Assert.Null(picked);
}
[Fact]
public void PickIndex_EmptyPool_ReturnsNullRegardlessOfShuffle()
{
Assert.Null(FlashcardScheduling.PickIndex([], new HashSet<string>(), shuffle: false, n => 0));
Assert.Null(FlashcardScheduling.PickIndex([], new HashSet<string>(), shuffle: true, n => 0));
}
[Fact]
public void PickIndex_Shuffle_StubReturningFirstCandidate_PicksFirstNonExcluded()
{
var pool = new List<string> { "a", "b", "c" };
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "a" }, shuffle: true, n => 0);
// Candidates (excluding "a") are ["b", "c"]; stub index 0 -> "b" -> original pool index 1.
Assert.Equal(1, picked);
}
[Fact]
public void PickIndex_Shuffle_CanReturnSomethingOtherThanTheOldestCandidate()
{
var pool = new List<string> { "a", "b", "c" };
// Stub picks the last candidate instead of always the first (oldest), proving this isn't
// hardcoded to FirstOrDefault-style behavior.
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string>(), shuffle: true, n => n - 1);
Assert.Equal(2, picked);
}
[Fact]
public void PickIndex_Shuffle_ExcludedItemsDoNotShiftTheOriginalIndexMapping()
{
var pool = new List<string> { "a", "b", "c", "d" };
// Candidates (excluding "b") are ["a", "c", "d"] at original indices [0, 2, 3]; stub
// index 2 should map back to original index 3 ("d"), not 2 ("c").
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "b" }, shuffle: true, n => 2);
Assert.Equal(3, picked);
}
[Fact]
public void PickIndex_Shuffle_EverythingExcluded_ReturnsNull()
{
var pool = new List<string> { "a", "b" };
var picked = FlashcardScheduling.PickIndex(pool, new HashSet<string> { "a", "b" }, shuffle: true, n => 0);
Assert.Null(picked);
}
}
@@ -0,0 +1,49 @@
using QuestionsApi.Models;
using QuestionsApi.Services;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class QuestionStoreTests
{
[Fact]
public void ApplyMediaReviewGate_FlagsUnflaggedQuestionWithMedia()
{
var question = new Question { Media = ["asset-1"] };
QuestionStore.ApplyMediaReviewGate(question);
Assert.NotNull(question.FlaggedForReviewUtc);
Assert.Equal("system", question.FlaggedByUserId);
Assert.False(string.IsNullOrWhiteSpace(question.FlagReason));
}
[Fact]
public void ApplyMediaReviewGate_LeavesMediaFreeQuestionsAlone()
{
var question = new Question { Media = [] };
QuestionStore.ApplyMediaReviewGate(question);
Assert.Null(question.FlaggedForReviewUtc);
}
[Fact]
public void ApplyMediaReviewGate_DoesNotOverwriteAnExistingFlag()
{
var flaggedAt = DateTime.UtcNow.AddDays(-1);
var question = new Question
{
Media = ["asset-1"],
FlaggedForReviewUtc = flaggedAt,
FlagReason = "Reported by student",
FlaggedByUserId = "user-42"
};
QuestionStore.ApplyMediaReviewGate(question);
Assert.Equal(flaggedAt, question.FlaggedForReviewUtc);
Assert.Equal("Reported by student", question.FlagReason);
Assert.Equal("user-42", question.FlaggedByUserId);
}
}
@@ -0,0 +1,17 @@
using QuestionsApi.Services;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class QuestionVisibilityTests
{
[Theory]
[InlineData(true, false, true)]
[InlineData(true, true, false)]
[InlineData(false, false, false)]
[InlineData(false, true, false)]
public void IsVisibleToStudent_MatchesTruthTable(bool isPublished, bool isFlagged, bool expected)
{
Assert.Equal(expected, QuestionVisibility.IsVisibleToStudent(isPublished, isFlagged));
}
}
@@ -0,0 +1,22 @@
using QuestionsApi.Models;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class UserProfileModelsTests
{
[Theory]
[InlineData(null, null, true)]
[InlineData(1.0, 1.0, true)]
[InlineData(0.85, 1.35, true)]
[InlineData(0.84, null, false)]
[InlineData(null, 1.36, false)]
public void UpdateTextScaleRequestValidator_EnforcesRange(double? questionScale, double? ccsScale, bool expectedValid)
{
var request = new UpdateTextScaleRequest { QuestionTextScale = questionScale, CcsTextScale = ccsScale };
var error = UpdateTextScaleRequestValidator.Validate(request);
Assert.Equal(expectedValid, error is null);
}
}
@@ -51,6 +51,16 @@ public sealed class CcsController(CcsStore store, CcsRatingStore ratings) : Cont
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpPost("attempts/{id}/commands/batch")]
public async Task<IActionResult> BatchCommand(string id, CcsBatchCommandRequest request)
{
var attempt = await store.GetOwnedAsync(id, UserId());
if (attempt is null) return NotFound();
try { return Ok(await store.ToResponseAsync(await store.ApplyBatchCommandAsync(attempt, request))); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpPost("attempts/{id}/abandon")]
public async Task<IActionResult> Abandon(string id)
{
@@ -0,0 +1,20 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/credits")]
[Authorize(Roles = "USER,SUPER")]
public sealed class CreditsController(CreditStore credits) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId()));
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
}
@@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController, Route("api/dynamic-questions"), Authorize(Roles = "USER,SUPER")]
public sealed class DynamicQuestionsController : ControllerBase
{
private readonly DynamicQuestionService _questions;
private readonly QuestionReportStore _reports;
public DynamicQuestionsController(DynamicQuestionService questions, QuestionReportStore reports)
{
_questions = questions;
_reports = reports;
}
[HttpGet("status")]
public async Task<ActionResult<DynamicQuestionStatusResponse>> Status() =>
Ok(await _questions.StatusAsync(UserId()));
[HttpPost("next")]
public async Task<ActionResult<DynamicQuestionEnvelope>> Next([FromBody] DynamicNextRequest request)
{
try { return Ok(await _questions.NextAsync(UserId(), request.RunToken)); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpPost("attempt")]
public async Task<ActionResult<DynamicAttemptResponse>> Attempt([FromBody] DynamicAttemptRequest request)
{
if (string.IsNullOrWhiteSpace(request.GenerationToken))
return BadRequest("generationToken required");
try { return Ok(await _questions.AttemptAsync(UserId(), request)); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpPost("report")]
public async Task<IActionResult> Report([FromBody] DynamicReportRequest request)
{
var category = request.Category.Trim().ToLowerInvariant();
var details = request.Details.Trim();
if (!QuestionReportCategory.All.Contains(category))
return BadRequest("category is invalid");
if (details.Length is < 10 or > 2000)
return BadRequest("details must be between 10 and 2000 characters");
try
{
var (claims, _) = await _questions.RegenerateAsync(UserId(), request.GenerationToken);
var report = await _reports.CreateDynamicAsync(claims, UserId(), category, details);
return StatusCode(StatusCodes.Status201Created, new { id = report.Id, questionExternalId = report.QuestionExternalId });
}
catch (ArgumentException exception) { return BadRequest(exception.Message); }
catch (KeyNotFoundException) { return NotFound(); }
}
private string UserId() =>
User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? "";
}
@@ -0,0 +1,415 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services.Flashcards;
using System.Security.Claims;
using System.Text;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/flashcards")]
[Authorize(Roles = "USER,SUPER")]
public sealed class FlashcardsController(
FlashcardDeckStore decks,
FlashcardNoteStore notes,
FlashcardScheduleStore schedules,
FlashcardSettingsStore settings,
FlashcardMediaStore media,
FlashcardStudyService study,
FlashcardImportExportService importExport) : ControllerBase
{
[HttpGet("decks")]
public async Task<IActionResult> ListDecks()
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
await decks.GetOrCreateDefaultDeckAsync(userId);
var now = DateTime.UtcNow;
var summaries = await study.GetDeckSummariesAsync(userId, now, now.Date);
return Ok(summaries);
}
[HttpPost("decks")]
public async Task<IActionResult> CreateDeck([FromBody] UpsertFlashcardDeckRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("name required");
var result = await decks.UpsertAsync(userId, request, null);
if (!result.Succeeded) return Conflict(result.Current);
return Ok(result.Deck);
}
[HttpPut("decks/{deckId}")]
public async Task<IActionResult> UpdateDeck(string deckId, [FromBody] UpsertFlashcardDeckRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Name)) return BadRequest("name required");
if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true))
return NotFound();
var result = await decks.UpsertAsync(userId, request, deckId);
if (!result.Succeeded) return Conflict(result.Current);
return Ok(result.Deck);
}
[HttpPost("decks/{deckId}/archive")]
public async Task<IActionResult> ArchiveDeck(string deckId)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true))
return NotFound();
var result = await decks.ArchiveAsync(userId, deckId, true);
if (!result.Succeeded) return Conflict("cannot archive the default deck, or deck not found");
return Ok(result.Deck);
}
[HttpPost("decks/{deckId}/restore")]
public async Task<IActionResult> RestoreDeck(string deckId)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(deckId))?.IsSystem == true))
return NotFound();
var result = await decks.ArchiveAsync(userId, deckId, false);
if (!result.Succeeded) return NotFound();
return Ok(result.Deck);
}
[HttpPost("notes/search")]
public async Task<IActionResult> SearchNotes([FromBody] FlashcardLibraryQuery query)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var (items, total) = await notes.SearchVisibleAsync(userId, query);
return Ok(new FlashcardLibraryResponse { Items = items, Total = total });
}
[HttpGet("notes/{noteId}")]
public async Task<IActionResult> GetNote(string noteId)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var note = await notes.GetVisibleAsync(userId, noteId);
if (note is null) return NotFound();
return Ok(note);
}
[HttpPost("notes")]
public async Task<IActionResult> CreateNote([FromBody] UpsertFlashcardNoteRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest("prompt required");
if (string.IsNullOrWhiteSpace(request.DeckId))
request.DeckId = (await decks.GetOrCreateDefaultDeckAsync(userId)).Id!;
else if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(request.DeckId))?.IsSystem == true))
return BadRequest("cannot add cards to a system deck");
try
{
var result = await notes.UpsertAsync(userId, request, null);
if (!result.Succeeded) return Conflict(result.Current);
return Ok(result.Note);
}
catch (FlashcardValidationException exception)
{
return BadRequest(exception.Message);
}
}
[HttpPut("notes/{noteId}")]
public async Task<IActionResult> UpdateNote(string noteId, [FromBody] UpsertFlashcardNoteRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.Prompt)) return BadRequest("prompt required");
var isSuper = User.IsInRole("SUPER");
if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true))
return NotFound();
if (!string.IsNullOrWhiteSpace(request.DeckId)
&& !FlashcardOwnershipGuard.CanMutate(isSuper, (await decks.GetAnyAsync(request.DeckId))?.IsSystem == true))
return BadRequest("cannot move cards into a system deck");
try
{
var result = await notes.UpsertAsync(userId, request, noteId);
if (!result.Succeeded) return Conflict(result.Current);
return Ok(result.Note);
}
catch (FlashcardValidationException exception)
{
return BadRequest(exception.Message);
}
}
[HttpPost("notes/move")]
public async Task<IActionResult> MoveNotes([FromBody] FlashcardMoveRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (string.IsNullOrWhiteSpace(request.TargetDeckId)) return BadRequest("targetDeckId required");
if (!FlashcardOwnershipGuard.CanMutate(User.IsInRole("SUPER"), (await decks.GetAnyAsync(request.TargetDeckId))?.IsSystem == true))
return BadRequest("cannot move cards into a system deck");
var moved = await notes.MoveAsync(userId, request.NoteIds, request.TargetDeckId);
return Ok(new { moved });
}
[HttpPost("notes/archive")]
public async Task<IActionResult> ArchiveNotes([FromBody] FlashcardBulkNoteIdsRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var isSuper = User.IsInRole("SUPER");
var results = new List<FlashcardNote>();
foreach (var noteId in request.NoteIds)
{
if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true)) continue;
var result = await notes.ArchiveAsync(userId, noteId, true);
if (result.Note is not null) results.Add(result.Note);
}
return Ok(results);
}
[HttpPost("notes/restore")]
public async Task<IActionResult> RestoreNotes([FromBody] FlashcardBulkNoteIdsRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var isSuper = User.IsInRole("SUPER");
var results = new List<FlashcardNote>();
foreach (var noteId in request.NoteIds)
{
if (!FlashcardOwnershipGuard.CanMutate(isSuper, (await notes.GetAnyAsync(noteId))?.IsSystem == true)) continue;
var result = await notes.ArchiveAsync(userId, noteId, false);
if (result.Note is not null) results.Add(result.Note);
}
return Ok(results);
}
[HttpPost("cards/suspend")]
public async Task<IActionResult> SuspendCards([FromBody] FlashcardSuspendRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var changed = await schedules.SetSuspendedAsync(userId, request.CardIds, request.Suspended);
return Ok(new { changed });
}
[HttpPost("cards/bury")]
public async Task<IActionResult> BuryCards([FromBody] FlashcardBuryRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var until = request.UntilUtc == default ? DateTime.UtcNow.Date.AddDays(1) : request.UntilUtc;
var changed = await schedules.BuryAsync(userId, request.CardIds, until);
return Ok(new { changed });
}
[HttpPost("study/next")]
public async Task<IActionResult> NextCard([FromBody] FlashcardStudyRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var response = await study.GetNextCardAsync(userId, request);
return Ok(response);
}
[HttpGet("counts")]
public async Task<IActionResult> GetCounts()
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var now = DateTime.UtcNow;
var counts = await study.GetDashboardCountsAsync(userId, now, now.Date.AddDays(-6));
return Ok(counts);
}
[HttpGet("settings")]
public async Task<IActionResult> GetSettings()
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
return Ok(await settings.GetAsync(userId));
}
[HttpPut("settings")]
public async Task<IActionResult> UpdateSettings([FromBody] UpdateFlashcardSettingsRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
return Ok(await settings.UpdateAsync(userId, request));
}
[HttpPost("reviews")]
public async Task<IActionResult> SubmitReviews([FromBody] FlashcardBatchReviewRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var results = new List<FlashcardReviewResponse>();
foreach (var review in request.Reviews)
{
if (!Guid.TryParse(review.ClientReviewId, out _)) return BadRequest("clientReviewId must be a UUID");
if (!FlashcardRatings.All.Contains(review.Rating)) return BadRequest("rating must be again/hard/good/easy");
var outcome = await schedules.SubmitReviewAsync(userId, review);
switch (outcome.Kind)
{
case FlashcardReviewOutcomeKind.NotFound:
return NotFound($"card {review.CardId} not found");
case FlashcardReviewOutcomeKind.Conflict:
return Conflict(new { cardId = review.CardId, current = outcome.Schedule });
case FlashcardReviewOutcomeKind.Duplicate:
results.Add(new FlashcardReviewResponse { Card = outcome.Schedule!, Duplicate = true });
break;
default:
results.Add(new FlashcardReviewResponse
{
Card = outcome.Schedule!,
RatingOptions = schedules.PreviewRatingOptions(outcome.Schedule!, DateTime.UtcNow)
});
break;
}
}
return Ok(new FlashcardBatchReviewResponse { Results = results });
}
[HttpPost("reviews/undo")]
public async Task<IActionResult> UndoReview([FromBody] FlashcardUndoRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var outcome = await schedules.UndoAsync(userId, request.ReviewLogId);
return outcome.Kind switch
{
FlashcardReviewOutcomeKind.NotFound => NotFound(),
FlashcardReviewOutcomeKind.Conflict => Conflict(outcome.Schedule),
_ => Ok(outcome.Schedule)
};
}
[HttpPost("media")]
[RequestSizeLimit(5_000_000)]
public async Task<IActionResult> UploadMedia(IFormFile file)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (file is null || file.Length == 0) return BadRequest("file required");
if (file.Length > 5_000_000) return BadRequest("file must be 5 MB or smaller");
var contentType = file.ContentType.Trim().ToLowerInvariant();
var allowedTypes = new HashSet<string> { "image/png", "image/jpeg", "image/webp" };
if (!allowedTypes.Contains(contentType)) return BadRequest("file must be a png, jpeg, or webp image");
await using var stream = file.OpenReadStream();
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
var asset = await media.CreateAsync(userId, file.FileName, contentType, memory.ToArray());
return Ok(new { id = asset.Id ?? "", url = $"/api/flashcards/media/{asset.Id}" });
}
[HttpGet("media/{id}")]
public async Task<IActionResult> GetMedia(string id)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var asset = await media.GetAsync(userId, id.Trim());
if (asset is null) return NotFound();
return File(asset.Bytes, asset.ContentType);
}
[HttpGet("export/csv")]
public async Task<IActionResult> ExportCsv()
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var (csv, omissions) = await importExport.ExportCsvAsync(userId);
Response.Headers.Append("X-Flashcard-Export-Omissions", string.Join(';', omissions.Select(v => v.Kind)));
return File(Encoding.UTF8.GetBytes(csv), "text/csv", "flashcards.csv");
}
[HttpGet("export/json")]
public async Task<IActionResult> ExportJson()
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var export = await importExport.ExportJsonAsync(userId);
return Ok(export);
}
[HttpPost("import/csv/preview")]
[RequestSizeLimit(25 * 1024 * 1024)]
public async Task<IActionResult> PreviewCsvImport(IFormFile file)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (file is null || file.Length == 0) return BadRequest("file required");
try
{
FlashcardImportExportService.ValidateSize(file.Length);
using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8);
var text = await reader.ReadToEndAsync();
return Ok(await importExport.PreviewCsvAsync(userId, text));
}
catch (FlashcardValidationException exception)
{
return BadRequest(exception.Message);
}
}
[HttpPost("import/json/preview")]
[RequestSizeLimit(25 * 1024 * 1024)]
public async Task<IActionResult> PreviewJsonImport(IFormFile file)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
if (file is null || file.Length == 0) return BadRequest("file required");
try
{
FlashcardImportExportService.ValidateSize(file.Length);
using var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8);
var text = await reader.ReadToEndAsync();
return Ok(await importExport.PreviewJsonAsync(userId, text));
}
catch (FlashcardValidationException exception)
{
return BadRequest(exception.Message);
}
}
[HttpPost("import/commit")]
public async Task<IActionResult> CommitImport([FromBody] FlashcardImportCommitRequest request)
{
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
try
{
return Ok(await importExport.CommitAsync(userId, request.ImportToken));
}
catch (FlashcardValidationException exception)
{
return BadRequest(exception.Message);
}
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? "";
}
public sealed class FlashcardSuspendRequest
{
public List<string> CardIds { get; set; } = [];
public bool Suspended { get; set; } = true;
}
public sealed class FlashcardBuryRequest
{
public List<string> CardIds { get; set; } = [];
public DateTime UntilUtc { get; set; }
}
@@ -27,6 +27,14 @@ public sealed class ProfileController(UserProfileStore profiles, IConfiguration
return Ok(await profiles.UpsertAsync(UserId(), request));
}
[HttpPut("me/text-scale")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> UpdateTextScale(UpdateTextScaleRequest request)
{
if (UpdateTextScaleRequestValidator.Validate(request) is { } error) return BadRequest(error);
return Ok(await profiles.UpdateTextScaleAsync(UserId(), request));
}
[HttpGet("me/export")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Export() => Ok(new
@@ -39,7 +39,7 @@ public class QuestionReportsController : ControllerBase
return BadRequest($"details must be between {MinDetailsLength} and {MaxDetailsLength} characters");
var question = await _questions.GetByExternalIdAsync(questionExternalId);
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
return NotFound();
var userId = CurrentUserId();
@@ -19,6 +19,7 @@ public class QuestionsController : ControllerBase
private readonly ServiceMetrics _metrics;
private readonly QuestionBookmarkStore _bookmarks;
private readonly ReviewQueueStore _reviewQueue;
private readonly CreditStore _credits;
public QuestionsController(
QuestionStore questions,
@@ -28,7 +29,8 @@ public class QuestionsController : ControllerBase
AnswerStatisticsStore statistics,
ServiceMetrics metrics,
QuestionBookmarkStore bookmarks,
ReviewQueueStore reviewQueue)
ReviewQueueStore reviewQueue,
CreditStore credits)
{
_questions = questions;
_attempts = attempts;
@@ -38,6 +40,7 @@ public class QuestionsController : ControllerBase
_metrics = metrics;
_bookmarks = bookmarks;
_reviewQueue = reviewQueue;
_credits = credits;
}
[HttpGet("systems")]
@@ -94,7 +97,7 @@ public class QuestionsController : ControllerBase
public async Task<IActionResult> Get(string id)
{
var question = await _questions.GetByIdAsync(id);
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
return NotFound();
var percentages = await _statistics.GetPercentagesForQuestionAsync(question.ExternalId);
@@ -119,7 +122,7 @@ public class QuestionsController : ControllerBase
return BadRequest("choiceId required");
var question = await _questions.GetByExternalIdAsync(req.QuestionExternalId.Trim());
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
return NotFound();
if (!question.Choices.Any(c => c.Id == req.ChoiceId.Trim()))
return BadRequest("choiceId must match a choice id");
@@ -172,6 +175,8 @@ public class QuestionsController : ControllerBase
_metrics.AttemptDuplicate();
}
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
if (record.Inserted && record.Attempt.IsCorrect && req.MetTimeLimit)
await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? "");
var ratingOwner = Guid.NewGuid().ToString("N");
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
@@ -187,7 +192,7 @@ public class QuestionsController : ControllerBase
record.Attempt.IsCorrect,
shouldRate);
if (shouldRate)
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner);
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner, rating.UserOverallElo);
}
catch
{
@@ -204,7 +209,8 @@ public class QuestionsController : ControllerBase
question,
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo,
Rating = rating
Rating = rating,
Credits = await _credits.GetStatusAsync(userId)
});
}
@@ -258,7 +264,7 @@ public class QuestionsController : ControllerBase
return BadRequest("externalId required");
var question = await _questions.GetByExternalIdAsync(externalId.Trim());
if (question is null || (!question.IsPublished && !User.IsInRole("SUPER")))
if (question is null || (!QuestionVisibility.IsVisibleToStudent(question.IsPublished, question.FlaggedForReviewUtc != null) && !User.IsInRole("SUPER")))
return NotFound();
var userId = CurrentUserId();
@@ -286,7 +292,7 @@ public class QuestionsController : ControllerBase
{
rating = await _ratings.ApplyFirstResultAsync(userId, question, false, shouldRate);
if (shouldRate)
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner);
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner, rating.UserOverallElo);
}
catch
{
@@ -404,6 +410,37 @@ public class QuestionsController : ControllerBase
return Ok("Deleted");
}
[HttpPost("{id}/flag")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Flag(string id, [FromBody] FlagQuestionRequest req)
{
if (string.IsNullOrWhiteSpace(req.Reason))
return BadRequest("reason required");
var flagged = await _questions.FlagAsync(id, req.Reason.Trim(), CurrentUserId());
if (flagged is null)
return NotFound();
return Ok(QuestionFlagResponse.FromModel(flagged));
}
[HttpPost("{id}/unflag")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Unflag(string id)
{
var unflagged = await _questions.UnflagAsync(id);
if (unflagged is null)
return NotFound();
return Ok(QuestionFlagResponse.FromModel(unflagged));
}
[HttpGet("admin/flagged")]
[Authorize(Roles = "SUPER")]
public async Task<IActionResult> Flagged()
{
var flagged = await _questions.GetFlaggedAsync();
return Ok(flagged.Select(QuestionFlagResponse.FromModel));
}
private string CurrentUserId() =>
User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
+6 -3
View File
@@ -4,16 +4,19 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_REVISION
WORKDIR /src
COPY ["QuestionsApi.csproj", "./"]
RUN dotnet restore "QuestionsApi.csproj"
COPY ["api/QuestionsApi/QuestionsApi.csproj", "api/QuestionsApi/"]
RUN dotnet restore "api/QuestionsApi/QuestionsApi.csproj"
COPY . .
COPY api/QuestionsApi/ api/QuestionsApi/
COPY content/dynamic_questions/ content/dynamic_questions/
WORKDIR /src/api/QuestionsApi
RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false /p:SourceRevisionId=${BUILD_REVISION}
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
ARG BUILD_REVISION
WORKDIR /app
COPY --from=build /app/publish .
COPY --from=build /src/content/dynamic_questions ./content/dynamic_questions
ENV ASPNETCORE_URLS=http://+:5008 \
ASPNETCORE_ENVIRONMENT=Production \
+82 -2
View File
@@ -17,6 +17,26 @@ public static class CcsAttemptModes
public const string Timed = "timed";
}
public static class CcsChartCategories
{
public const string Orders = "orders";
public const string Progress = "progress";
public const string Vitals = "vitals";
public const string Lab = "lab";
public const string Imaging = "imaging";
public const string OtherTest = "other_test";
public const string Treatment = "treatment";
public static readonly IReadOnlySet<string> All = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
Orders, Progress, Vitals, Lab, Imaging, OtherTest, Treatment
};
public static readonly IReadOnlySet<string> ResultSections = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
Lab, Imaging, OtherTest, Treatment
};
}
public class CcsOrderDefinition
{
[BsonId] public string Code { get; set; } = "";
@@ -25,6 +45,9 @@ public class CcsOrderDefinition
public List<string> Aliases { get; set; } = [];
public int DefaultTurnaroundMinutes { get; set; }
public List<string> AvailableLocations { get; set; } = [];
public string DisplayRoute { get; set; } = "";
public string DisplayFrequency { get; set; } = "";
public string ResultSection { get; set; } = "";
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
@@ -57,11 +80,19 @@ public class CcsInitialChart
public string ReasonForEncounter { get; set; } = "";
public string Appearance { get; set; } = "";
public string History { get; set; } = "";
public List<CcsNarrativeSection> HistorySections { get; set; } = [];
public Dictionary<string, string> Vitals { get; set; } = [];
public DateTime SimulatedStartUtc { get; set; } = DateTime.UtcNow;
public List<string> InitialOrderCodes { get; set; } = [];
}
public class CcsNarrativeSection
{
public string Code { get; set; } = "";
public string Title { get; set; } = "";
public string Text { get; set; } = "";
}
public class CcsExamDefinition
{
public string Code { get; set; } = "";
@@ -75,6 +106,7 @@ public class CcsCaseOrderDefinition
public string OrderCode { get; set; } = "";
public int? TurnaroundMinutes { get; set; }
public string Result { get; set; } = "";
public string ResultSection { get; set; } = "";
public Dictionary<string, string> VitalPatches { get; set; } = [];
}
@@ -111,19 +143,22 @@ public class CcsAttempt
public string CaseExternalId { get; set; } = "";
[BsonRepresentation(BsonType.ObjectId)] public string CaseRevisionId { get; set; } = "";
public int CaseVersion { get; set; }
public string Mode { get; set; } = CcsAttemptModes.Practice;
public string Mode { get; set; } = CcsAttemptModes.Timed;
public string State { get; set; } = CcsAttemptStates.Active;
public int Revision { get; set; }
public int SimulatedMinute { get; set; }
[BsonIgnoreIfNull] public bool? OpeningCompleted { get; set; }
public string Location { get; set; } = "";
public Dictionary<string, string> CurrentVitals { get; set; } = [];
public List<CcsAttemptAction> Actions { get; set; } = [];
public List<CcsPendingResult> PendingResults { get; set; } = [];
public List<CcsChartEntry> Chart { get; set; } = [];
public List<CcsVitalSnapshot> VitalHistory { get; set; } = [];
public List<string> ActiveOrderCodes { get; set; } = [];
public List<string> ProcessedEventIds { get; set; } = [];
public List<string> ProcessedCommandIds { get; set; } = [];
public DateTime? DeadlineUtc { get; set; }
public DateTime? HardDeadlineUtc { get; set; }
public DateTime? FinalOrdersDeadlineUtc { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
@@ -144,6 +179,8 @@ public class CcsAttemptAction
public class CcsPendingResult
{
public string OrderCode { get; set; } = "";
public string Category { get; set; } = "";
public string Title { get; set; } = "";
public int DueMinute { get; set; }
public string Result { get; set; } = "";
public Dictionary<string, string> VitalPatches { get; set; } = [];
@@ -153,9 +190,30 @@ public class CcsChartEntry
{
public string Category { get; set; } = "progress";
public int SimulatedMinute { get; set; }
public string Code { get; set; } = "";
public string Title { get; set; } = "";
public string Text { get; set; } = "";
}
public class CcsVitalSnapshot
{
public int SimulatedMinute { get; set; }
public Dictionary<string, string> Values { get; set; } = [];
}
public class CcsActiveOrder
{
public string Code { get; set; } = "";
public string Name { get; set; } = "";
public string Category { get; set; } = "";
public string Route { get; set; } = "";
public string Frequency { get; set; } = "";
public int OrderedMinute { get; set; }
public int? ReportMinute { get; set; }
public bool IsPending { get; set; }
public bool HasResult { get; set; }
}
public class CcsCaseScore
{
public double Percent { get; set; }
@@ -194,7 +252,7 @@ public class CcsImportResponse
public class CreateCcsAttemptRequest
{
public string CaseExternalId { get; set; } = "";
public string Mode { get; set; } = CcsAttemptModes.Practice;
public string Mode { get; set; } = CcsAttemptModes.Timed;
}
public class CcsCommandRequest
@@ -206,6 +264,20 @@ public class CcsCommandRequest
public int Minutes { get; set; }
}
public class CcsBatchCommandRequest
{
public string CommandId { get; set; } = "";
public int ExpectedRevision { get; set; }
public List<CcsBatchCommandItem> Commands { get; set; } = [];
}
public class CcsBatchCommandItem
{
public string Type { get; set; } = "";
public string Code { get; set; } = "";
public int Minutes { get; set; }
}
public class CcsCaseSummary
{
public string ExternalId { get; set; } = "";
@@ -226,8 +298,11 @@ public class CcsAttemptResponse
public string State { get; set; } = "";
public int Revision { get; set; }
public int SimulatedMinute { get; set; }
public DateTime SimulatedStartUtc { get; set; }
public bool OpeningCompleted { get; set; }
public string Location { get; set; } = "";
public DateTime? DeadlineUtc { get; set; }
public DateTime? HardDeadlineUtc { get; set; }
public DateTime? FinalOrdersDeadlineUtc { get; set; }
public DateTime CreatedUtc { get; set; }
public int RealTimeMinutes { get; set; }
@@ -236,9 +311,12 @@ public class CcsAttemptResponse
public string ReasonForEncounter { get; set; } = "";
public string Appearance { get; set; } = "";
public string History { get; set; } = "";
public List<CcsNarrativeSection> HistorySections { get; set; } = [];
public Dictionary<string, string> Vitals { get; set; } = [];
public List<CcsChartEntry> Chart { get; set; } = [];
public List<string> ActiveOrderCodes { get; set; } = [];
public List<CcsActiveOrder> ActiveOrders { get; set; } = [];
public List<CcsVitalSnapshot> VitalHistory { get; set; } = [];
public List<CcsPendingResult> PendingResults { get; set; } = [];
public List<CcsExamDefinition> AvailableExams { get; set; } = [];
public List<string> AllowedLocations { get; set; } = [];
@@ -271,6 +349,8 @@ public class CcsUserRating
public string UserId { get; set; } = "";
public int Elo { get; set; } = 1000;
public int RatedCaseCount { get; set; }
public int? HistoryBaselineElo { get; set; }
public DateTime? HistoryStartedUtc { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
+35
View File
@@ -0,0 +1,35 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class CreditEconomy
{
public const int RollingEarningLimit = 20;
public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24);
}
public sealed class CreditEvent
{
public string OperationId { get; set; } = "";
public DateTime CreatedUtc { get; set; }
}
public sealed class UserCreditAccount
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public int Credits { get; set; }
public List<CreditEvent> RecentEarnings { get; set; } = [];
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class CreditStatusResponse
{
public int Credits { get; set; }
public int EarnedInRollingWindow { get; set; }
public int RemainingEarningCapacity { get; set; }
public int RollingWindowHours { get; set; } = 24;
}
@@ -10,12 +10,23 @@ public sealed class DashboardResponse
public int CcsCasesCompleted { get; set; }
public int FirstAttemptAccuracy { get; set; }
public int AccuracySampleSize { get; set; }
public int? AccuracyDeltaVsPreviousSample { get; set; }
public int? QuestionBankRatingDeltaThisWeek { get; set; }
public int? QuestionBankPercentile { get; set; }
public int? CcsRatingDeltaThisWeek { get; set; }
public int? CcsPercentile { get; set; }
public string? ExamReadinessLabel { get; set; }
public int? ExamReadinessProjectedPassPercent { get; set; }
public int QuestionsThisWeek { get; set; }
public int StudyTimeSecondsThisWeek { get; set; }
public int CurrentStreak { get; set; }
public int MissedQuestionCount { get; set; }
public int DynamicReviewsDue { get; set; }
public int BookmarkCount { get; set; }
public int NoteCount { get; set; }
public int FlashcardsDue { get; set; }
public int FlashcardsNewAvailable { get; set; }
public int FlashcardsReviewedThisWeek { get; set; }
public List<DashboardTrendPoint> PerformanceTrend { get; set; } = [];
public List<DashboardSystemPerformance> Systems { get; set; } = [];
public List<DashboardActivityDay> WeeklyActivity { get; set; } = [];
@@ -28,6 +39,8 @@ public sealed class DashboardTrendPoint
public int Correct { get; set; }
public int Total { get; set; }
public int Accuracy { get; set; }
public int? QuestionBankRating { get; set; }
public int? CcsRating { get; set; }
}
public sealed class DashboardSystemPerformance
@@ -0,0 +1,319 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public sealed class DynamicQuestionBlueprint
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string ExternalId { get; set; } = "";
public int Version { get; set; } = 1;
public string ObjectiveId { get; set; } = "";
public string Title { get; set; } = "";
public string System { get; set; } = "";
public string Discipline { get; set; } = "";
public string PerformanceCategory { get; set; } = "";
public string PhysicianTask { get; set; } = "";
public string EncounterFrame { get; set; } = "";
public string SiteOfCare { get; set; } = "";
public double BlueprintWeight { get; set; }
public List<string> ConceptIds { get; set; } = [];
public List<string> PrerequisiteConceptIds { get; set; } = [];
public DateTime? MedicallyReviewedUtc { get; set; }
public DateTime? MedicalReviewExpiresUtc { get; set; }
public string MedicalReviewer { get; set; } = "";
public int Difficulty { get; set; } = 3;
public int QualityStandardVersion { get; set; } = 1;
public string CorrectConceptId { get; set; } = "";
public int BaseBlueprintVersion { get; set; }
public bool IsPublished { get; set; } = true;
public string StemTemplate { get; set; } = "";
public string LeadIn { get; set; } = "";
public Dictionary<string, DynamicVariableDefinition> Variables { get; set; } = [];
public Dictionary<string, DynamicDerivedDefinition> Derived { get; set; } = [];
public List<DynamicConstraint> Constraints { get; set; } = [];
public List<DynamicLabTemplate> Labs { get; set; } = [];
public DynamicAnswerTemplate CorrectAnswer { get; set; } = new();
public List<DynamicAnswerTemplate> Distractors { get; set; } = [];
public string ExplanationTemplate { get; set; } = "";
public string KeyTakeaway { get; set; } = "";
public string UsmlePearl { get; set; } = "";
public List<QuestionReference> References { get; set; } = [];
public List<string> Tags { get; set; } = [];
public List<DynamicReviewFollowupTemplate> ReviewFollowups { get; set; } = [];
public Dictionary<string, DynamicWrongAnswerFollowupTemplate> WrongAnswerFollowups { get; set; } = [];
public List<DynamicCaseFamily> CaseFamilies { get; set; } = [];
}
public sealed class DynamicCaseFamily
{
public string Id { get; set; } = "";
public string BaseFamilyId { get; set; } = "";
public string DifficultyTier { get; set; } = "standard";
public int NominalElo { get; set; } = 1050;
public string StemTemplate { get; set; } = "";
public string LeadIn { get; set; } = "";
public Dictionary<string, DynamicVariableDefinition> Variables { get; set; } = [];
public List<Dictionary<string, string>> ValueBundles { get; set; } = [];
public Dictionary<string, DynamicDerivedDefinition> Derived { get; set; } = [];
public List<DynamicConstraint> Constraints { get; set; } = [];
public List<DynamicLabTemplate> Labs { get; set; } = [];
public DynamicAnswerTemplate CorrectAnswer { get; set; } = new();
public List<DynamicAnswerTemplate> Distractors { get; set; } = [];
public string ExplanationTemplate { get; set; } = "";
public string KeyTakeaway { get; set; } = "";
public string UsmlePearl { get; set; } = "";
public List<DynamicReviewFollowupTemplate> ReviewFollowups { get; set; } = [];
public Dictionary<string, DynamicWrongAnswerFollowupTemplate> WrongAnswerFollowups { get; set; } = [];
}
public sealed class DynamicReviewFollowupTemplate
{
public string PromptTemplate { get; set; } = "";
public List<DynamicAnswerTemplate> Choices { get; set; } = [];
public string CorrectChoiceId { get; set; } = "";
public string TeachingPointTemplate { get; set; } = "";
}
public sealed class DynamicWrongAnswerFollowupTemplate
{
public string PromptTemplate { get; set; } = "";
public List<DynamicAnswerTemplate> Choices { get; set; } = [];
public string CorrectChoiceId { get; set; } = "";
public string TeachingPointTemplate { get; set; } = "";
}
public sealed class DynamicVariableDefinition
{
public string Type { get; set; } = "integer";
public double Minimum { get; set; }
public double Maximum { get; set; }
public double Step { get; set; } = 1;
public int Precision { get; set; }
public List<string> Values { get; set; } = [];
}
public sealed class DynamicDerivedDefinition
{
public string Operation { get; set; } = "";
public string Left { get; set; } = "";
public string Right { get; set; } = "";
public int Precision { get; set; }
}
public sealed class DynamicConstraint
{
public string Field { get; set; } = "";
public string Operator { get; set; } = "";
public double Value { get; set; }
}
public sealed class DynamicLabTemplate
{
public string Name { get; set; } = "";
public string ValueTemplate { get; set; } = "";
public string Unit { get; set; } = "";
public string Reference { get; set; } = "";
}
public sealed class DynamicAnswerTemplate
{
public string Id { get; set; } = "";
public string TextTemplate { get; set; } = "";
public string ExplanationTemplate { get; set; } = "";
public string MisconceptionId { get; set; } = "";
public List<string> RemediationConceptIds { get; set; } = [];
}
public sealed class DynamicGenerationClaims
{
public string UserId { get; set; } = "";
public string BlueprintExternalId { get; set; } = "";
public int BlueprintVersion { get; set; }
public string ObjectiveId { get; set; } = "";
public string Seed { get; set; } = "";
public string Nonce { get; set; } = "";
public int GeneratorVersion { get; set; } = 1;
public string CaseFamilyId { get; set; } = "";
public string DifficultyTier { get; set; } = "";
public int EffectiveAbilityElo { get; set; }
public int FamilyElo { get; set; }
public bool IsScheduledReview { get; set; }
public string ReviewItemId { get; set; } = "";
public int ReviewStage { get; set; }
public DateTime ExpiresUtc { get; set; }
}
public sealed class DynamicRunClaims
{
public string UserId { get; set; } = "";
public List<string> SeenObjectiveIds { get; set; } = [];
public string PreviousObjectiveId { get; set; } = "";
public int Cycle { get; set; }
public List<string> RecentCaseFamilyIds { get; set; } = [];
public DateTime ExpiresUtc { get; set; }
}
public sealed class DynamicGeneratedQuestion
{
public Question Question { get; set; } = new();
public DynamicQuestionBlueprint Blueprint { get; set; } = new();
public string CaseFamilyId { get; set; } = "";
}
public sealed class DynamicQuestionEnvelope
{
public QuestionResponse Question { get; set; } = new();
public string Source { get; set; } = "dynamic";
public string ObjectiveId { get; set; } = "";
public string ObjectiveTitle { get; set; } = "";
public string GenerationToken { get; set; } = "";
public string RunToken { get; set; } = "";
public int CycleSeen { get; set; }
public int TotalObjectives { get; set; }
}
public sealed class DynamicNextRequest
{
public string RunToken { get; set; } = "";
}
public sealed class DynamicAttemptRequest
{
public string GenerationToken { get; set; } = "";
public string ChoiceId { get; set; } = "";
public bool IsSkip { get; set; }
public int DurationSeconds { get; set; }
}
public sealed class DynamicAttemptResponse
{
public bool Inserted { get; set; }
public bool IsCorrect { get; set; }
public string CorrectChoiceId { get; set; } = "";
public string ObjectiveId { get; set; } = "";
public int ObjectiveElo { get; set; }
public int RatingChange { get; set; }
public string SelectedMisconceptionId { get; set; } = "";
public bool IsScheduledReview { get; set; }
public DateTime? NextReviewUtc { get; set; }
}
public sealed class DynamicReportRequest
{
public string GenerationToken { get; set; } = "";
public string Category { get; set; } = "";
public string Details { get; set; } = "";
}
public sealed class DynamicQuestionStatusResponse
{
public bool Available { get; set; }
public int PublishedObjectives { get; set; }
public int AttemptedObjectives { get; set; }
public int TotalAttempts { get; set; }
public int AverageObjectiveElo { get; set; } = 1000;
public int ReviewsDue { get; set; }
public List<DynamicCoverageSummary> SystemCoverage { get; set; } = [];
public List<DynamicCoverageSummary> PhysicianTaskCoverage { get; set; } = [];
}
public sealed class DynamicCoverageSummary
{
public string Id { get; set; } = "";
public int PublishedObjectives { get; set; }
public int AttemptedObjectives { get; set; }
public int Attempts { get; set; }
public double CoveragePercent { get; set; }
public int AverageElo { get; set; } = 1000;
}
public sealed class DynamicQuestionAttempt
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public string Nonce { get; set; } = "";
public string BlueprintExternalId { get; set; } = "";
public int BlueprintVersion { get; set; }
public string ObjectiveId { get; set; } = "";
public string CaseFamilyId { get; set; } = "";
public string DifficultyTier { get; set; } = "";
public int EffectiveAbilityElo { get; set; }
public int FamilyElo { get; set; }
public string ChoiceId { get; set; } = "";
public bool IsCorrect { get; set; }
public bool IsSkip { get; set; }
public string SelectedMisconceptionId { get; set; } = "";
public bool IsScheduledReview { get; set; }
public int DurationSeconds { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}
public static class DynamicReviewStates
{
public const string Pending = "pending";
public const string Mastered = "mastered";
}
public sealed class DynamicReviewItem
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public string ObjectiveId { get; set; } = "";
public string BlueprintExternalId { get; set; } = "";
public int BlueprintVersion { get; set; }
public string MisconceptionId { get; set; } = "";
public string State { get; set; } = DynamicReviewStates.Pending;
public int Stage { get; set; }
public int ReviewCount { get; set; }
public DateTime DueUtc { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class DynamicObjectiveMastery
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public string ObjectiveId { get; set; } = "";
public int Elo { get; set; } = 1000;
public int Attempts { get; set; }
public int Correct { get; set; }
public DateTime LastSeenUtc { get; set; }
}
public sealed class DynamicFamilyRating
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string BlueprintExternalId { get; set; } = "";
public int BlueprintVersion { get; set; }
public string CaseFamilyId { get; set; } = "";
public string DifficultyTier { get; set; } = "";
public int NominalElo { get; set; }
public int Elo { get; set; }
public int Attempts { get; set; }
public int Correct { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class DynamicGenerationDescriptor
{
public string BlueprintExternalId { get; set; } = "";
public int BlueprintVersion { get; set; }
public int GeneratorVersion { get; set; }
public string Seed { get; set; } = "";
public string CaseFamilyId { get; set; } = "";
public string DifficultyTier { get; set; } = "";
public int FamilyElo { get; set; }
public int EffectiveAbilityElo { get; set; }
}
+396
View File
@@ -0,0 +1,396 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class FlashcardTypes
{
public const string Basic = "basic";
public const string Cloze = "cloze";
public static readonly HashSet<string> All = [Basic, Cloze];
}
// A reserved, never-authenticatable owner id. Decks/notes owned by this id are shared,
// admin-curated content visible to every user (studied via their own independent schedule
// rows) rather than a real user's private content.
public static class FlashcardOwners
{
public const string System = "system";
}
public static class FlashcardRatings
{
public const string Again = "again";
public const string Hard = "hard";
public const string Good = "good";
public const string Easy = "easy";
public static readonly string[] All = [Again, Hard, Good, Easy];
}
public static class FlashcardScheduleStates
{
public const string New = "new";
public const string Learning = "learning";
public const string Review = "review";
public const string Relearning = "relearning";
}
public sealed class FlashcardDeck
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string UserId { get; set; } = "";
public string StableId { get; set; } = Guid.NewGuid().ToString();
public string Name { get; set; } = "";
public string NormalizedName { get; set; } = "";
public string Description { get; set; } = "";
public bool IsDefault { get; set; }
public int? NewCardsPerDay { get; set; }
public int? ReviewsPerDay { get; set; }
public double? DesiredRetention { get; set; }
public long Revision { get; set; } = 1;
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
public DateTime? ArchivedUtc { get; set; }
[BsonIgnore] public bool IsSystem => UserId == FlashcardOwners.System;
}
public sealed class FlashcardImageReference
{
public string Kind { get; set; } = "owned";
public string AssetId { get; set; } = "";
}
public sealed class FlashcardNote
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string UserId { get; set; } = "";
public string StableId { get; set; } = Guid.NewGuid().ToString();
public string DeckId { get; set; } = "";
public string Type { get; set; } = FlashcardTypes.Basic;
public string Prompt { get; set; } = "";
public string Answer { get; set; } = "";
public string Extra { get; set; } = "";
public List<string> Tags { get; set; } = [];
public List<FlashcardImageReference> Images { get; set; } = [];
public string SourceQuestionExternalId { get; set; } = "";
public string ContentFingerprint { get; set; } = "";
public long Revision { get; set; } = 1;
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
public DateTime? ArchivedUtc { get; set; }
[BsonIgnore] public bool IsSystem => UserId == FlashcardOwners.System;
}
public sealed class FlashcardSchedule
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string UserId { get; set; } = "";
public string NoteId { get; set; } = "";
public int Variant { get; set; }
public string State { get; set; } = FlashcardScheduleStates.New;
public int? Step { get; set; }
public double? Stability { get; set; }
public double? Difficulty { get; set; }
public DateTime DueUtc { get; set; } = DateTime.UtcNow;
public DateTime? LastReviewUtc { get; set; }
public DateTime? BuriedUntilUtc { get; set; }
public bool IsSuspended { get; set; }
public long Revision { get; set; } = 1;
public string AlgorithmVersion { get; set; } = "fsrs-core-1.0.7";
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class FlashcardReviewLog
{
[BsonId] public string Id { get; set; } = "";
public string UserId { get; set; } = "";
public string CardId { get; set; } = "";
public string Rating { get; set; } = "";
public DateTime ReviewedUtc { get; set; }
public int DurationSeconds { get; set; }
public FlashcardScheduleSnapshot Before { get; set; } = new();
public FlashcardScheduleSnapshot After { get; set; } = new();
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime? UndoneUtc { get; set; }
}
public sealed class FlashcardScheduleSnapshot
{
public string State { get; set; } = FlashcardScheduleStates.New;
public int? Step { get; set; }
public double? Stability { get; set; }
public double? Difficulty { get; set; }
public DateTime DueUtc { get; set; }
public DateTime? LastReviewUtc { get; set; }
public long Revision { get; set; }
}
public sealed class FlashcardMediaAsset
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string UserId { get; set; } = "";
public string FileName { get; set; } = "";
public string ContentType { get; set; } = "";
public byte[] Bytes { get; set; } = [];
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class FlashcardSettings
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string UserId { get; set; } = "";
public int NewCardsPerDay { get; set; } = 20;
public int ReviewsPerDay { get; set; } = 200;
public double DesiredRetention { get; set; } = 0.90;
public long Revision { get; set; } = 1;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
public sealed class UpsertFlashcardDeckRequest
{
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public int? NewCardsPerDay { get; set; }
public int? ReviewsPerDay { get; set; }
public double? DesiredRetention { get; set; }
public long? ExpectedRevision { get; set; }
}
public sealed class UpsertFlashcardNoteRequest
{
public string DeckId { get; set; } = "";
public string StableId { get; set; } = "";
public string Type { get; set; } = FlashcardTypes.Basic;
public string Prompt { get; set; } = "";
public string Answer { get; set; } = "";
public string Extra { get; set; } = "";
public List<string> Tags { get; set; } = [];
public List<FlashcardImageReference> Images { get; set; } = [];
public string SourceQuestionExternalId { get; set; } = "";
public long? ExpectedRevision { get; set; }
}
public sealed class UpdateFlashcardSettingsRequest
{
public int NewCardsPerDay { get; set; } = 20;
public int ReviewsPerDay { get; set; } = 200;
public double DesiredRetention { get; set; } = 0.90;
public long? ExpectedRevision { get; set; }
}
public sealed class FlashcardDeckSummary
{
public FlashcardDeck Deck { get; set; } = new();
public int Total { get; set; }
public int Due { get; set; }
public int New { get; set; }
public int Suspended { get; set; }
public int Learning { get; set; }
public int Review { get; set; }
public int Mastered { get; set; }
}
public sealed class FlashcardLibraryResponse
{
public List<FlashcardNote> Items { get; set; } = [];
public int Total { get; set; }
}
public sealed class FlashcardLibraryQuery
{
public string? DeckId { get; set; }
public string? Tag { get; set; }
public string? Type { get; set; }
public string? State { get; set; }
public string? Search { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 50;
}
public sealed class FlashcardStudyRequest
{
public string DeckId { get; set; } = "";
public List<string> ExcludeCardIds { get; set; } = [];
public DateTime StudyDayStartUtc { get; set; } = DateTime.UtcNow.Date;
public bool Shuffle { get; set; }
}
public sealed class FlashcardBulkNoteIdsRequest
{
public List<string> NoteIds { get; set; } = [];
}
public sealed class FlashcardMoveRequest
{
public List<string> NoteIds { get; set; } = [];
public string TargetDeckId { get; set; } = "";
}
public sealed class FlashcardBatchReviewRequest
{
public List<FlashcardReviewRequest> Reviews { get; set; } = [];
}
public sealed class FlashcardBatchReviewResponse
{
public List<FlashcardReviewResponse> Results { get; set; } = [];
}
public sealed class FlashcardUndoRequest
{
public string ReviewLogId { get; set; } = "";
}
public sealed class FlashcardRatingOption
{
public string Rating { get; set; } = "";
public string IntervalLabel { get; set; } = "";
public DateTime DueUtc { get; set; }
}
public sealed class FlashcardStudyCardResponse
{
public FlashcardNote? Note { get; set; }
public FlashcardSchedule? Card { get; set; }
public List<FlashcardRatingOption> RatingOptions { get; set; } = [];
public int DueRemaining { get; set; }
public int NewRemaining { get; set; }
}
public sealed class FlashcardReviewRequest
{
public string ClientReviewId { get; set; } = "";
public string CardId { get; set; } = "";
public string Rating { get; set; } = "";
public long ExpectedRevision { get; set; }
public DateTime ReviewedUtc { get; set; }
public int DurationSeconds { get; set; }
public DateTime StudyDayStartUtc { get; set; } = DateTime.UtcNow.Date;
}
public sealed class FlashcardReviewResponse
{
public FlashcardSchedule Card { get; set; } = new();
public List<FlashcardRatingOption> RatingOptions { get; set; } = [];
public bool Duplicate { get; set; }
}
public sealed class FlashcardDashboardCounts
{
public int Due { get; set; }
public int NewAvailable { get; set; }
public int ReviewedThisWeek { get; set; }
public int Learning { get; set; }
public int Review { get; set; }
public int CurrentStreakDays { get; set; }
public int ReviewsToday { get; set; }
public int DailyGoal { get; set; }
}
public static class FlashcardImportFormats
{
public const string Csv = "csv";
public const string Json = "json";
}
public static class FlashcardImportRowActions
{
public const string Create = "create";
public const string Update = "update";
public const string Skip = "skip";
public const string Error = "error";
}
public sealed class FlashcardImportPendingRecord
{
public int RowNumber { get; set; }
public string Action { get; set; } = FlashcardImportRowActions.Create;
public string? Error { get; set; }
public string StableId { get; set; } = "";
public string DeckName { get; set; } = "";
public string Type { get; set; } = FlashcardTypes.Basic;
public string Prompt { get; set; } = "";
public string Answer { get; set; } = "";
public string Extra { get; set; } = "";
public List<string> Tags { get; set; } = [];
public string SourceQuestionExternalId { get; set; } = "";
public List<FlashcardSchedule> Cards { get; set; } = [];
public List<FlashcardReviewLog> ReviewLogs { get; set; } = [];
}
public sealed class FlashcardImportPreview
{
[BsonId] public string Id { get; set; } = "";
public string UserId { get; set; } = "";
public string Format { get; set; } = FlashcardImportFormats.Csv;
public List<FlashcardImportPendingRecord> Records { get; set; } = [];
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime ExpiresUtc { get; set; } = DateTime.UtcNow.AddHours(1);
}
public sealed class FlashcardImportPreviewRow
{
public int RowNumber { get; set; }
public string Action { get; set; } = FlashcardImportRowActions.Create;
public string? Error { get; set; }
public string StableId { get; set; } = "";
public string Deck { get; set; } = "";
public string Type { get; set; } = FlashcardTypes.Basic;
public string Prompt { get; set; } = "";
public string Answer { get; set; } = "";
}
public sealed class FlashcardImportPreviewResponse
{
public string ImportToken { get; set; } = "";
public int CreateCount { get; set; }
public int UpdateCount { get; set; }
public int SkipCount { get; set; }
public int ErrorCount { get; set; }
public List<FlashcardImportPreviewRow> Rows { get; set; } = [];
}
public sealed class FlashcardImportCommitRequest
{
public string ImportToken { get; set; } = "";
}
public sealed class FlashcardImportCommitResponse
{
public int Created { get; set; }
public int Updated { get; set; }
public int Skipped { get; set; }
public int Errored { get; set; }
public List<FlashcardImportPreviewRow> Errors { get; set; } = [];
}
public sealed class FlashcardExportOmission
{
public string Kind { get; set; } = "";
public string Reason { get; set; } = "";
}
public sealed class FlashcardJsonExport
{
public int SchemaVersion { get; set; } = 1;
public DateTime ExportedUtc { get; set; } = DateTime.UtcNow;
public FlashcardSettings Settings { get; set; } = new();
public List<FlashcardDeck> Decks { get; set; } = [];
public List<FlashcardJsonExportNote> Notes { get; set; } = [];
}
public sealed class FlashcardJsonExportNote
{
public FlashcardNote Note { get; set; } = new();
public List<FlashcardSchedule> Cards { get; set; } = [];
public List<FlashcardReviewLog> ReviewLogs { get; set; } = [];
public List<FlashcardJsonExportImage> Images { get; set; } = [];
}
public sealed class FlashcardJsonExportImage
{
public string AssetId { get; set; } = "";
public string FileName { get; set; } = "";
public string ContentType { get; set; } = "";
public string Base64Bytes { get; set; } = "";
}
+3
View File
@@ -30,6 +30,9 @@ public class Question
public List<string> Tags { get; set; } = [];
public List<QuestionReference> References { get; set; } = [];
public bool IsPublished { get; set; } = true;
public DateTime? FlaggedForReviewUtc { get; set; }
public string FlagReason { get; set; } = "";
public string FlaggedByUserId { get; set; } = "";
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
@@ -19,6 +19,8 @@ public class QuestionAttempt
public int DurationSeconds { get; set; }
public int CounterBucket { get; set; }
public bool? RatingApplied { get; set; }
public int? UserOverallEloAfter { get; set; }
public DateTime? RatingAppliedUtc { get; set; }
public string RatingLeaseOwner { get; set; } = "";
public DateTime RatingLeaseUntilUtc { get; set; }
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
@@ -45,6 +45,8 @@ public class QuestionReport
public string Details { get; set; } = "";
public string Status { get; set; } = QuestionReportStatus.Open;
public QuestionReportSnapshot QuestionSnapshot { get; set; } = new();
public string SourceType { get; set; } = "authored";
public DynamicGenerationDescriptor? DynamicGeneration { get; set; }
public string ReviewedByUserId { get; set; } = "";
public string ReviewerNote { get; set; } = "";
public DateTime? ReviewedUtc { get; set; }
@@ -38,10 +38,16 @@ public class UpsertQuestionRequest
public bool IsPublished { get; set; } = true;
}
public class FlagQuestionRequest
{
public string Reason { get; set; } = "";
}
public class FirstQuestionAttemptRequest
{
public string QuestionExternalId { get; set; } = "";
public string ChoiceId { get; set; } = "";
public List<string> PresentedChoiceIds { get; set; } = [];
public int DurationSeconds { get; set; }
public bool MetTimeLimit { get; set; } = true;
}
@@ -111,12 +111,37 @@ public class QuestionResponse
}
}
// Admin-only shape for the flag/unflag/list-flagged endpoints - deliberately separate from
// QuestionResponse (which is student-facing and never exposes IsPublished or the flag fields).
public class QuestionFlagResponse
{
public string Id { get; set; } = "";
public string ExternalId { get; set; } = "";
public string Stem { get; set; } = "";
public bool IsPublished { get; set; }
public DateTime? FlaggedForReviewUtc { get; set; }
public string FlagReason { get; set; } = "";
public string FlaggedByUserId { get; set; } = "";
public static QuestionFlagResponse FromModel(Question question) => new()
{
Id = question.Id ?? "",
ExternalId = question.ExternalId,
Stem = question.Stem,
IsPublished = question.IsPublished,
FlaggedForReviewUtc = question.FlaggedForReviewUtc,
FlagReason = question.FlagReason,
FlaggedByUserId = question.FlaggedByUserId
};
}
public class FirstQuestionAttemptResponse
{
public string QuestionExternalId { get; set; } = "";
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public int QuestionElo { get; set; } = 1000;
public RatingChangeResponse? Rating { get; set; }
public CreditStatusResponse? Credits { get; set; }
}
public class SkipQuestionResponse
+4
View File
@@ -13,6 +13,8 @@ public class UserRating
public int OverallElo { get; set; } = 1000;
public Dictionary<string, int> CategoryElos { get; set; } = [];
public int RatedQuestionCount { get; set; }
public int? HistoryBaselineElo { get; set; }
public DateTime? HistoryStartedUtc { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
@@ -45,4 +47,6 @@ public class RatingProfileResponse
public int OverallElo { get; set; }
public int RatedQuestionCount { get; set; }
public Dictionary<string, int> CategoryElos { get; set; } = [];
public string Tier { get; set; } = "";
public int TierDivision { get; set; }
}
@@ -13,6 +13,8 @@ public sealed class UserStudyProfile
public string PreparationStage { get; set; } = "pre_dedicated";
public int WeeklyGoalHours { get; set; } = 10;
public string PreferredStudyMode { get; set; } = "mixed";
public double QuestionTextScale { get; set; } = 1.0;
public double CcsTextScale { get; set; } = 1.0;
public int Revision { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
@@ -25,6 +27,12 @@ public sealed class UpdateUserStudyProfileRequest
public string PreferredStudyMode { get; set; } = "mixed";
}
public sealed class UpdateTextScaleRequest
{
public double? QuestionTextScale { get; set; }
public double? CcsTextScale { get; set; }
}
public sealed class DeletedUserTombstone
{
[BsonId]
@@ -48,3 +56,18 @@ public static class UserStudyProfileValidator
return null;
}
}
public static class UpdateTextScaleRequestValidator
{
private const double Min = 0.85;
private const double Max = 1.35;
public static string? Validate(UpdateTextScaleRequest request)
{
if (request.QuestionTextScale is { } questionScale && (questionScale < Min || questionScale > Max))
return "Question text scale must be between 0.85 and 1.35.";
if (request.CcsTextScale is { } ccsScale && (ccsScale < Min || ccsScale > Max))
return "CCS text scale must be between 0.85 and 1.35.";
return null;
}
}
+53
View File
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using QuestionsApi.Services;
using QuestionsApi.Services.Flashcards;
using MongoDB.Bson;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
@@ -16,6 +17,44 @@ if (args.FirstOrDefault() == "promote-content")
return;
}
if (args.FirstOrDefault() == "promote-questions")
{
Environment.ExitCode = await QuestionPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
return;
}
if (args.FirstOrDefault() == "promote-dynamic-questions")
{
Environment.ExitCode = await DynamicBlueprintCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
return;
}
if (args.FirstOrDefault() == "export-dynamic-review")
{
Environment.ExitCode = await DynamicReviewExportCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
return;
}
if (args.FirstOrDefault() == "seed-flashcards")
{
var seedConfiguration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
Environment.ExitCode = await SeedFlashcardsCommand.RunAsync(seedConfiguration, Console.Out);
return;
}
if (args.FirstOrDefault() == "flag-media-questions")
{
var flagConfiguration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
Environment.ExitCode = await FlagMediaQuestionsCommand.RunAsync(flagConfiguration, Console.Out);
return;
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
@@ -27,12 +66,18 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
});
builder.Services.AddSingleton<MongoDatabaseProvider>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<CreditStore>();
builder.Services.AddSingleton<QuestionStore>();
builder.Services.AddSingleton<QuestionAttemptStore>();
builder.Services.AddSingleton<AnswerStatisticsStore>();
builder.Services.AddSingleton<RatingStore>();
builder.Services.AddSingleton<QuestionMediaStore>();
builder.Services.AddSingleton<QuestionReportStore>();
builder.Services.AddSingleton<DynamicQuestionStore>();
builder.Services.AddSingleton<DynamicQuestionGenerator>();
builder.Services.AddSingleton<DynamicQuestionTokenService>();
builder.Services.AddSingleton<DynamicQuestionService>();
builder.Services.AddSingleton<ExamSimulationStore>();
builder.Services.AddSingleton<CcsStore>();
builder.Services.AddSingleton<CcsRatingStore>();
@@ -43,6 +88,14 @@ builder.Services.AddSingleton<QuestionBookmarkStore>();
builder.Services.AddSingleton<ReviewQueueStore>();
builder.Services.AddSingleton<DashboardStore>();
builder.Services.AddSingleton<UserProfileStore>();
builder.Services.AddSingleton<IFlashcardScheduler, FsrsFlashcardScheduler>();
builder.Services.AddSingleton<FlashcardDeckStore>();
builder.Services.AddSingleton<FlashcardScheduleStore>();
builder.Services.AddSingleton<FlashcardNoteStore>();
builder.Services.AddSingleton<FlashcardSettingsStore>();
builder.Services.AddSingleton<FlashcardMediaStore>();
builder.Services.AddSingleton<FlashcardStudyService>();
builder.Services.AddSingleton<FlashcardImportExportService>();
builder.Services.AddSingleton<AuthUserStore>();
builder.Services.AddSingleton<TokenBlacklistStore>();
builder.Services.AddSingleton<JwtTokenService>();
+1
View File
@@ -8,6 +8,7 @@
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FSRS.Core" Version="1.0.7" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
+34 -3
View File
@@ -4,8 +4,7 @@ namespace QuestionsApi.Services;
public static class CcsCaseValidator
{
private static readonly HashSet<string> Modes = [CcsAttemptModes.Practice, CcsAttemptModes.Timed];
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "end_case", "exit_case"];
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "end_case", "exit_case"];
public static List<string> ValidateImport(CcsImportRequest request, IReadOnlySet<string> existingOrderCodes)
{
@@ -18,6 +17,8 @@ public static class CcsCaseValidator
if (string.IsNullOrWhiteSpace(order.CanonicalName)) errors.Add(prefix + "canonicalName required");
if (string.IsNullOrWhiteSpace(order.Category)) errors.Add(prefix + "category required");
if (order.DefaultTurnaroundMinutes < 0) errors.Add(prefix + "defaultTurnaroundMinutes cannot be negative");
if (!string.IsNullOrWhiteSpace(order.ResultSection) && !CcsChartCategories.ResultSections.Contains(order.ResultSection))
errors.Add(prefix + "resultSection is invalid");
}
if (request.Orders.Select(order => order.Code).Distinct(StringComparer.OrdinalIgnoreCase).Count() != request.Orders.Count)
errors.Add("order codes must be unique within an import");
@@ -50,7 +51,11 @@ public static class CcsCaseValidator
if (eventIds.Any(string.IsNullOrWhiteSpace) || eventIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != eventIds.Count)
errors.Add(prefix + "event ids must be present and unique");
foreach (var response in item.OrderResponses)
{
if (!orderCodes.Contains(response.OrderCode)) errors.Add(prefix + $"unknown order code {response.OrderCode}");
if (!string.IsNullOrWhiteSpace(response.ResultSection) && !CcsChartCategories.ResultSections.Contains(response.ResultSection))
errors.Add(prefix + $"order response {response.OrderCode} resultSection is invalid");
}
foreach (var code in item.InitialChart.InitialOrderCodes.Concat(item.Events.SelectMany(value => value.RequiresOrderCodes.Concat(value.RequiresAnyOrderCodes))))
if (!orderCodes.Contains(code)) errors.Add(prefix + $"unknown order code {code}");
var criteriaIds = item.ScoringCriteria.Select(value => value.Id).ToList();
@@ -75,7 +80,8 @@ public static class CcsCaseValidator
public static string? ValidateCreate(CreateCcsAttemptRequest request)
{
if (!Modes.Contains(request.Mode)) return "mode must be practice or timed";
if (!request.Mode.Equals(CcsAttemptModes.Timed, StringComparison.OrdinalIgnoreCase))
return "new CCS attempts must be timed";
return null;
}
@@ -88,4 +94,29 @@ public static class CcsCaseValidator
if (request.Type == "advance" && request.Minutes is < 1 or > 525600) return "advance minutes must be between 1 and 525600";
return null;
}
public static string? ValidateBatch(CcsBatchCommandRequest request)
{
if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required";
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
if (request.Commands.Count is < 1 or > 50) return "commands must contain between 1 and 50 items";
foreach (var item in request.Commands)
{
var error = ValidateCommand(new CcsCommandRequest
{
CommandId = request.CommandId,
ExpectedRevision = request.ExpectedRevision,
Type = item.Type,
Code = item.Code,
Minutes = item.Minutes
});
if (error is not null) return error;
if (item.Type is not ("exam" or "interval_history" or "order" or "cancel_order"))
return "batch commands support history/exams, orders, and order cancellations only";
}
var hasClinicalAssessment = request.Commands.Any(value => value.Type is "exam" or "interval_history");
if (hasClinicalAssessment && request.Commands.Any(value => value.Type is not ("exam" or "interval_history")))
return "history and examinations cannot be mixed with order changes in one batch";
return null;
}
}
@@ -31,6 +31,12 @@ public sealed class CcsRatingStore
return new CcsRatingProfileResponse { Elo = rating.Elo, RatedCaseCount = rating.RatedCaseCount };
}
public async Task<CcsUserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
{
var rating = await GetOrCreateUserAsync(userId);
return await EnsureHistoryBaselineAsync(rating, nowUtc);
}
public async Task<CcsRatingChange> ApplyAsync(CcsAttempt attempt)
{
var actual = Math.Clamp((attempt.Score?.Percent ?? 0) / 100.0, 0, 1);
@@ -52,6 +58,7 @@ public sealed class CcsRatingStore
}
var user = await GetOrCreateUserAsync(attempt.UserId);
user = await EnsureHistoryBaselineAsync(user, DateTime.UtcNow);
var caseRating = await GetOrCreateCaseAsync(attempt.CaseExternalId);
var expected = ExpectedScore(user.Elo, caseRating.Elo);
var userDelta = (int)Math.Round(UserK(user.RatedCaseCount) * (actual - expected), MidpointRounding.AwayFromZero);
@@ -137,6 +144,26 @@ public sealed class CcsRatingStore
}
}
private async Task<CcsUserRating> EnsureHistoryBaselineAsync(CcsUserRating rating, DateTime nowUtc)
{
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
return rating;
var result = await _users.UpdateOneAsync(
value => value.UserId == rating.UserId && value.HistoryStartedUtc == null,
Builders<CcsUserRating>.Update
.Set(value => value.HistoryBaselineElo, rating.Elo)
.Set(value => value.HistoryStartedUtc, nowUtc));
if (result.ModifiedCount > 0)
{
rating.HistoryBaselineElo = rating.Elo;
rating.HistoryStartedUtc = nowUtc;
return rating;
}
return await _users.Find(value => value.UserId == rating.UserId).FirstAsync();
}
private async Task<CcsCaseRating> UpdateCaseAsync(CcsCaseRating current, int delta)
{
while (true)
+220 -23
View File
@@ -115,16 +115,22 @@ public sealed class CcsStore
CaseExternalId = definition.ExternalId,
CaseRevisionId = definition.Id ?? "",
CaseVersion = definition.Version,
Mode = request.Mode,
Mode = CcsAttemptModes.Timed,
OpeningCompleted = false,
Location = definition.PresentingLocation,
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
VitalHistory =
[
new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary<string, string>(definition.InitialChart.Vitals) }
],
ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
DeadlineUtc = request.Mode == CcsAttemptModes.Timed ? now.AddMinutes(definition.RealTimeMinutes) : null,
RatingEligible = request.Mode == CcsAttemptModes.Timed,
DeadlineUtc = now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
HardDeadlineUtc = now.AddMinutes(definition.RealTimeMinutes),
RatingEligible = true,
Chart =
[
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.ReasonForEncounter },
new CcsChartEntry { Category = "progress", Text = definition.InitialChart.Appearance }
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter },
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "initial_appearance", Title = "Initial appearance", Text = definition.InitialChart.Appearance }
]
};
try { await _attempts.InsertOneAsync(attempt); }
@@ -173,7 +179,12 @@ public sealed class CcsStore
if (attempt.State == CcsAttemptStates.Active && attempt.DeadlineUtc <= DateTime.UtcNow)
{
attempt.State = CcsAttemptStates.FinalOrders;
attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.AddMinutes(2);
attempt.FinalOrdersDeadlineUtc = FinalOrdersDeadline(DateTime.UtcNow, attempt.HardDeadlineUtc);
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "case_end", Title = "Case-end instructions",
SimulatedMinute = attempt.SimulatedMinute, Text = "Finalize patient care orders."
});
attempt.Revision++;
await SaveAsync(attempt);
}
@@ -206,9 +217,16 @@ public sealed class CcsStore
case "advance": Advance(attempt, definition, command.Minutes); break;
case "interval_history":
attempt.Actions.Add(Action("interval_history", "interval_history", attempt.SimulatedMinute, now));
attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change." });
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "interval_history", Title = "Interval history",
SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change."
});
Advance(attempt, definition, 1);
break;
case "acknowledge_opening":
attempt.OpeningCompleted = true;
break;
case "end_case": EnterFinalOrders(attempt); break;
case "exit_case": await CompleteAsync(attempt); break;
}
@@ -220,6 +238,63 @@ public sealed class CcsStore
return attempt;
}
public async Task<CcsAttempt> ApplyBatchCommandAsync(CcsAttempt attempt, CcsBatchCommandRequest request)
{
var validation = CcsCaseValidator.ValidateBatch(request);
if (validation is not null) throw new ArgumentException(validation);
if (attempt.ProcessedCommandIds.Contains(request.CommandId, StringComparer.Ordinal)) return attempt;
if (attempt.Revision != request.ExpectedRevision) throw new InvalidOperationException("Attempt revision is stale.");
if (attempt.State is CcsAttemptStates.Completed or CcsAttemptStates.Abandoned)
throw new InvalidOperationException("The CCS attempt is closed.");
if (attempt.State == CcsAttemptStates.FinalOrders && request.Commands.Any(value => value.Type is not ("order" or "cancel_order")))
throw new InvalidOperationException("Only final orders may be changed now.");
var definition = await DefinitionAsync(attempt);
var now = DateTime.UtcNow;
if (request.Commands.Any(value => value.Type is "exam" or "interval_history"))
{
var selected = request.Commands.Where(item => item.Type == "exam").Select(item =>
definition.Exams.FirstOrDefault(value => value.Code.Equals(item.Code, StringComparison.OrdinalIgnoreCase))
?? throw new ArgumentException($"Unknown examination component: {item.Code}.")).ToList();
var includeHistory = request.Commands.Any(item => item.Type == "interval_history");
var startingMinute = attempt.SimulatedMinute;
foreach (var exam in selected)
attempt.Actions.Add(Action("exam", exam.Code, startingMinute, now));
if (includeHistory)
attempt.Actions.Add(Action("interval_history", "interval_history", startingMinute, now));
Advance(attempt, definition, selected.Sum(value => Math.Max(1, value.DurationMinutes)) + (includeHistory ? 1 : 0));
if (includeHistory)
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "interval_history", Title = "Interval history",
SimulatedMinute = attempt.SimulatedMinute, Text = "The patient was reassessed for interval change."
});
foreach (var exam in selected)
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = exam.Code, Title = exam.Name,
SimulatedMinute = attempt.SimulatedMinute, Text = exam.Finding
});
}
else
{
foreach (var item in request.Commands)
{
if (item.Type == "order") await ApplyOrderAsync(attempt, definition, item.Code, now, false);
else CancelOrder(attempt, item.Code, now);
}
ProcessDue(attempt);
}
attempt.ProcessedCommandIds.Add(request.CommandId);
if (attempt.ProcessedCommandIds.Count > 100)
attempt.ProcessedCommandIds.RemoveRange(0, attempt.ProcessedCommandIds.Count - 100);
attempt.Revision++;
attempt.UpdatedUtc = now;
await SaveAsync(attempt);
return attempt;
}
public async Task<CcsHistoryPage> HistoryAsync(string userId, int limit, string status = "all", string sort = "newest", string? cursor = null)
{
limit = Math.Clamp(limit, 1, 50);
@@ -314,19 +389,49 @@ public sealed class CcsStore
{
var definition = await DefinitionAsync(attempt);
var completed = attempt.State == CcsAttemptStates.Completed;
List<CcsOrderDefinition> activeCatalog = attempt.ActiveOrderCodes.Count == 0
? []
: await _orders.Find(value => attempt.ActiveOrderCodes.Contains(value.Code)).ToListAsync();
var activeOrders = activeCatalog.Select(order =>
{
var orderedMinute = attempt.Actions.LastOrDefault(value =>
value.Type == "order" && value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase))?.SimulatedMinute ?? 0;
var pending = attempt.PendingResults.FirstOrDefault(value =>
value.OrderCode.Equals(order.Code, StringComparison.OrdinalIgnoreCase));
var reported = attempt.Chart.LastOrDefault(value =>
value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase)
&& value.Category is not CcsChartCategories.Orders);
return new CcsActiveOrder
{
Code = order.Code, Name = order.CanonicalName, Category = order.Category,
Route = order.DisplayRoute, Frequency = order.DisplayFrequency, OrderedMinute = orderedMinute,
ReportMinute = pending is not null ? null : reported?.SimulatedMinute,
IsPending = pending is not null, HasResult = reported is not null
};
}).OrderBy(value => value.OrderedMinute).ThenBy(value => value.Name).ToList();
List<CcsVitalSnapshot> vitalHistory = attempt.VitalHistory.Count > 0
? attempt.VitalHistory
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
return new CcsAttemptResponse
{
Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId,
CaseTitle = completed ? definition.Title : $"CCS Case {attempt.CaseExternalId}",
Mode = attempt.Mode, State = attempt.State, Revision = attempt.Revision,
SimulatedMinute = attempt.SimulatedMinute, Location = attempt.Location,
DeadlineUtc = attempt.DeadlineUtc, FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
SimulatedMinute = attempt.SimulatedMinute, SimulatedStartUtc = definition.InitialChart.SimulatedStartUtc,
OpeningCompleted = attempt.OpeningCompleted ?? true, Location = attempt.Location,
DeadlineUtc = attempt.DeadlineUtc, HardDeadlineUtc = attempt.HardDeadlineUtc,
FinalOrdersDeadlineUtc = attempt.FinalOrdersDeadlineUtc,
CreatedUtc = attempt.CreatedUtc, RealTimeMinutes = definition.RealTimeMinutes,
ElapsedSeconds = Math.Max(0, (int)Math.Round(((attempt.CompletedUtc ?? DateTime.UtcNow) - attempt.CreatedUtc).TotalSeconds)),
PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History,
HistorySections = definition.InitialChart.HistorySections,
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult { OrderCode = value.OrderCode, DueMinute = value.DueMinute }).ToList(),
ActiveOrders = activeOrders, VitalHistory = vitalHistory,
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
{
OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title
}).ToList(),
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
@@ -342,7 +447,7 @@ public sealed class CcsStore
await SaveAsync(attempt);
}
private async Task ApplyOrderAsync(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now)
private async Task ApplyOrderAsync(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now, bool processImmediately = true)
{
code = code.Trim().ToLowerInvariant();
var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code.");
@@ -352,17 +457,30 @@ public sealed class CcsStore
if (!attempt.ActiveOrderCodes.Contains(code, StringComparer.OrdinalIgnoreCase)) attempt.ActiveOrderCodes.Add(code);
var response = definition.OrderResponses.FirstOrDefault(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase));
var delay = response?.TurnaroundMinutes ?? catalog.DefaultTurnaroundMinutes;
var resultCategory = ResultCategory(response?.ResultSection, catalog.ResultSection, catalog.Category);
if (response is not null && (!string.IsNullOrWhiteSpace(response.Result) || response.VitalPatches.Count > 0))
attempt.PendingResults.Add(new CcsPendingResult { OrderCode = code, DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches });
attempt.Chart.Add(new CcsChartEntry { Category = "orders", SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}" });
if (delay == 0) ProcessDue(attempt);
attempt.PendingResults.Add(new CcsPendingResult
{
OrderCode = code, Category = resultCategory, Title = catalog.CanonicalName,
DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches
});
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Orders, Code = code, Title = catalog.CanonicalName,
SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}"
});
if (delay == 0 && processImmediately) ProcessDue(attempt);
}
private static void ApplyExam(CcsAttempt attempt, CcsCaseRevision definition, string code, DateTime now)
{
var exam = definition.Exams.FirstOrDefault(value => value.Code.Equals(code, StringComparison.OrdinalIgnoreCase)) ?? throw new ArgumentException("Unknown examination component.");
attempt.Actions.Add(Action("exam", exam.Code, attempt.SimulatedMinute, now));
attempt.Chart.Add(new CcsChartEntry { Category = "exam", SimulatedMinute = attempt.SimulatedMinute, Text = $"{exam.Name}: {exam.Finding}" });
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = exam.Code, Title = exam.Name,
SimulatedMinute = attempt.SimulatedMinute, Text = exam.Finding
});
Advance(attempt, definition, Math.Max(1, exam.DurationMinutes));
}
@@ -371,7 +489,11 @@ public sealed class CcsStore
attempt.Actions.Add(Action("cancel_order", code, attempt.SimulatedMinute, now));
attempt.ActiveOrderCodes.RemoveAll(value => value.Equals(code, StringComparison.OrdinalIgnoreCase));
attempt.PendingResults.RemoveAll(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase));
attempt.Chart.Add(new CcsChartEntry { Category = "orders", SimulatedMinute = attempt.SimulatedMinute, Text = $"Canceled: {code}" });
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Orders, Code = code, Title = Humanize(code),
SimulatedMinute = attempt.SimulatedMinute, Text = $"Canceled: {Humanize(code)}"
});
}
private static void ChangeLocation(CcsAttempt attempt, CcsCaseRevision definition, string location, DateTime now)
@@ -379,7 +501,11 @@ public sealed class CcsStore
if (!definition.AllowedLocations.Contains(location, StringComparer.OrdinalIgnoreCase)) throw new ArgumentException("Unsupported location.");
attempt.Location = location;
attempt.Actions.Add(Action("location", location, attempt.SimulatedMinute, now));
attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = $"Patient moved to {location}." });
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "location", Title = "Location change",
SimulatedMinute = attempt.SimulatedMinute, Text = $"Patient moved to {Humanize(location)}."
});
}
private static void Advance(CcsAttempt attempt, CcsCaseRevision definition, int minutes)
@@ -403,13 +529,20 @@ public sealed class CcsStore
private static void ProcessDue(CcsAttempt attempt)
{
var due = attempt.PendingResults.Where(value => value.DueMinute <= attempt.SimulatedMinute).ToList();
var vitalsChanged = false;
foreach (var result in due)
{
if (!string.IsNullOrWhiteSpace(result.Result))
attempt.Chart.Add(new CcsChartEntry { Category = "results", SimulatedMinute = attempt.SimulatedMinute, Text = result.Result });
attempt.Chart.Add(new CcsChartEntry
{
Category = string.IsNullOrWhiteSpace(result.Category) ? CcsChartCategories.OtherTest : result.Category,
Code = result.OrderCode, Title = result.Title, SimulatedMinute = attempt.SimulatedMinute, Text = result.Result
});
foreach (var patch in result.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value;
vitalsChanged |= result.VitalPatches.Count > 0;
attempt.PendingResults.Remove(result);
}
if (vitalsChanged) SnapshotVitals(attempt);
}
private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition)
@@ -419,13 +552,20 @@ public sealed class CcsStore
&& value.TriggerMinute <= attempt.SimulatedMinute
&& value.RequiresOrderCodes.All(activeOrders.Contains)
&& (value.RequiresAnyOrderCodes.Count == 0 || value.RequiresAnyOrderCodes.Any(activeOrders.Contains))).ToList();
var vitalsChanged = false;
foreach (var item in events)
{
attempt.ProcessedEventIds.Add(item.Id);
foreach (var patch in item.VitalPatches) attempt.CurrentVitals[patch.Key] = patch.Value;
if (!string.IsNullOrWhiteSpace(item.Message)) attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = item.Message });
vitalsChanged |= item.VitalPatches.Count > 0;
if (!string.IsNullOrWhiteSpace(item.Message)) attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = item.Id, Title = "Clinical update",
SimulatedMinute = attempt.SimulatedMinute, Text = item.Message
});
if (item.EndCase) EnterFinalOrders(attempt);
}
if (vitalsChanged) SnapshotVitals(attempt);
return events.Any(value => !string.IsNullOrWhiteSpace(value.Message) || value.EndCase);
}
@@ -433,9 +573,21 @@ public sealed class CcsStore
{
if (attempt.State != CcsAttemptStates.Active) return;
attempt.State = CcsAttemptStates.FinalOrders;
var remaining = attempt.DeadlineUtc is null ? TimeSpan.FromMinutes(2) : attempt.DeadlineUtc.Value - DateTime.UtcNow;
attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.Add(remaining < TimeSpan.FromMinutes(2) ? TimeSpan.FromSeconds(Math.Max(0, remaining.TotalSeconds)) : TimeSpan.FromMinutes(2));
attempt.Chart.Add(new CcsChartEntry { Category = "progress", SimulatedMinute = attempt.SimulatedMinute, Text = "Case-end instructions: finalize patient care orders." });
if (attempt.HardDeadlineUtc is not null)
{
attempt.FinalOrdersDeadlineUtc = FinalOrdersDeadline(DateTime.UtcNow, attempt.HardDeadlineUtc);
}
else
{
var remaining = attempt.DeadlineUtc is null ? TimeSpan.FromMinutes(2) : attempt.DeadlineUtc.Value - DateTime.UtcNow;
attempt.FinalOrdersDeadlineUtc = DateTime.UtcNow.Add(
remaining < TimeSpan.FromMinutes(2) ? TimeSpan.FromSeconds(Math.Max(0, remaining.TotalSeconds)) : TimeSpan.FromMinutes(2));
}
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = "case_end", Title = "Case-end instructions",
SimulatedMinute = attempt.SimulatedMinute, Text = "Finalize patient care orders."
});
}
private async Task CompleteAsync(CcsAttempt attempt)
@@ -489,12 +641,44 @@ public sealed class CcsStore
};
}
public static int ActivePhaseMinutes(int totalMinutes) => Math.Max(1, totalMinutes - 2);
public static DateTime FinalOrdersDeadline(DateTime now, DateTime? hardDeadline)
{
var proposed = now.AddMinutes(2);
return hardDeadline is null || proposed <= hardDeadline ? proposed : hardDeadline.Value;
}
public async Task<CcsCaseRevision> DefinitionAsync(CcsAttempt attempt) =>
await _cases.Find(value => value.Id == attempt.CaseRevisionId).FirstOrDefaultAsync()
?? throw new InvalidOperationException("The case revision is unavailable.");
private Task SaveAsync(CcsAttempt attempt) => _attempts.ReplaceOneAsync(value => value.Id == attempt.Id, attempt);
private static CcsAttemptAction Action(string type, string code, int minute, DateTime now) => new() { Type = type, Code = code, SimulatedMinute = minute, RealUtc = now };
private static void SnapshotVitals(CcsAttempt attempt)
{
if (attempt.VitalHistory.Count > 0
&& attempt.VitalHistory[^1].SimulatedMinute == attempt.SimulatedMinute
&& attempt.VitalHistory[^1].Values.OrderBy(value => value.Key).SequenceEqual(attempt.CurrentVitals.OrderBy(value => value.Key)))
return;
attempt.VitalHistory.Add(new CcsVitalSnapshot
{
SimulatedMinute = attempt.SimulatedMinute,
Values = new Dictionary<string, string>(attempt.CurrentVitals)
});
}
private static string ResultCategory(string? responseSection, string catalogSection, string catalogCategory)
{
var explicitSection = string.IsNullOrWhiteSpace(responseSection) ? catalogSection : responseSection;
if (!string.IsNullOrWhiteSpace(explicitSection)) return explicitSection;
return catalogCategory.ToLowerInvariant() switch
{
"laboratory" => CcsChartCategories.Lab,
"imaging" => CcsChartCategories.Imaging,
"medication" or "procedure" or "monitoring" => CcsChartCategories.Treatment,
_ => CcsChartCategories.OtherTest
};
}
private static string Humanize(string value) => string.Join(' ', value.Split('_', StringSplitOptions.RemoveEmptyEntries).Select(word => char.ToUpperInvariant(word[0]) + word[1..]));
private static string DescribeExpected(CcsScoringCriterion criterion, List<string> targets)
{
@@ -511,12 +695,25 @@ public sealed class CcsStore
PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes,
Difficulty = value.Difficulty, Tags = value.Tags
};
private static int Rank(CcsOrderDefinition order, string query)
internal static int Rank(CcsOrderDefinition order, string query)
{
var values = new[] { order.CanonicalName }.Concat(order.Aliases).Select(value => value.ToLowerInvariant());
if (values.Any(value => value == query)) return 0;
if (values.Any(value => value.StartsWith(query))) return 1;
if (values.Any(value => value.Contains(query))) return 2;
if (CategorySearchTerms(order.Category).Contains(query, StringComparer.OrdinalIgnoreCase)) return 3;
return 1000;
}
private static IReadOnlyList<string> CategorySearchTerms(string category) =>
category.Trim().ToLowerInvariant() switch
{
"laboratory" => ["laboratory", "laboratories", "lab", "labs", "test", "tests", "diagnostic", "diagnostics", "study", "studies"],
"diagnostic" => ["diagnostic", "diagnostics", "test", "tests", "study", "studies"],
"medication" => ["medication", "medications", "medicine", "medicines", "drug", "drugs", "treatment", "treatments", "therapy", "therapies"],
"procedure" => ["procedure", "procedures", "intervention", "interventions", "treatment", "treatments"],
"monitoring" => ["monitor", "monitoring", "treatment", "treatments"],
"disposition" => ["disposition", "admission", "admit", "transfer", "discharge"],
_ => [category.Trim().ToLowerInvariant()]
};
}
@@ -25,8 +25,11 @@ public static class ContentPromotionCommand
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
var questions = await source.GetCollection<BsonDocument>("Questions")
var sourceQuestions = await source.GetCollection<BsonDocument>("Questions")
.Find(Builders<BsonDocument>.Filter.Eq("IsPublished", true)).ToListAsync();
var questions = sourceQuestions
.Select(QuestionPromotionCommand.PrepareForProduction)
.ToList();
var mediaIds = CollectMediaIds(questions);
var media = mediaIds.Count == 0
? []
+84
View File
@@ -0,0 +1,84 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class CreditStore
{
private readonly IMongoCollection<UserCreditAccount> _accounts;
private readonly TimeProvider _clock;
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock)
{
_accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
_clock = clock;
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" }));
}
public async Task<bool> AwardForFirstAnswerAsync(string userId, string attemptId)
{
if (string.IsNullOrWhiteSpace(attemptId)) return false;
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filters = Builders<UserCreditAccount>.Filter;
var belowCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
{
new BsonDocument("$size", "$RecentEarnings"),
CreditEconomy.RollingEarningLimit
})));
var filter = filters.Eq(value => value.UserId, userId) &
filters.Not(filters.ElemMatch(value => value.RecentEarnings,
earning => earning.OperationId == attemptId)) &
belowCap;
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.Credits, 1)
.Push(value => value.RecentEarnings, new CreditEvent
{
OperationId = attemptId,
CreatedUtc = now
})
.Set(value => value.UpdatedUtc, now);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public async Task<CreditStatusResponse> GetStatusAsync(string userId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
return new CreditStatusResponse
{
Credits = account.Credits,
EarnedInRollingWindow = account.RecentEarnings.Count,
RemainingEarningCapacity = Math.Max(
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count)
};
}
private async Task EnsureAndCleanAsync(string userId, DateTime now)
{
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update
.SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.Credits, 0)
.SetOnInsert(value => value.RecentEarnings, [])
.SetOnInsert(value => value.UpdatedUtc, now),
new UpdateOptions { IsUpsert = true });
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false),
Builders<UserCreditAccount>.Update.Set(value => value.RecentEarnings, []));
var cutoff = now - CreditEconomy.EarningWindow;
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update.PullFilter(
value => value.RecentEarnings,
earning => earning.CreatedUtc <= cutoff));
}
}
+255 -41
View File
@@ -1,12 +1,12 @@
using MongoDB.Driver;
using QuestionsApi.Models;
using QuestionsApi.Services.Flashcards;
namespace QuestionsApi.Services;
public sealed class DashboardStore
{
private readonly IMongoCollection<QuestionAttempt> _attempts;
private readonly IMongoCollection<UserRating> _ratings;
private readonly IMongoCollection<CcsUserRating> _ccsRatings;
private readonly IMongoCollection<CcsAttempt> _ccsAttempts;
private readonly IMongoCollection<CcsCaseRevision> _ccsCases;
@@ -15,19 +15,56 @@ public sealed class DashboardStore
private readonly IMongoCollection<UserNotepad> _notes;
private readonly QuestionStore _questions;
private readonly ReviewQueueStore _reviewQueue;
private readonly DynamicQuestionStore _dynamicQuestions;
private readonly FlashcardStudyService _flashcards;
private readonly FlashcardScheduleStore _flashcardSchedules;
private readonly RatingStore _ratingStore;
private readonly CcsRatingStore _ccsRatingStore;
private readonly IMongoCollection<UserRating> _ratings;
public DashboardStore(MongoDatabaseProvider mongo, QuestionStore questions, ReviewQueueStore reviewQueue)
public static readonly string[] Step3Systems =
[
"Human Development",
"Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders",
"Behavioral Health",
"Nervous System & Special Senses",
"Skin & Subcutaneous Tissue",
"Musculoskeletal System",
"Cardiovascular System",
"Respiratory System",
"Gastrointestinal System",
"Renal/Urinary & Male Reproductive Systems",
"Pregnancy/Childbirth & Female Reproductive System & Breast",
"Endocrine System",
"Biostatistics & Epidemiology/Population Health & Interpretation of the Medical Literature",
"Social Sciences: Communication Skills/Ethics/Patient Safety"
];
public DashboardStore(
MongoDatabaseProvider mongo,
QuestionStore questions,
ReviewQueueStore reviewQueue,
DynamicQuestionStore dynamicQuestions,
FlashcardStudyService flashcards,
FlashcardScheduleStore flashcardSchedules,
RatingStore ratingStore,
CcsRatingStore ccsRatingStore)
{
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
_ccsAttempts = mongo.Database.GetCollection<CcsAttempt>("CcsAttempts");
_ccsCases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
_simulations = mongo.Database.GetCollection<ExamSimulation>("ExamSimulations");
_bookmarks = mongo.Database.GetCollection<QuestionBookmark>("QuestionBookmarks");
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
_questions = questions;
_reviewQueue = reviewQueue;
_dynamicQuestions = dynamicQuestions;
_flashcards = flashcards;
_flashcardSchedules = flashcardSchedules;
_ratingStore = ratingStore;
_ccsRatingStore = ccsRatingStore;
}
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
@@ -36,21 +73,30 @@ public sealed class DashboardStore
var trendStart = today.AddDays(-29);
var weekStart = today.AddDays(-6);
var attemptsTask = _attempts.Find(value => value.UserId == userId).ToListAsync();
var ratingTask = _ratings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var ccsRatingTask = _ccsRatings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var ratingTask = _ratingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
var ccsRatingTask = _ccsRatingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
var ccsAllAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId).ToListAsync();
var completedCcsCountTask = _ccsAttempts.CountDocumentsAsync(value => value.UserId == userId
&& value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue);
var simulationsTask = _simulations.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
var simulationsTask = _simulations.Find(value => value.UserId == userId && !value.IsSample && value.CreatedUtc >= trendStart).ToListAsync();
var completedSimulationsTask = _simulations.Find(value => value.UserId == userId
&& value.State == ExamSimulationStates.Completed && value.Score != null)
.SortByDescending(value => value.CompletedUtc).ToListAsync();
var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, completedCcsCountTask,
simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask);
var flashcardCountsTask = _flashcards.GetDashboardCountsAsync(userId, nowUtc, weekStart);
var flashcardReviewsTask = _flashcardSchedules.GetReviewsSinceAsync(userId, weekStart);
var dynamicReviewsTask = _dynamicQuestions.DueReviewsAsync(userId, nowUtc);
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, ccsAllAttemptsTask, completedCcsCountTask,
simulationsTask, completedSimulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask,
flashcardReviewsTask, dynamicReviewsTask);
var attempts = attemptsTask.Result;
var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList();
var recentAnswered = answeredAttempts.OrderByDescending(value => value.CreatedUtc).Take(100).ToList();
var previousHundred = answeredAttempts.OrderByDescending(value => value.CreatedUtc).Skip(100).Take(100).ToList();
var questionMap = await _questions.GetByExternalIdsAsync(attempts.Select(value => value.QuestionExternalId));
var ccsAttempts = ccsAttemptsTask.Result;
var simulations = simulationsTask.Result;
@@ -58,47 +104,51 @@ public sealed class DashboardStore
await _reviewQueue.EnsureUserQueueAsync(userId);
var pendingReviewCount = await _reviewQueue.CountPendingAsync(userId);
var qbankElo = ratingTask.Result.OverallElo;
var ccsElo = ccsRatingTask.Result.Elo;
var qbankHigherTask = _ratings.CountDocumentsAsync(value => value.OverallElo > qbankElo);
var qbankTotalTask = _ratings.CountDocumentsAsync(FilterDefinition<UserRating>.Empty);
var ccsHigherTask = _ccsRatings.CountDocumentsAsync(value => value.Elo > ccsElo);
var ccsTotalTask = _ccsRatings.CountDocumentsAsync(FilterDefinition<CcsUserRating>.Empty);
await Task.WhenAll(qbankHigherTask, qbankTotalTask, ccsHigherTask, ccsTotalTask);
var latestScore = SelectLatestReadinessScore(completedSimulationsTask.Result);
var accuracySampleSize = recentAnswered.Count;
var firstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count);
var performanceTrend = BuildPerformanceTrend(attempts, ratingTask.Result, ccsAllAttemptsTask.Result, ccsRatingTask.Result, today);
var weekAgoTrend = performanceTrend[^8];
var response = new DashboardResponse
{
QuestionBankRating = ratingTask.Result?.OverallElo ?? 1000,
RatedQuestionCount = ratingTask.Result?.RatedQuestionCount ?? 0,
CcsRating = ccsRatingTask.Result?.Elo ?? 1000,
RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0,
QuestionBankRating = qbankElo,
RatedQuestionCount = ratingTask.Result.RatedQuestionCount,
CcsRating = ccsElo,
RatedCcsCaseCount = ccsRatingTask.Result.RatedCaseCount,
QuestionsCompleted = answeredAttempts.Count,
CcsCasesCompleted = checked((int)completedCcsCountTask.Result),
AccuracySampleSize = recentAnswered.Count,
FirstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count),
AccuracySampleSize = accuracySampleSize,
FirstAttemptAccuracy = firstAttemptAccuracy,
AccuracyDeltaVsPreviousSample = previousHundred.Count == 0
? null
: firstAttemptAccuracy - Percentage(previousHundred.Count(value => value.IsCorrect), previousHundred.Count),
QuestionBankRatingDeltaThisWeek = weekAgoTrend.QuestionBankRating is null ? null : qbankElo - weekAgoTrend.QuestionBankRating,
QuestionBankPercentile = Percentile(qbankHigherTask.Result, qbankTotalTask.Result),
CcsRatingDeltaThisWeek = weekAgoTrend.CcsRating is null ? null : ccsElo - weekAgoTrend.CcsRating,
CcsPercentile = Percentile(ccsHigherTask.Result, ccsTotalTask.Result),
ExamReadinessLabel = latestScore is null ? null : ReadinessLabel(latestScore.PassProbability),
ExamReadinessProjectedPassPercent = latestScore is null ? null : (int)Math.Round(latestScore.PassProbability * 100),
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
BookmarkCount = checked((int)bookmarkCountTask.Result),
NoteCount = CountNotes(notepadTask.Result?.Content),
MissedQuestionCount = checked((int)pendingReviewCount)
MissedQuestionCount = checked((int)pendingReviewCount),
DynamicReviewsDue = dynamicReviewsTask.Result.Count,
FlashcardsDue = flashcardCountsTask.Result.Due,
FlashcardsNewAvailable = flashcardCountsTask.Result.NewAvailable,
FlashcardsReviewedThisWeek = flashcardCountsTask.Result.ReviewedThisWeek
};
response.PerformanceTrend = Enumerable.Range(0, 30).Select(offset =>
{
var date = trendStart.AddDays(offset);
var daily = answeredAttempts.Where(value => value.CreatedUtc.Date == date).ToList();
return new DashboardTrendPoint
{
DateUtc = date,
Correct = daily.Count(value => value.IsCorrect),
Total = daily.Count,
Accuracy = Percentage(daily.Count(value => value.IsCorrect), daily.Count)
};
}).ToList();
response.Systems = answeredAttempts
.Where(value => questionMap.ContainsKey(value.QuestionExternalId))
.GroupBy(value => NormalizeSystem(questionMap[value.QuestionExternalId].System))
.Select(group => new DashboardSystemPerformance
{
System = group.Key,
Correct = group.Count(value => value.IsCorrect),
Total = group.Count(),
Accuracy = Percentage(group.Count(value => value.IsCorrect), group.Count())
})
.OrderByDescending(value => value.Total).ThenByDescending(value => value.Accuracy)
.ToList();
response.PerformanceTrend = performanceTrend;
response.Systems = BuildSystemPerformance(answeredAttempts, questionMap);
response.WeeklyActivity = Enumerable.Range(0, 7).Select(offset =>
{
@@ -115,12 +165,14 @@ public sealed class DashboardStore
+ dailyCcs.Sum(CcsDurationSeconds)
+ dailySimulations.Sum(value => value.Responses.Sum(item => Math.Max(0, item.ElapsedSeconds)))
+ reviewAttemptsTask.Result.Where(value => value.CreatedUtc.Date == date).Sum(value => value.DurationSeconds)
+ flashcardReviewsTask.Result.Where(value => value.ReviewedUtc.Date == date).Sum(value => value.DurationSeconds)
};
}).ToList();
response.StudyTimeSecondsThisWeek = response.WeeklyActivity.Sum(value => value.StudyTimeSeconds);
var activeDates = attempts.Select(value => value.CreatedUtc.Date)
.Concat(completedCcs.Select(value => value.CompletedUtc!.Value.Date))
.Concat(simulations.Where(value => value.CompletedUtc.HasValue).Select(value => value.CompletedUtc!.Value.Date));
.Concat(simulations.Where(value => value.CompletedUtc.HasValue).Select(value => value.CompletedUtc!.Value.Date))
.Concat(flashcardReviewsTask.Result.Select(value => value.ReviewedUtc.Date));
response.CurrentStreak = CalculateCurrentStreak(activeDates, today);
response.RecentActivity = await BuildRecentActivityAsync(answeredAttempts, questionMap, completedCcs, simulations);
return response;
@@ -129,6 +181,22 @@ public sealed class DashboardStore
public static int Percentage(int correct, int total) =>
total <= 0 ? 0 : (int)Math.Round(correct * 100.0 / total, MidpointRounding.AwayFromZero);
public static int? Percentile(long usersRatedHigher, long totalUsers) =>
totalUsers <= 0 ? null : Math.Max(1, (int)Math.Round(usersRatedHigher * 100.0 / totalUsers, MidpointRounding.AwayFromZero));
// Thresholds are a reasonable default for labeling an existing, tested
// pass-probability estimate (ExamSimulationScoring.Calculate) - tune freely,
// this isn't a validated clinical cutoff.
public static string ReadinessLabel(double passProbability) =>
passProbability >= 0.7 ? "High" : passProbability >= 0.4 ? "Medium" : "Low";
// completedSimulationsByRecency must already be sorted newest-first (by CompletedUtc).
// Sample simulations (QA-only, seeded via the SUPER-role "preview results" tooling with
// scripted answers) never reflect real performance, so they're skipped here rather than
// in the Mongo filter - keeping the "which one counts" decision unit-testable.
public static ExamSimulationScore? SelectLatestReadinessScore(IEnumerable<ExamSimulation> completedSimulationsByRecency) =>
completedSimulationsByRecency.FirstOrDefault(value => !value.IsSample)?.Score;
public static int CalculateCurrentStreak(IEnumerable<DateTime> activeDates, DateTime todayUtc)
{
var dates = activeDates.Select(value => value.Date).ToHashSet();
@@ -143,6 +211,152 @@ public sealed class DashboardStore
return streak;
}
public static List<DashboardTrendPoint> BuildPerformanceTrend(
IEnumerable<QuestionAttempt> attempts,
UserRating rating,
IEnumerable<CcsAttempt> ccsAttempts,
CcsUserRating ccsRating,
DateTime todayUtc)
{
var attemptList = attempts.ToList();
var ccsAttemptList = ccsAttempts.ToList();
var trendStart = todayUtc.Date.AddDays(-29);
var ratingEvents = new List<(DateTime AtUtc, int Elo)>();
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
ratingEvents.Add((rating.HistoryStartedUtc.Value, rating.HistoryBaselineElo.Value));
ratingEvents.AddRange(attemptList
.Where(value => value.RatingAppliedUtc.HasValue && value.UserOverallEloAfter.HasValue)
.Select(value => (value.RatingAppliedUtc!.Value, value.UserOverallEloAfter!.Value)));
ratingEvents.Sort((left, right) => left.AtUtc.CompareTo(right.AtUtc));
var ccsRatingEvents = new List<(DateTime AtUtc, int Elo)>();
if (ccsRating.HistoryBaselineElo.HasValue && ccsRating.HistoryStartedUtc.HasValue)
ccsRatingEvents.Add((ccsRating.HistoryStartedUtc.Value, ccsRating.HistoryBaselineElo.Value));
ccsRatingEvents.AddRange(ccsAttemptList
.Where(value => value.Rating is { AppliedUtc: not null })
.Select(value => (value.Rating!.AppliedUtc!.Value, value.Rating!.UserElo)));
ccsRatingEvents.Sort((left, right) => left.AtUtc.CompareTo(right.AtUtc));
int? currentRating = null;
int? currentCcsRating = null;
var eventIndex = 0;
var ccsEventIndex = 0;
var result = new List<DashboardTrendPoint>();
foreach (var offset in Enumerable.Range(0, 30))
{
var date = trendStart.AddDays(offset);
var nextDate = date.AddDays(1);
while (eventIndex < ratingEvents.Count && ratingEvents[eventIndex].AtUtc < nextDate)
{
currentRating = ratingEvents[eventIndex].Elo;
eventIndex++;
}
while (ccsEventIndex < ccsRatingEvents.Count && ccsRatingEvents[ccsEventIndex].AtUtc < nextDate)
{
currentCcsRating = ccsRatingEvents[ccsEventIndex].Elo;
ccsEventIndex++;
}
var daily = attemptList
.Where(value => !value.IsSkip && value.CreatedUtc >= date && value.CreatedUtc < nextDate)
.ToList();
var correct = daily.Count(value => value.IsCorrect);
result.Add(new DashboardTrendPoint
{
DateUtc = date,
Correct = correct,
Total = daily.Count,
Accuracy = Percentage(correct, daily.Count),
QuestionBankRating = currentRating,
CcsRating = currentCcsRating
});
}
return result;
}
public static List<DashboardSystemPerformance> BuildSystemPerformance(
IEnumerable<QuestionAttempt> attempts,
IReadOnlyDictionary<string, Question> questionMap)
{
var totals = Step3Systems.ToDictionary(
system => system,
_ => (Correct: 0, Total: 0),
StringComparer.Ordinal);
foreach (var attempt in attempts.Where(value => !value.IsSkip))
{
if (!questionMap.TryGetValue(attempt.QuestionExternalId, out var question))
continue;
var system = NormalizeStep3System(question);
if (system is null)
continue;
var current = totals[system];
totals[system] = (current.Correct + (attempt.IsCorrect ? 1 : 0), current.Total + 1);
}
return Step3Systems.Select(system => new DashboardSystemPerformance
{
System = system,
Correct = totals[system].Correct,
Total = totals[system].Total,
Accuracy = Percentage(totals[system].Correct, totals[system].Total)
}).ToList();
}
public static string? NormalizeStep3System(Question question)
{
foreach (var candidate in new[] { question.System, question.PerformanceCategory, question.Discipline })
{
var normalized = NormalizeStep3System(candidate);
if (normalized is not null)
return normalized;
}
return null;
}
public static string? NormalizeStep3System(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return null;
var exact = Step3Systems.FirstOrDefault(
system => string.Equals(system, value.Trim(), StringComparison.OrdinalIgnoreCase));
if (exact is not null)
return exact;
var lower = value.Trim().ToLowerInvariant();
if (ContainsAny(lower, "human development", "well patient", "preventive", "health maintenance", "geriatr", "age-related"))
return Step3Systems[0];
if (ContainsAny(lower, "social science", "communication", "ethic", "patient safety", "professionalism", "systems-based"))
return Step3Systems[13];
if (ContainsAny(lower, "pregnan", "obstetric", "gynec", "female reproductive", "breast", "puerper"))
return Step3Systems[10];
if (ContainsAny(lower, "renal", "urinary", "male reproductive", "urolog", "nephro"))
return Step3Systems[9];
if (ContainsAny(lower, "biostat", "epidemi", "population health", "medical literature"))
return Step3Systems[12];
if (ContainsAny(lower, "immune", "immun", "blood", "hemat", "lymph", "multisystem", "infectious"))
return Step3Systems[1];
if (ContainsAny(lower, "behavior", "psychi", "mental health"))
return Step3Systems[2];
if (ContainsAny(lower, "nervous", "neuro", "special sense", "ophthalm", "otolaryng", " ent"))
return Step3Systems[3];
if (ContainsAny(lower, "skin", "derm", "subcutaneous"))
return Step3Systems[4];
if (ContainsAny(lower, "musculo", "orthop", "rheumat"))
return Step3Systems[5];
if (ContainsAny(lower, "cardio", "circulatory"))
return Step3Systems[6];
if (ContainsAny(lower, "resp", "pulmon"))
return Step3Systems[7];
if (ContainsAny(lower, "gastro", "digest", "hepato"))
return Step3Systems[8];
if (ContainsAny(lower, "endocr", "metabolic"))
return Step3Systems[11];
return null;
}
private static bool ContainsAny(string value, params string[] fragments) =>
fragments.Any(value.Contains);
private async Task<List<DashboardRecentActivity>> BuildRecentActivityAsync(
List<QuestionAttempt> attempts,
Dictionary<string, Question> questionMap,
@@ -0,0 +1,54 @@
using Microsoft.Extensions.Configuration;
using QuestionsApi.Models;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class DynamicBlueprintCommand
{
public static async Task<int> RunAsync(string[] args, TextWriter output)
{
var apply = args.Contains("--apply", StringComparer.OrdinalIgnoreCase);
var sourceIndex = Array.FindIndex(args, value => value.Equals("--source", StringComparison.OrdinalIgnoreCase));
var source = sourceIndex >= 0 && sourceIndex + 1 < args.Length
? args[sourceIndex + 1]
: Path.Combine(AppContext.BaseDirectory, "content", "dynamic_questions");
if (!Directory.Exists(source))
{
await output.WriteLineAsync($"Dynamic blueprint directory not found: {source}");
return 2;
}
var json = new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true };
var blueprints = new List<DynamicQuestionBlueprint>();
foreach (var file in Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories))
blueprints.AddRange(JsonSerializer.Deserialize<List<DynamicQuestionBlueprint>>(await File.ReadAllTextAsync(file), json) ?? []);
blueprints = DynamicBlueprintResolver.ResolveAll(blueprints);
var generator = new DynamicQuestionGenerator();
var generatedVariants = 0;
foreach (var blueprint in blueprints)
{
var error = DynamicQuestionGenerator.Validate(blueprint);
if (error is not null)
throw new InvalidDataException($"{blueprint.ExternalId} v{blueprint.Version}: {error}");
var validationSeeds = blueprint.QualityStandardVersion >= 2 ? 10_000 : 1_000;
for (var seed = 0; seed < validationSeeds; seed++)
generator.Generate(blueprint, seed.ToString(), seed.ToString());
generatedVariants += validationSeeds;
}
await output.WriteLineAsync($"Validated {blueprints.Count} blueprints across {generatedVariants:N0} generated variants.");
if (!apply)
{
await output.WriteLineAsync("Dry run only. Pass --apply to publish.");
return 0;
}
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
var store = new DynamicQuestionStore(new MongoDatabaseProvider(configuration));
var result = await store.UpsertBlueprintsAsync(blueprints);
await output.WriteLineAsync($"Promotion complete: {result.Inserted} inserted, {result.Unchanged} unchanged.");
return 0;
}
}
@@ -0,0 +1,37 @@
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public static class DynamicBlueprintCoveragePolicy
{
private static readonly Dictionary<string, double> SystemWeights =
new(StringComparer.OrdinalIgnoreCase)
{
["Nutrition"] = 17.5,
["Biostatistics"] = 12,
["Cardiovascular"] = 10,
["Neurologic"] = 9,
["Nervous System"] = 9,
["Pulmonary"] = 9,
["Respiratory"] = 9,
["Pregnancy"] = 8,
["Social Sciences"] = 8,
["Gastrointestinal"] = 7,
["Immune System"] = 7,
["Hematology"] = 7,
["Multisystem"] = 7,
["Infectious Disease"] = 7,
["Endocrine"] = 6,
["Musculoskeletal"] = 6,
["Behavioral Health"] = 5,
["Renal"] = 5,
["Skin"] = 5,
["Human Development"] = 2
};
public static double Weight(DynamicQuestionBlueprint blueprint)
{
if (blueprint.BlueprintWeight > 0) return blueprint.BlueprintWeight;
return SystemWeights.GetValueOrDefault(blueprint.System, 5);
}
}
@@ -0,0 +1,72 @@
using QuestionsApi.Models;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class DynamicBlueprintResolver
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public static List<DynamicQuestionBlueprint> ResolveAll(IEnumerable<DynamicQuestionBlueprint> source)
{
var all = source.ToList();
var resolved = new List<DynamicQuestionBlueprint>();
foreach (var blueprint in all.OrderBy(value => value.Version))
{
if (blueprint.BaseBlueprintVersion <= 0)
{
resolved.Add(blueprint);
continue;
}
var baseBlueprint = resolved.FirstOrDefault(value =>
value.ExternalId == blueprint.ExternalId
&& value.Version == blueprint.BaseBlueprintVersion)
?? throw new InvalidDataException(
$"{blueprint.ExternalId} v{blueprint.Version} base version {blueprint.BaseBlueprintVersion} not found");
var materialized = Clone(baseBlueprint);
materialized.Id = null;
materialized.Version = blueprint.Version;
materialized.BaseBlueprintVersion = blueprint.BaseBlueprintVersion;
materialized.QualityStandardVersion = blueprint.QualityStandardVersion;
materialized.IsPublished = blueprint.IsPublished;
materialized.EncounterFrame = blueprint.EncounterFrame;
materialized.SiteOfCare = blueprint.SiteOfCare;
materialized.BlueprintWeight = blueprint.BlueprintWeight;
materialized.ConceptIds = blueprint.ConceptIds;
materialized.PrerequisiteConceptIds = blueprint.PrerequisiteConceptIds;
materialized.MedicallyReviewedUtc = blueprint.MedicallyReviewedUtc;
materialized.MedicalReviewExpiresUtc = blueprint.MedicalReviewExpiresUtc;
materialized.MedicalReviewer = blueprint.MedicalReviewer;
if (blueprint.Distractors.Count > 0)
materialized.Distractors = blueprint.Distractors;
if (blueprint.CaseFamilies.Count > 0)
materialized.CaseFamilies = blueprint.CaseFamilies.Select(overlay =>
{
var baseFamily = baseBlueprint.CaseFamilies.FirstOrDefault(value => value.Id == overlay.BaseFamilyId)
?? throw new InvalidDataException(
$"{blueprint.ExternalId} v{blueprint.Version} base family {overlay.BaseFamilyId} not found");
var family = Clone(baseFamily);
family.Id = overlay.Id;
family.BaseFamilyId = overlay.BaseFamilyId;
family.DifficultyTier = overlay.DifficultyTier;
family.NominalElo = overlay.NominalElo;
if (!string.IsNullOrWhiteSpace(family.LeadIn)
&& family.StemTemplate.EndsWith(family.LeadIn, StringComparison.Ordinal))
family.StemTemplate = family.StemTemplate[..^family.LeadIn.Length].TrimEnd();
if (!string.IsNullOrWhiteSpace(overlay.StemTemplate))
family.StemTemplate = $"{family.StemTemplate} {overlay.StemTemplate}".Trim();
if (!string.IsNullOrWhiteSpace(overlay.LeadIn))
family.LeadIn = overlay.LeadIn;
if (overlay.Distractors.Count > 0)
family.Distractors = overlay.Distractors;
return family;
}).ToList();
resolved.Add(materialized);
}
return resolved;
}
private static T Clone<T>(T value) =>
JsonSerializer.Deserialize<T>(JsonSerializer.Serialize(value, Json), Json)
?? throw new InvalidOperationException("Blueprint clone failed.");
}
@@ -0,0 +1,69 @@
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public static class DynamicDifficultyPolicy
{
public const int MinimumElo = 600;
public const int MaximumElo = 1800;
public const int TargetOffset = 70;
public static readonly IReadOnlyDictionary<string, int> TierElos =
new Dictionary<string, int>(StringComparer.Ordinal)
{
["foundation"] = 850,
["standard"] = 1050,
["advanced"] = 1250,
["expert"] = 1450
};
public static int EffectiveAbility(
int overallElo,
int categoryElo,
DynamicObjectiveMastery? objective)
{
var bankAbility = (int)Math.Round((overallElo + categoryElo) / 2.0);
if (objective is null || objective.Attempts <= 0)
return Clamp(bankAbility);
var objectiveWeight = Math.Min(0.8, objective.Attempts * 0.08);
return Clamp((int)Math.Round(bankAbility * (1.0 - objectiveWeight) + objective.Elo * objectiveWeight));
}
public static int TargetFamilyElo(int effectiveAbility) => Clamp(effectiveAbility - TargetOffset);
public static double ExpectedScore(int playerElo, int familyElo) =>
1.0 / (1.0 + Math.Pow(10, (familyElo - playerElo) / 400.0));
public static int LearnerDelta(int playerElo, int familyElo, bool correct, int attempts) =>
(int)Math.Round((attempts < 5 ? 40 : 24)
* ((correct ? 1.0 : 0.0) - ExpectedScore(playerElo, familyElo)));
public static int FamilyDelta(int playerElo, int familyElo, bool correct, int attempts) =>
(int)Math.Round((attempts < 20 ? 32 : 24)
* (ExpectedScore(playerElo, familyElo) - (correct ? 1.0 : 0.0)));
public static DynamicCaseFamily SelectFamily(
IReadOnlyList<DynamicCaseFamily> families,
IReadOnlyDictionary<string, DynamicFamilyRating> ratings,
int targetElo,
ISet<string> recentFamilyIds,
int tieBreaker)
{
if (families.Count == 0) throw new InvalidOperationException("No case families are available.");
var eligible = families.Where(value => !recentFamilyIds.Contains(value.Id)).ToList();
if (eligible.Count == 0) eligible = families.ToList();
var ranked = eligible
.Select(value => new
{
Family = value,
Elo = ratings.TryGetValue(value.Id, out var rating) ? rating.Elo : value.NominalElo
})
.OrderBy(value => Math.Abs(value.Elo - targetElo))
.ThenBy(value => value.Family.Id, StringComparer.Ordinal)
.ToList();
var bestDistance = Math.Abs(ranked[0].Elo - targetElo);
var tied = ranked.Where(value => Math.Abs(value.Elo - targetElo) == bestDistance).ToList();
return tied[Math.Abs(tieBreaker) % tied.Count].Family;
}
public static int Clamp(int elo) => Math.Clamp(elo, MinimumElo, MaximumElo);
}
@@ -0,0 +1,448 @@
using QuestionsApi.Models;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace QuestionsApi.Services;
public sealed class DynamicQuestionGenerator
{
public const int CurrentVersion = 2;
private static readonly Regex Placeholder = new(@"\{\{([a-zA-Z0-9_]+)\}\}", RegexOptions.Compiled);
public DynamicGeneratedQuestion Generate(
DynamicQuestionBlueprint blueprint,
string seed,
string nonce,
int generatorVersion = CurrentVersion,
string caseFamilyId = "")
=> blueprint.QualityStandardVersion >= 2
? GenerateV2(blueprint, seed, nonce, caseFamilyId)
: GenerateLegacy(blueprint, seed, nonce, generatorVersion);
private DynamicGeneratedQuestion GenerateLegacy(DynamicQuestionBlueprint blueprint, string seed, string nonce, int generatorVersion)
{
var draw = new DeterministicDraw($"{blueprint.ExternalId}|{blueprint.Version}|{generatorVersion}|{seed}");
var values = new Dictionary<string, string>(StringComparer.Ordinal);
var numbers = new Dictionary<string, double>(StringComparer.Ordinal);
foreach (var item in blueprint.Variables.OrderBy(value => value.Key, StringComparer.Ordinal))
{
var definition = item.Value;
if (definition.Type == "enum")
{
if (definition.Values.Count == 0) throw new InvalidOperationException($"{item.Key} requires values");
values[item.Key] = definition.Values[draw.Next(definition.Values.Count)];
continue;
}
var step = definition.Step <= 0 ? 1 : definition.Step;
var count = (int)Math.Floor((definition.Maximum - definition.Minimum) / step) + 1;
if (count < 1) throw new InvalidOperationException($"{item.Key} range is invalid");
var number = definition.Minimum + draw.Next(count) * step;
number = Math.Round(number, Math.Clamp(definition.Precision, 0, 8));
numbers[item.Key] = number;
values[item.Key] = Format(number, definition.Precision);
}
foreach (var item in blueprint.Derived)
{
var definition = item.Value;
var left = ResolveNumber(definition.Left, numbers);
var right = ResolveNumber(definition.Right, numbers);
var number = definition.Operation switch
{
"add" => left + right,
"subtract" => left - right,
"multiply" => left * right,
"divide" when Math.Abs(right) > double.Epsilon => left / right,
_ => throw new InvalidOperationException($"{item.Key} operation is invalid")
};
number = Math.Round(number, Math.Clamp(definition.Precision, 0, 8));
numbers[item.Key] = number;
values[item.Key] = Format(number, definition.Precision);
}
foreach (var constraint in blueprint.Constraints)
{
if (!numbers.TryGetValue(constraint.Field, out var actual))
throw new InvalidOperationException($"constraint field {constraint.Field} is not numeric");
var valid = constraint.Operator switch
{
"lt" => actual < constraint.Value,
"lte" => actual <= constraint.Value,
"gt" => actual > constraint.Value,
"gte" => actual >= constraint.Value,
"eq" => Math.Abs(actual - constraint.Value) < 0.0000001,
"neq" => Math.Abs(actual - constraint.Value) >= 0.0000001,
_ => false
};
if (!valid) throw new InvalidOperationException($"generated value violates {constraint.Field} {constraint.Operator}");
}
var distractors = blueprint.Distractors.OrderBy(_ => draw.Next(int.MaxValue)).Take(4).ToList();
if (distractors.Count != 4) throw new InvalidOperationException("at least four distractors are required");
var answerTemplates = distractors.Append(blueprint.CorrectAnswer)
.OrderBy(_ => draw.Next(int.MaxValue)).ToList();
var choices = answerTemplates.Select(answer => new QuestionChoice
{
Id = answer.Id,
Text = Render(answer.TextTemplate, values)
}).ToList();
if (choices.Select(value => value.Id).Distinct(StringComparer.Ordinal).Count() != 5
|| choices.Select(value => value.Text).Distinct(StringComparer.OrdinalIgnoreCase).Count() != 5)
throw new InvalidOperationException("generated choices must be unique");
var question = new Question
{
Id = $"dynamic-{nonce}",
ExternalId = $"dynamic-{nonce}",
Stem = Render(blueprint.StemTemplate, values),
LeadIn = Render(blueprint.LeadIn, values),
Choices = choices,
CorrectChoiceId = blueprint.CorrectAnswer.Id,
Explanation = Render(blueprint.ExplanationTemplate, values),
KeyTakeaway = Render(blueprint.KeyTakeaway, values),
UsmlePearl = Render(blueprint.UsmlePearl, values),
ChoiceExplanations = answerTemplates.ToDictionary(
value => value.Id,
value => Render(value.ExplanationTemplate, values)),
Discipline = blueprint.Discipline,
System = blueprint.System,
PerformanceCategory = blueprint.PerformanceCategory,
Difficulty = blueprint.Difficulty,
Labs = blueprint.Labs.Select(lab => new QuestionLab
{
Name = lab.Name,
Value = Render(lab.ValueTemplate, values),
Unit = lab.Unit,
Reference = lab.Reference
}).ToList(),
Tags = [.. blueprint.Tags, "dynamic", $"objective:{blueprint.ObjectiveId}"],
References = blueprint.References,
IsPublished = true
};
return new DynamicGeneratedQuestion { Question = question, Blueprint = blueprint };
}
private DynamicGeneratedQuestion GenerateV2(DynamicQuestionBlueprint blueprint, string seed, string nonce, string caseFamilyId)
{
if (blueprint.CaseFamilies.Count == 0)
throw new InvalidOperationException("quality-standard-2 blueprints require case families");
var draw = new DeterministicDraw($"{blueprint.ExternalId}|{blueprint.Version}|{CurrentVersion}|{seed}");
var orderedFamilies = blueprint.CaseFamilies.OrderBy(value => value.Id, StringComparer.Ordinal).ToList();
var family = string.IsNullOrWhiteSpace(caseFamilyId)
? orderedFamilies[draw.Next(orderedFamilies.Count)]
: orderedFamilies.FirstOrDefault(value => value.Id == caseFamilyId)
?? throw new InvalidOperationException($"case family {caseFamilyId} is unavailable");
var values = new Dictionary<string, string>(StringComparer.Ordinal);
var numbers = new Dictionary<string, double>(StringComparer.Ordinal);
if (family.ValueBundles.Count > 0)
foreach (var item in family.ValueBundles[draw.Next(family.ValueBundles.Count)])
values[item.Key] = item.Value;
PopulateVariables(family.Variables, values, numbers, draw);
PopulateDerived(family.Derived, values, numbers);
ValidateConstraints(family.Constraints, numbers);
var correctAnswer = string.IsNullOrWhiteSpace(family.CorrectAnswer.Id) ? blueprint.CorrectAnswer : family.CorrectAnswer;
var distractorPool = family.Distractors.Count == 0 ? blueprint.Distractors : family.Distractors;
var reviewTemplates = family.ReviewFollowups.Count == 0 ? blueprint.ReviewFollowups : family.ReviewFollowups;
var wrongTemplates = family.WrongAnswerFollowups.Count == 0 ? blueprint.WrongAnswerFollowups : family.WrongAnswerFollowups;
var explanationTemplate = string.IsNullOrWhiteSpace(family.ExplanationTemplate) ? blueprint.ExplanationTemplate : family.ExplanationTemplate;
var keyTakeaway = string.IsNullOrWhiteSpace(family.KeyTakeaway) ? blueprint.KeyTakeaway : family.KeyTakeaway;
var usmlePearl = string.IsNullOrWhiteSpace(family.UsmlePearl) ? blueprint.UsmlePearl : family.UsmlePearl;
var distractors = distractorPool.OrderBy(_ => draw.Next(int.MaxValue)).Take(4).ToList();
if (distractors.Count != 4) throw new InvalidOperationException($"{family.Id} requires at least four distractors");
var answerTemplates = distractors.Append(correctAnswer).OrderBy(_ => draw.Next(int.MaxValue)).ToList();
var choices = answerTemplates.Select(answer => new QuestionChoice
{
Id = answer.Id,
Text = Render(answer.TextTemplate, values)
}).ToList();
if (choices.Select(value => value.Id).Distinct(StringComparer.Ordinal).Count() != 5
|| choices.Select(value => value.Text).Distinct(StringComparer.OrdinalIgnoreCase).Count() != 5)
throw new InvalidOperationException($"{family.Id} generated choices must be unique");
var question = new Question
{
Id = $"dynamic-{nonce}",
ExternalId = $"dynamic-{nonce}",
Stem = Render(family.StemTemplate, values),
LeadIn = Render(family.LeadIn, values),
Choices = choices,
CorrectChoiceId = correctAnswer.Id,
Explanation = Render(explanationTemplate, values),
KeyTakeaway = Render(keyTakeaway, values),
UsmlePearl = Render(usmlePearl, values),
ChoiceExplanations = answerTemplates.ToDictionary(
value => value.Id, value => Render(value.ExplanationTemplate, values)),
ReviewFollowups = reviewTemplates.Select(followup => new QuestionReviewFollowup
{
Prompt = Render(followup.PromptTemplate, values),
Choices = followup.Choices.Select(choice => new QuestionChoice
{ Id = choice.Id, Text = Render(choice.TextTemplate, values) }).ToList(),
CorrectChoiceId = followup.CorrectChoiceId,
TeachingPoint = Render(followup.TeachingPointTemplate, values)
}).ToList(),
WrongAnswerFollowups = BuildWrongAnswerFollowups(
distractors, correctAnswer, wrongTemplates, values),
Discipline = blueprint.Discipline,
System = blueprint.System,
PerformanceCategory = blueprint.PerformanceCategory,
Difficulty = family.DifficultyTier switch
{
"foundation" => 1,
"standard" => 2,
"advanced" => 4,
"expert" => 5,
_ => blueprint.Difficulty
},
Labs = family.Labs.Select(lab => new QuestionLab
{
Name = lab.Name,
Value = Render(lab.ValueTemplate, values),
Unit = lab.Unit,
Reference = lab.Reference
}).ToList(),
Tags = [.. blueprint.Tags, "dynamic", "quality-standard:2", $"objective:{blueprint.ObjectiveId}", $"family:{family.Id}"],
References = blueprint.References,
IsPublished = true
};
return new DynamicGeneratedQuestion { Question = question, Blueprint = blueprint, CaseFamilyId = family.Id };
}
private static void PopulateVariables(
IReadOnlyDictionary<string, DynamicVariableDefinition> definitions,
IDictionary<string, string> values,
IDictionary<string, double> numbers,
DeterministicDraw draw)
{
foreach (var item in definitions.OrderBy(value => value.Key, StringComparer.Ordinal))
{
var definition = item.Value;
if (definition.Type == "enum")
{
if (definition.Values.Count == 0) throw new InvalidOperationException($"{item.Key} requires values");
values[item.Key] = definition.Values[draw.Next(definition.Values.Count)];
continue;
}
var step = definition.Step <= 0 ? 1 : definition.Step;
var count = (int)Math.Floor((definition.Maximum - definition.Minimum) / step) + 1;
if (count < 1) throw new InvalidOperationException($"{item.Key} range is invalid");
var number = Math.Round(definition.Minimum + draw.Next(count) * step, Math.Clamp(definition.Precision, 0, 8));
numbers[item.Key] = number;
values[item.Key] = Format(number, definition.Precision);
}
}
private static void PopulateDerived(
IReadOnlyDictionary<string, DynamicDerivedDefinition> definitions,
IDictionary<string, string> values,
Dictionary<string, double> numbers)
{
foreach (var item in definitions)
{
var definition = item.Value;
var left = ResolveNumber(definition.Left, numbers);
var right = ResolveNumber(definition.Right, numbers);
var number = definition.Operation switch
{
"add" => left + right,
"subtract" => left - right,
"multiply" => left * right,
"divide" when Math.Abs(right) > double.Epsilon => left / right,
_ => throw new InvalidOperationException($"{item.Key} operation is invalid")
};
number = Math.Round(number, Math.Clamp(definition.Precision, 0, 8));
numbers[item.Key] = number;
values[item.Key] = Format(number, definition.Precision);
}
}
private static void ValidateConstraints(IEnumerable<DynamicConstraint> constraints, IReadOnlyDictionary<string, double> numbers)
{
foreach (var constraint in constraints)
{
if (!numbers.TryGetValue(constraint.Field, out var actual))
throw new InvalidOperationException($"constraint field {constraint.Field} is not numeric");
var valid = constraint.Operator switch
{
"lt" => actual < constraint.Value,
"lte" => actual <= constraint.Value,
"gt" => actual > constraint.Value,
"gte" => actual >= constraint.Value,
"eq" => Math.Abs(actual - constraint.Value) < 0.0000001,
"neq" => Math.Abs(actual - constraint.Value) >= 0.0000001,
_ => false
};
if (!valid) throw new InvalidOperationException($"generated value violates {constraint.Field} {constraint.Operator}");
}
}
private static Dictionary<string, WrongAnswerFollowup> BuildWrongAnswerFollowups(
IEnumerable<DynamicAnswerTemplate> distractors,
DynamicAnswerTemplate correct,
IReadOnlyDictionary<string, DynamicWrongAnswerFollowupTemplate> templates,
IReadOnlyDictionary<string, string> values)
{
var result = new Dictionary<string, WrongAnswerFollowup>();
foreach (var distractor in distractors)
{
if (templates.TryGetValue(distractor.Id, out var template))
{
result[distractor.Id] = new WrongAnswerFollowup
{
Prompt = Render(template.PromptTemplate, values),
Choices = template.Choices.Select(choice => new QuestionChoice
{ Id = choice.Id, Text = Render(choice.TextTemplate, values) }).ToList(),
CorrectChoiceId = template.CorrectChoiceId,
TeachingPoint = Render(template.TeachingPointTemplate, values)
};
continue;
}
result[distractor.Id] = new WrongAnswerFollowup
{
Prompt = "Which option best addresses the decisive finding in this case?",
Choices =
[
new QuestionChoice { Id = correct.Id, Text = Render(correct.TextTemplate, values) },
new QuestionChoice { Id = distractor.Id, Text = Render(distractor.TextTemplate, values) }
],
CorrectChoiceId = correct.Id,
TeachingPoint = $"{Render(distractor.ExplanationTemplate, values)} {Render(correct.ExplanationTemplate, values)}"
};
}
return result;
}
public static string? Validate(DynamicQuestionBlueprint blueprint)
{
if (string.IsNullOrWhiteSpace(blueprint.ExternalId)) return "externalId required";
if (string.IsNullOrWhiteSpace(blueprint.ObjectiveId)) return "objectiveId required";
if (blueprint.Version < 1) return "version must be positive";
if (blueprint.BlueprintWeight < 0) return "blueprintWeight cannot be negative";
if (blueprint.IsPublished
&& blueprint.MedicalReviewExpiresUtc is { } expiresUtc
&& expiresUtc <= DateTime.UtcNow)
return "medical review has expired";
if (blueprint.QualityStandardVersion >= 2)
return ValidateV2(blueprint);
if (string.IsNullOrWhiteSpace(blueprint.StemTemplate) || string.IsNullOrWhiteSpace(blueprint.LeadIn)) return "stemTemplate and leadIn required";
if (string.IsNullOrWhiteSpace(blueprint.CorrectAnswer.Id) || string.IsNullOrWhiteSpace(blueprint.CorrectAnswer.TextTemplate)) return "correctAnswer required";
if (blueprint.Distractors.Count < 4) return "at least four distractors required";
var ids = blueprint.Distractors.Select(value => value.Id).Append(blueprint.CorrectAnswer.Id).ToList();
if (ids.Any(string.IsNullOrWhiteSpace) || ids.Distinct(StringComparer.Ordinal).Count() != ids.Count) return "answer ids must be unique";
if (blueprint.References.Count == 0) return "at least one reference required";
try
{
var generator = new DynamicQuestionGenerator();
for (var index = 0; index < 100; index++)
generator.Generate(blueprint, $"validation-{index}", $"validation-{index}");
}
catch (Exception exception) { return exception.Message; }
return null;
}
private static string? ValidateV2(DynamicQuestionBlueprint blueprint)
{
if (string.IsNullOrWhiteSpace(blueprint.CorrectConceptId)) return "correctConceptId required";
if (blueprint.CaseFamilies.Count < 3) return "at least three case families required";
if (blueprint.References.Count == 0) return "at least one reference required";
if (blueprint.CaseFamilies.Select(value => value.Id).Distinct(StringComparer.Ordinal).Count() != blueprint.CaseFamilies.Count)
return "case family ids must be unique";
if (blueprint.QualityStandardVersion >= 3)
{
foreach (var tier in DynamicDifficultyPolicy.TierElos)
{
var tierFamilies = blueprint.CaseFamilies
.Where(value => value.DifficultyTier == tier.Key).ToList();
if (tierFamilies.Count < 2) return $"at least two {tier.Key} case families required";
if (tierFamilies.Any(value => Math.Abs(value.NominalElo - tier.Value) > 100))
return $"{tier.Key} nominal Elo must be within 100 points of {tier.Value}";
}
if (blueprint.CaseFamilies.Any(value => !DynamicDifficultyPolicy.TierElos.ContainsKey(value.DifficultyTier)))
return "difficultyTier must be foundation, standard, advanced, or expert";
}
if (blueprint.QualityStandardVersion >= 4)
{
if (blueprint.BlueprintWeight <= 0) return "quality standard 4 requires blueprintWeight";
if (string.IsNullOrWhiteSpace(blueprint.System)
|| string.IsNullOrWhiteSpace(blueprint.PhysicianTask)
|| string.IsNullOrWhiteSpace(blueprint.EncounterFrame)
|| string.IsNullOrWhiteSpace(blueprint.SiteOfCare))
return "quality standard 4 requires system, physicianTask, encounterFrame, and siteOfCare";
if (blueprint.ConceptIds.Count == 0) return "quality standard 4 requires conceptIds";
if (blueprint.MedicallyReviewedUtc is null
|| blueprint.MedicalReviewExpiresUtc is null
|| string.IsNullOrWhiteSpace(blueprint.MedicalReviewer))
return "quality standard 4 requires medical review metadata";
if (blueprint.Distractors.Any(value => string.IsNullOrWhiteSpace(value.MisconceptionId)))
return "quality standard 4 requires a misconceptionId for every distractor";
}
foreach (var family in blueprint.CaseFamilies)
{
if (string.IsNullOrWhiteSpace(family.Id)) return "case family id required";
if (family.StemTemplate.Length < 220) return $"{family.Id} stem is too short for quality standard 2";
if (string.IsNullOrWhiteSpace(family.LeadIn)) return $"{family.Id} leadIn required";
if (!string.IsNullOrWhiteSpace(family.CorrectAnswer.Id))
return $"{family.Id} must use the blueprint-level invariant correct answer";
var correctAnswer = string.IsNullOrWhiteSpace(family.CorrectAnswer.Id) ? blueprint.CorrectAnswer : family.CorrectAnswer;
var distractors = family.Distractors.Count == 0 ? blueprint.Distractors : family.Distractors;
var reviews = family.ReviewFollowups.Count == 0 ? blueprint.ReviewFollowups : family.ReviewFollowups;
var wrong = family.WrongAnswerFollowups.Count == 0 ? blueprint.WrongAnswerFollowups : family.WrongAnswerFollowups;
if (distractors.Count < 6) return $"{family.Id} requires at least six distractors";
if (blueprint.QualityStandardVersion >= 4
&& distractors.Any(value => string.IsNullOrWhiteSpace(value.MisconceptionId)))
return $"{family.Id} every distractor requires a misconceptionId";
var answers = distractors.Append(correctAnswer).ToList();
if (answers.Any(value => string.IsNullOrWhiteSpace(value.Id)
|| string.IsNullOrWhiteSpace(value.TextTemplate)
|| string.IsNullOrWhiteSpace(value.ExplanationTemplate)))
return $"{family.Id} every answer requires id, text, and explanation";
if (answers.Select(value => value.Id).Distinct(StringComparer.Ordinal).Count() != answers.Count)
return $"{family.Id} answer ids must be unique";
if (reviews.Count == 0) return $"{family.Id} review followup required";
if (string.IsNullOrWhiteSpace(family.ExplanationTemplate) && string.IsNullOrWhiteSpace(blueprint.ExplanationTemplate)
|| string.IsNullOrWhiteSpace(family.KeyTakeaway) && string.IsNullOrWhiteSpace(blueprint.KeyTakeaway)
|| string.IsNullOrWhiteSpace(family.UsmlePearl) && string.IsNullOrWhiteSpace(blueprint.UsmlePearl))
return $"{family.Id} complete teaching content required";
}
try
{
var generator = new DynamicQuestionGenerator();
for (var index = 0; index < 300; index++)
{
var generated = generator.Generate(blueprint, $"validation-{index}", $"validation-{index}");
if (generated.Question.ChoiceExplanations.Count != 5) return "every displayed choice requires an explanation";
}
}
catch (Exception exception) { return exception.Message; }
return null;
}
private static string Render(string template, IReadOnlyDictionary<string, string> values)
{
var rendered = Placeholder.Replace(template ?? "", match =>
values.TryGetValue(match.Groups[1].Value, out var value) ? value : match.Value);
if (Placeholder.IsMatch(rendered)) throw new InvalidOperationException($"unresolved template in {rendered}");
return rendered;
}
private static double ResolveNumber(string operand, IReadOnlyDictionary<string, double> numbers) =>
numbers.TryGetValue(operand, out var value)
? value
: double.TryParse(operand, NumberStyles.Float, CultureInfo.InvariantCulture, out value)
? value
: throw new InvalidOperationException($"unknown numeric operand {operand}");
private static string Format(double value, int precision) =>
value.ToString(precision > 0 ? $"F{precision}" : "0", CultureInfo.InvariantCulture);
private sealed class DeterministicDraw(string source)
{
private int _counter;
public int Next(int maximum)
{
if (maximum <= 0) throw new ArgumentOutOfRangeException(nameof(maximum));
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{source}|{_counter++}"));
return (int)(BitConverter.ToUInt32(bytes, 0) % (uint)maximum);
}
}
}
@@ -0,0 +1,192 @@
using QuestionsApi.Models;
using System.Security.Cryptography;
namespace QuestionsApi.Services;
public sealed class DynamicQuestionService
{
private readonly DynamicQuestionStore _store;
private readonly DynamicQuestionGenerator _generator;
private readonly DynamicQuestionTokenService _tokens;
private readonly RatingStore _ratings;
private readonly ServiceMetrics _metrics;
public DynamicQuestionService(
DynamicQuestionStore store,
DynamicQuestionGenerator generator,
DynamicQuestionTokenService tokens,
RatingStore ratings,
ServiceMetrics metrics)
{
_store = store;
_generator = generator;
_tokens = tokens;
_ratings = ratings;
_metrics = metrics;
}
public Task<DynamicQuestionStatusResponse> StatusAsync(string userId) => _store.StatusAsync(userId);
public async Task<DynamicQuestionEnvelope> NextAsync(string userId, string runToken)
{
var blueprints = await _store.PublishedAsync();
if (blueprints.Count == 0)
throw new InvalidOperationException("No dynamic question blueprints are published.");
var run = string.IsNullOrWhiteSpace(runToken)
? NewRun(userId)
: _tokens.ReadRun(runToken, userId);
var byObjective = blueprints.GroupBy(value => value.ObjectiveId).ToList();
var objectiveIds = byObjective.Select(value => value.Key).ToHashSet(StringComparer.Ordinal);
run.SeenObjectiveIds = run.SeenObjectiveIds.Where(objectiveIds.Contains).Distinct(StringComparer.Ordinal).ToList();
var unseen = byObjective.Where(group => !run.SeenObjectiveIds.Contains(group.Key, StringComparer.Ordinal)).ToList();
if (unseen.Count == 0)
{
run.Cycle++;
run.SeenObjectiveIds.Clear();
unseen = byObjective;
}
var mastery = await _store.MasteryAsync(userId);
var dueReviews = await _store.DueReviewsAsync(userId, DateTime.UtcNow);
var dueByObjective = dueReviews.ToDictionary(value => value.ObjectiveId, StringComparer.Ordinal);
var candidates = unseen
.Where(group => group.Key != run.PreviousObjectiveId || unseen.Count == 1)
.ToList();
var roll = RandomNumberGenerator.GetInt32(100);
IGrouping<string, DynamicQuestionBlueprint> selectedObjective;
var dueCandidates = candidates.Where(value => dueByObjective.ContainsKey(value.Key)).ToList();
if (roll < 50 && dueCandidates.Count == 0 && dueByObjective.Count > 0)
dueCandidates = byObjective
.Where(value => dueByObjective.ContainsKey(value.Key)
&& (value.Key != run.PreviousObjectiveId || dueByObjective.Count == 1))
.ToList();
if (roll < 50 && dueCandidates.Count > 0)
{
selectedObjective = dueCandidates
.OrderBy(value => dueByObjective[value.Key].DueUtc)
.ThenBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue))
.First();
}
else if (roll < 80)
{
var systemAttempts = byObjective
.GroupBy(group => group.OrderByDescending(value => value.Version).First().System)
.ToDictionary(
group => group.Key,
group => group.Sum(objective => mastery.TryGetValue(objective.Key, out var value) ? value.Attempts : 0),
StringComparer.Ordinal);
selectedObjective = candidates
.OrderBy(group =>
{
var latest = group.OrderByDescending(value => value.Version).First();
var weight = DynamicBlueprintCoveragePolicy.Weight(latest);
return (systemAttempts.GetValueOrDefault(latest.System) + 1) / weight;
})
.ThenBy(_ => RandomNumberGenerator.GetInt32(int.MaxValue))
.First();
}
else
{
var weakestBand = candidates
.OrderBy(group => mastery.TryGetValue(group.Key, out var value) ? value.Elo : 1000)
.Take(Math.Min(4, candidates.Count))
.ToList();
selectedObjective = weakestBand[RandomNumberGenerator.GetInt32(weakestBand.Count)];
}
var blueprint = selectedObjective
.OrderByDescending(value => value.Version)
.First();
var ratingProfile = await _ratings.GetProfileAsync(userId);
mastery.TryGetValue(blueprint.ObjectiveId, out var objectiveMastery);
var category = RatingCategory.Normalize(
blueprint.PerformanceCategory, blueprint.System, blueprint.Discipline);
var categoryElo = ratingProfile.CategoryElos.GetValueOrDefault(category, ratingProfile.OverallElo);
var effectiveAbility = DynamicDifficultyPolicy.EffectiveAbility(
ratingProfile.OverallElo, categoryElo, objectiveMastery);
var targetElo = DynamicDifficultyPolicy.TargetFamilyElo(effectiveAbility);
var familyRatings = await _store.FamilyRatingsAsync(blueprint);
var blueprintFamilyIds = blueprint.CaseFamilies.Select(value => value.Id).ToHashSet(StringComparer.Ordinal);
var recentForObjective = run.RecentCaseFamilyIds
.Where(blueprintFamilyIds.Contains)
.TakeLast(Math.Max(1, blueprint.CaseFamilies.Count / 4))
.ToHashSet(StringComparer.Ordinal);
var selectedFamily = DynamicDifficultyPolicy.SelectFamily(
blueprint.CaseFamilies,
familyRatings,
targetElo,
recentForObjective,
RandomNumberGenerator.GetInt32(int.MaxValue));
var selectedFamilyElo = familyRatings.TryGetValue(selectedFamily.Id, out var selectedRating)
? selectedRating.Elo
: selectedFamily.NominalElo;
_metrics.DynamicSelected(selectedFamily.DifficultyTier, selectedFamilyElo - targetElo);
var claims = new DynamicGenerationClaims
{
UserId = userId,
BlueprintExternalId = blueprint.ExternalId,
BlueprintVersion = blueprint.Version,
ObjectiveId = blueprint.ObjectiveId,
Seed = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
Nonce = Guid.NewGuid().ToString("N"),
GeneratorVersion = DynamicQuestionGenerator.CurrentVersion,
CaseFamilyId = selectedFamily.Id,
DifficultyTier = selectedFamily.DifficultyTier,
EffectiveAbilityElo = effectiveAbility,
FamilyElo = selectedFamilyElo,
IsScheduledReview = dueByObjective.TryGetValue(blueprint.ObjectiveId, out var reviewItem),
ReviewItemId = reviewItem?.Id ?? "",
ReviewStage = reviewItem?.Stage ?? 0,
ExpiresUtc = DateTime.UtcNow.AddDays(7)
};
var generated = _generator.Generate(
blueprint, claims.Seed, claims.Nonce, claims.GeneratorVersion, claims.CaseFamilyId);
if (!run.SeenObjectiveIds.Contains(blueprint.ObjectiveId, StringComparer.Ordinal))
run.SeenObjectiveIds.Add(blueprint.ObjectiveId);
run.PreviousObjectiveId = blueprint.ObjectiveId;
run.RecentCaseFamilyIds.Add(generated.CaseFamilyId);
run.RecentCaseFamilyIds = run.RecentCaseFamilyIds.TakeLast(30).ToList();
run.ExpiresUtc = DateTime.UtcNow.AddDays(7);
return new DynamicQuestionEnvelope
{
Question = QuestionResponse.FromModel(generated.Question, questionElo: selectedFamilyElo),
ObjectiveId = blueprint.ObjectiveId,
ObjectiveTitle = blueprint.Title,
GenerationToken = _tokens.SignGeneration(claims),
RunToken = _tokens.SignRun(run),
CycleSeen = run.SeenObjectiveIds.Count,
TotalObjectives = byObjective.Count
};
}
public async Task<DynamicAttemptResponse> AttemptAsync(string userId, DynamicAttemptRequest request)
{
var (claims, generated) = await RegenerateAsync(userId, request.GenerationToken);
return await _store.RecordAsync(claims, generated, request);
}
public async Task<(DynamicGenerationClaims Claims, DynamicGeneratedQuestion Generated)> RegenerateAsync(
string userId,
string generationToken)
{
var claims = _tokens.ReadGeneration(generationToken, userId);
if (claims.GeneratorVersion is < 1 or > DynamicQuestionGenerator.CurrentVersion)
throw new InvalidOperationException("This generated question version is no longer supported.");
var blueprint = await _store.GetAsync(claims.BlueprintExternalId, claims.BlueprintVersion)
?? throw new KeyNotFoundException("The question blueprint is no longer available.");
var generated = _generator.Generate(
blueprint, claims.Seed, claims.Nonce, claims.GeneratorVersion, claims.CaseFamilyId);
if (!string.IsNullOrWhiteSpace(claims.CaseFamilyId)
&& claims.CaseFamilyId != generated.CaseFamilyId)
throw new InvalidOperationException("Generated case family does not match its signed descriptor.");
return (claims, generated);
}
private static DynamicRunClaims NewRun(string userId) => new()
{
UserId = userId,
ExpiresUtc = DateTime.UtcNow.AddDays(7)
};
}
@@ -0,0 +1,364 @@
using MongoDB.Driver;
using MongoDB.Bson;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class DynamicQuestionStore
{
private readonly IMongoCollection<DynamicQuestionBlueprint> _blueprints;
private readonly IMongoCollection<DynamicQuestionAttempt> _attempts;
private readonly IMongoCollection<DynamicObjectiveMastery> _mastery;
private readonly IMongoCollection<DynamicFamilyRating> _familyRatings;
private readonly IMongoCollection<DynamicReviewItem> _reviews;
private readonly ServiceMetrics? _metrics;
public DynamicQuestionStore(MongoDatabaseProvider mongo, ServiceMetrics? metrics = null)
{
_metrics = metrics;
_blueprints = mongo.Database.GetCollection<DynamicQuestionBlueprint>("DynamicQuestionBlueprints");
_attempts = mongo.Database.GetCollection<DynamicQuestionAttempt>("DynamicQuestionAttempts");
_mastery = mongo.Database.GetCollection<DynamicObjectiveMastery>("DynamicObjectiveMastery");
_familyRatings = mongo.Database.GetCollection<DynamicFamilyRating>("DynamicFamilyRatings");
_reviews = mongo.Database.GetCollection<DynamicReviewItem>("DynamicReviewItems");
_blueprints.Indexes.CreateOne(new CreateIndexModel<DynamicQuestionBlueprint>(
Builders<DynamicQuestionBlueprint>.IndexKeys.Ascending(value => value.ExternalId).Ascending(value => value.Version),
new CreateIndexOptions { Unique = true }));
_attempts.Indexes.CreateOne(new CreateIndexModel<DynamicQuestionAttempt>(
Builders<DynamicQuestionAttempt>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.Nonce),
new CreateIndexOptions { Unique = true }));
_mastery.Indexes.CreateOne(new CreateIndexModel<DynamicObjectiveMastery>(
Builders<DynamicObjectiveMastery>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.ObjectiveId),
new CreateIndexOptions { Unique = true }));
_familyRatings.Indexes.CreateOne(new CreateIndexModel<DynamicFamilyRating>(
Builders<DynamicFamilyRating>.IndexKeys
.Ascending(value => value.BlueprintExternalId)
.Ascending(value => value.BlueprintVersion)
.Ascending(value => value.CaseFamilyId),
new CreateIndexOptions { Unique = true }));
_reviews.Indexes.CreateMany([
new CreateIndexModel<DynamicReviewItem>(
Builders<DynamicReviewItem>.IndexKeys
.Ascending(value => value.UserId)
.Ascending(value => value.ObjectiveId),
new CreateIndexOptions { Unique = true, Name = "dynamic_review_user_objective" }),
new CreateIndexModel<DynamicReviewItem>(
Builders<DynamicReviewItem>.IndexKeys
.Ascending(value => value.UserId)
.Ascending(value => value.State)
.Ascending(value => value.DueUtc),
new CreateIndexOptions { Name = "dynamic_review_user_due" })
]);
}
public async Task<List<DynamicQuestionBlueprint>> PublishedAsync() =>
await _blueprints.Find(value => value.IsPublished && value.QualityStandardVersion >= 2).ToListAsync();
public async Task<DynamicQuestionBlueprint?> GetAsync(string externalId, int version) =>
await _blueprints.Find(value => value.ExternalId == externalId && value.Version == version).FirstOrDefaultAsync();
public async Task<Dictionary<string, DynamicObjectiveMastery>> MasteryAsync(string userId) =>
(await _mastery.Find(value => value.UserId == userId).ToListAsync())
.ToDictionary(value => value.ObjectiveId, StringComparer.Ordinal);
public async Task<List<DynamicReviewItem>> DueReviewsAsync(string userId, DateTime nowUtc) =>
await _reviews.Find(value => value.UserId == userId
&& value.State == DynamicReviewStates.Pending
&& value.DueUtc <= nowUtc).ToListAsync();
public async Task<Dictionary<string, DynamicFamilyRating>> FamilyRatingsAsync(DynamicQuestionBlueprint blueprint)
{
var ratings = await _familyRatings.Find(value =>
value.BlueprintExternalId == blueprint.ExternalId
&& value.BlueprintVersion == blueprint.Version).ToListAsync();
return ratings.ToDictionary(value => value.CaseFamilyId, StringComparer.Ordinal);
}
public async Task<DynamicAttemptResponse> RecordAsync(
DynamicGenerationClaims claims,
DynamicGeneratedQuestion generated,
DynamicAttemptRequest request)
{
var isCorrect = !request.IsSkip && request.ChoiceId == generated.Question.CorrectChoiceId;
var selectedAnswer = generated.Blueprint.Distractors
.Concat(generated.Blueprint.CaseFamilies
.Where(value => value.Id == claims.CaseFamilyId)
.SelectMany(value => value.Distractors))
.FirstOrDefault(value => value.Id == request.ChoiceId);
var misconceptionId = isCorrect || request.IsSkip
? ""
: selectedAnswer?.MisconceptionId ?? "";
var attempt = new DynamicQuestionAttempt
{
UserId = claims.UserId,
Nonce = claims.Nonce,
BlueprintExternalId = claims.BlueprintExternalId,
BlueprintVersion = claims.BlueprintVersion,
ObjectiveId = claims.ObjectiveId,
CaseFamilyId = claims.CaseFamilyId,
DifficultyTier = claims.DifficultyTier,
EffectiveAbilityElo = claims.EffectiveAbilityElo,
FamilyElo = claims.FamilyElo,
ChoiceId = request.ChoiceId,
IsCorrect = isCorrect,
IsSkip = request.IsSkip,
SelectedMisconceptionId = misconceptionId,
IsScheduledReview = claims.IsScheduledReview,
DurationSeconds = Math.Clamp(request.DurationSeconds, 0, 86_400),
CreatedUtc = DateTime.UtcNow
};
try { await _attempts.InsertOneAsync(attempt); }
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
var existingMastery = await _mastery.Find(value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId).FirstOrDefaultAsync();
return new DynamicAttemptResponse
{
Inserted = false,
IsCorrect = isCorrect,
CorrectChoiceId = generated.Question.CorrectChoiceId,
ObjectiveId = claims.ObjectiveId,
ObjectiveElo = existingMastery?.Elo ?? 1000
};
}
_metrics?.DynamicAttempt(claims.DifficultyTier, isCorrect);
await EnsureMasteryAsync(claims.UserId, claims.ObjectiveId);
var current = await _mastery.Find(value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId).FirstAsync()
?? throw new InvalidOperationException("Dynamic objective mastery could not be initialized.");
var oldElo = current.Elo;
var familyElo = claims.FamilyElo > 0
? claims.FamilyElo
: 800 + Math.Clamp(generated.Blueprint.Difficulty, 1, 5) * 100;
var delta = DynamicDifficultyPolicy.LearnerDelta(oldElo, familyElo, isCorrect, current.Attempts);
var now = DateTime.UtcNow;
var update = Builders<DynamicObjectiveMastery>.Update
.Inc(value => value.Elo, delta)
.Inc(value => value.Attempts, 1)
.Inc(value => value.Correct, isCorrect ? 1 : 0)
.Set(value => value.LastSeenUtc, now);
var updated = await _mastery.FindOneAndUpdateAsync(
value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId,
update,
new FindOneAndUpdateOptions<DynamicObjectiveMastery> { ReturnDocument = ReturnDocument.After });
await _mastery.UpdateOneAsync(
value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId,
Builders<DynamicObjectiveMastery>.Update
.Max(value => value.Elo, DynamicDifficultyPolicy.MinimumElo));
await _mastery.UpdateOneAsync(
value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId,
Builders<DynamicObjectiveMastery>.Update
.Min(value => value.Elo, DynamicDifficultyPolicy.MaximumElo));
if (!string.IsNullOrWhiteSpace(claims.CaseFamilyId))
await UpdateFamilyRatingAsync(claims, generated, isCorrect);
DateTime? nextReviewUtc = null;
if (!request.IsSkip)
nextReviewUtc = await UpdateReviewAsync(claims, misconceptionId, isCorrect);
return new DynamicAttemptResponse
{
Inserted = true,
IsCorrect = isCorrect,
CorrectChoiceId = generated.Question.CorrectChoiceId,
ObjectiveId = claims.ObjectiveId,
ObjectiveElo = DynamicDifficultyPolicy.Clamp(updated.Elo),
RatingChange = delta,
SelectedMisconceptionId = misconceptionId,
IsScheduledReview = claims.IsScheduledReview,
NextReviewUtc = nextReviewUtc
};
}
private async Task<DateTime?> UpdateReviewAsync(
DynamicGenerationClaims claims,
string misconceptionId,
bool isCorrect)
{
var now = DateTime.UtcNow;
var existing = await _reviews.Find(value =>
value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId).FirstOrDefaultAsync();
if (!claims.IsScheduledReview)
{
if (isCorrect) return existing?.DueUtc;
var due = DynamicReviewPolicy.FirstDue(now);
var update = Builders<DynamicReviewItem>.Update
.SetOnInsert(value => value.UserId, claims.UserId)
.SetOnInsert(value => value.ObjectiveId, claims.ObjectiveId)
.SetOnInsert(value => value.CreatedUtc, now)
.Set(value => value.BlueprintExternalId, claims.BlueprintExternalId)
.Set(value => value.BlueprintVersion, claims.BlueprintVersion)
.Set(value => value.MisconceptionId, misconceptionId)
.Set(value => value.State, DynamicReviewStates.Pending)
.Set(value => value.Stage, 0)
.Set(value => value.DueUtc, due)
.Set(value => value.UpdatedUtc, now);
await _reviews.UpdateOneAsync(
value => value.UserId == claims.UserId && value.ObjectiveId == claims.ObjectiveId,
update,
new UpdateOptions { IsUpsert = true });
return due;
}
if (existing is null || existing.Id != claims.ReviewItemId || existing.Stage != claims.ReviewStage)
return null;
var next = DynamicReviewPolicy.AfterReview(existing.Stage, isCorrect, now);
var reviewUpdate = Builders<DynamicReviewItem>.Update
.Set(value => value.Stage, next.Stage)
.Set(value => value.State, next.Mastered ? DynamicReviewStates.Mastered : DynamicReviewStates.Pending)
.Set(value => value.MisconceptionId, isCorrect ? existing.MisconceptionId : misconceptionId)
.Set(value => value.UpdatedUtc, now)
.Inc(value => value.ReviewCount, 1);
if (next.DueUtc.HasValue)
reviewUpdate = reviewUpdate.Set(value => value.DueUtc, next.DueUtc.Value);
await _reviews.UpdateOneAsync(
value => value.Id == existing.Id && value.Stage == claims.ReviewStage,
reviewUpdate);
return next.DueUtc;
}
private async Task EnsureMasteryAsync(string userId, string objectiveId)
{
try
{
await _mastery.UpdateOneAsync(
value => value.UserId == userId && value.ObjectiveId == objectiveId,
Builders<DynamicObjectiveMastery>.Update
.SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.ObjectiveId, objectiveId)
.SetOnInsert(value => value.Elo, 1000)
.SetOnInsert(value => value.Attempts, 0)
.SetOnInsert(value => value.Correct, 0),
new UpdateOptions { IsUpsert = true });
}
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { }
}
private async Task UpdateFamilyRatingAsync(
DynamicGenerationClaims claims,
DynamicGeneratedQuestion generated,
bool isCorrect)
{
var family = generated.Blueprint.CaseFamilies.First(value => value.Id == claims.CaseFamilyId);
try
{
await _familyRatings.UpdateOneAsync(
value => value.BlueprintExternalId == claims.BlueprintExternalId
&& value.BlueprintVersion == claims.BlueprintVersion
&& value.CaseFamilyId == claims.CaseFamilyId,
Builders<DynamicFamilyRating>.Update
.SetOnInsert(value => value.BlueprintExternalId, claims.BlueprintExternalId)
.SetOnInsert(value => value.BlueprintVersion, claims.BlueprintVersion)
.SetOnInsert(value => value.CaseFamilyId, claims.CaseFamilyId)
.SetOnInsert(value => value.DifficultyTier, family.DifficultyTier)
.SetOnInsert(value => value.NominalElo, family.NominalElo)
.SetOnInsert(value => value.Elo, family.NominalElo)
.SetOnInsert(value => value.Attempts, 0)
.SetOnInsert(value => value.Correct, 0),
new UpdateOptions { IsUpsert = true });
}
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { }
var current = await _familyRatings.Find(value =>
value.BlueprintExternalId == claims.BlueprintExternalId
&& value.BlueprintVersion == claims.BlueprintVersion
&& value.CaseFamilyId == claims.CaseFamilyId).FirstAsync();
var delta = DynamicDifficultyPolicy.FamilyDelta(
claims.EffectiveAbilityElo > 0 ? claims.EffectiveAbilityElo : 1000,
claims.FamilyElo > 0 ? claims.FamilyElo : current.Elo,
isCorrect,
current.Attempts);
await _familyRatings.UpdateOneAsync(
value => value.Id == current.Id,
Builders<DynamicFamilyRating>.Update
.Inc(value => value.Elo, delta)
.Inc(value => value.Attempts, 1)
.Inc(value => value.Correct, isCorrect ? 1 : 0)
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
await _familyRatings.UpdateOneAsync(
value => value.Id == current.Id,
Builders<DynamicFamilyRating>.Update
.Max(value => value.Elo, DynamicDifficultyPolicy.MinimumElo));
await _familyRatings.UpdateOneAsync(
value => value.Id == current.Id,
Builders<DynamicFamilyRating>.Update
.Min(value => value.Elo, DynamicDifficultyPolicy.MaximumElo));
}
public async Task<DynamicQuestionStatusResponse> StatusAsync(string userId)
{
var blueprints = (await PublishedAsync())
.GroupBy(value => value.ObjectiveId)
.Select(value => value.OrderByDescending(item => item.Version).First())
.ToList();
var objectiveCount = blueprints.Count;
var mastery = await _mastery.Find(value => value.UserId == userId).ToListAsync();
var masteryByObjective = mastery.ToDictionary(value => value.ObjectiveId, StringComparer.Ordinal);
var due = await _reviews.CountDocumentsAsync(value => value.UserId == userId
&& value.State == DynamicReviewStates.Pending
&& value.DueUtc <= DateTime.UtcNow);
return new DynamicQuestionStatusResponse
{
Available = objectiveCount > 0,
PublishedObjectives = objectiveCount,
AttemptedObjectives = mastery.Count,
TotalAttempts = mastery.Sum(value => value.Attempts),
AverageObjectiveElo = mastery.Count == 0 ? 1000 : (int)Math.Round(mastery.Average(value => value.Elo)),
ReviewsDue = checked((int)due),
SystemCoverage = Coverage(blueprints, masteryByObjective, value => value.System),
PhysicianTaskCoverage = Coverage(blueprints, masteryByObjective, value => value.PhysicianTask)
};
}
private static List<DynamicCoverageSummary> Coverage(
IReadOnlyCollection<DynamicQuestionBlueprint> blueprints,
IReadOnlyDictionary<string, DynamicObjectiveMastery> mastery,
Func<DynamicQuestionBlueprint, string> keySelector) =>
blueprints
.GroupBy(value => string.IsNullOrWhiteSpace(keySelector(value)) ? "Unclassified" : keySelector(value))
.Select(group =>
{
var attempts = group
.Where(value => mastery.ContainsKey(value.ObjectiveId))
.Select(value => mastery[value.ObjectiveId])
.ToList();
return new DynamicCoverageSummary
{
Id = group.Key,
PublishedObjectives = group.Count(),
AttemptedObjectives = attempts.Count,
Attempts = attempts.Sum(value => value.Attempts),
CoveragePercent = group.Count() == 0 ? 0 : Math.Round(attempts.Count * 100.0 / group.Count(), 1),
AverageElo = attempts.Count == 0 ? 1000 : (int)Math.Round(attempts.Average(value => value.Elo))
};
})
.OrderBy(value => value.CoveragePercent)
.ThenBy(value => value.Id)
.ToList();
public async Task<(int Inserted, int Unchanged)> UpsertBlueprintsAsync(IEnumerable<DynamicQuestionBlueprint> blueprints)
{
var inserted = 0;
var unchanged = 0;
foreach (var blueprint in blueprints)
{
var existing = await _blueprints.Find(
value => value.ExternalId == blueprint.ExternalId && value.Version == blueprint.Version)
.FirstOrDefaultAsync();
if (existing is not null)
{
blueprint.Id = existing.Id;
var incomingDocument = blueprint.ToBsonDocument();
var existingDocument = existing.ToBsonDocument();
incomingDocument.Remove("_id");
existingDocument.Remove("_id");
if (incomingDocument != existingDocument)
throw new InvalidDataException(
$"{blueprint.ExternalId} v{blueprint.Version} is immutable; publish changes with a new version");
unchanged++;
continue;
}
blueprint.Id = ObjectId.GenerateNewId().ToString();
await _blueprints.InsertOneAsync(blueprint);
inserted++;
}
return (inserted, unchanged);
}
}
@@ -0,0 +1,67 @@
using QuestionsApi.Models;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace QuestionsApi.Services;
public sealed class DynamicQuestionTokenService
{
private readonly byte[] _key;
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public DynamicQuestionTokenService(IConfiguration configuration)
{
var key = configuration["DynamicQuestions:SigningKey"]
?? configuration["Jwt:Key"]
?? "development-dynamic-question-signing-key-change-me";
_key = SHA256.HashData(Encoding.UTF8.GetBytes(key));
}
public string SignGeneration(DynamicGenerationClaims claims) => Sign(claims);
public string SignRun(DynamicRunClaims claims) => Sign(claims);
public DynamicGenerationClaims ReadGeneration(string token, string userId) => Read<DynamicGenerationClaims>(token, userId);
public DynamicRunClaims ReadRun(string token, string userId) => Read<DynamicRunClaims>(token, userId);
private string Sign<T>(T value)
{
var payload = Base64Url(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value, Json)));
using var hmac = new HMACSHA256(_key);
return $"{payload}.{Base64Url(hmac.ComputeHash(Encoding.UTF8.GetBytes(payload)))}";
}
private T Read<T>(string token, string userId) where T : class
{
var parts = (token ?? "").Split('.');
if (parts.Length != 2) throw new ArgumentException("token is invalid");
using var hmac = new HMACSHA256(_key);
var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes(parts[0]));
var supplied = FromBase64Url(parts[1]);
if (!CryptographicOperations.FixedTimeEquals(expected, supplied)) throw new ArgumentException("token is invalid");
var value = JsonSerializer.Deserialize<T>(FromBase64Url(parts[0]), Json)
?? throw new ArgumentException("token is invalid");
var tokenUser = value switch
{
DynamicGenerationClaims generation => generation.UserId,
DynamicRunClaims run => run.UserId,
_ => ""
};
var expires = value switch
{
DynamicGenerationClaims generation => generation.ExpiresUtc,
DynamicRunClaims run => run.ExpiresUtc,
_ => DateTime.MinValue
};
if (!string.Equals(tokenUser, userId, StringComparison.Ordinal) || expires < DateTime.UtcNow)
throw new ArgumentException("token is expired or belongs to another user");
return value;
}
private static string Base64Url(byte[] value) => Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static byte[] FromBase64Url(string value)
{
value = value.Replace('-', '+').Replace('_', '/');
value += new string('=', (4 - value.Length % 4) % 4);
return Convert.FromBase64String(value);
}
}
@@ -0,0 +1,75 @@
using Microsoft.Extensions.Configuration;
using QuestionsApi.Models;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class DynamicReviewExportCommand
{
public static async Task<int> RunAsync(string[] args, TextWriter output)
{
static string Argument(string[] values, string name, string fallback = "")
{
var index = Array.FindIndex(values, value => value.Equals(name, StringComparison.OrdinalIgnoreCase));
return index >= 0 && index + 1 < values.Length ? values[index + 1] : fallback;
}
var source = Argument(args, "--source", Path.Combine(AppContext.BaseDirectory, "content", "dynamic_questions"));
var destination = Argument(args, "--output");
if (!Directory.Exists(source) || string.IsNullOrWhiteSpace(destination))
{
await output.WriteLineAsync("Usage: export-dynamic-review --source <blueprints> --output <directory>");
return 2;
}
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{ PropertyNameCaseInsensitive = true, WriteIndented = true };
var blueprints = new List<DynamicQuestionBlueprint>();
foreach (var file in Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories))
blueprints.AddRange(JsonSerializer.Deserialize<List<DynamicQuestionBlueprint>>(
await File.ReadAllTextAsync(file), jsonOptions) ?? []);
blueprints = DynamicBlueprintResolver.ResolveAll(blueprints);
blueprints = blueprints.Where(value => value.IsPublished && value.QualityStandardVersion >= 2).ToList();
var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
var authored = (await new QuestionStore(new MongoDatabaseProvider(configuration)).GetPublishedAsync())
.OrderBy(value => value.ExternalId, StringComparer.Ordinal)
.Take(blueprints.Count * 3)
.ToList();
if (authored.Count < blueprints.Count * 3)
throw new InvalidOperationException("Not enough published authored questions for a balanced review packet.");
var generator = new DynamicQuestionGenerator();
var entries = new List<(Question Question, string Source, string Detail)>();
foreach (var blueprint in blueprints)
for (var seed = 0; seed < 3; seed++)
{
var generated = generator.Generate(blueprint, $"review-{seed}", $"review-{blueprint.ExternalId}-{seed}");
entries.Add((generated.Question, "dynamic", $"{blueprint.ExternalId}:{generated.CaseFamilyId}"));
}
entries.AddRange(authored.Select(value => (value, "authored", value.ExternalId)));
var ordered = entries.OrderBy(value => Convert.ToHexString(
System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value.Detail)))).ToList();
Directory.CreateDirectory(destination);
var packet = ordered.Select((entry, index) => new
{
reviewId = $"Q{index + 1:D2}",
entry.Question.Stem,
entry.Question.LeadIn,
entry.Question.Choices,
entry.Question.Labs,
entry.Question.Explanation,
entry.Question.ChoiceExplanations,
entry.Question.ReviewFollowups,
entry.Question.WrongAnswerFollowups,
scoring = new { realism = 0, distractors = 0, explanation = 0, step3Relevance = 0, criticalIssue = "", predictedSource = "" }
});
var key = ordered.Select((entry, index) => new { reviewId = $"Q{index + 1:D2}", entry.Source, entry.Detail });
await File.WriteAllTextAsync(Path.Combine(destination, "review-packet.json"), JsonSerializer.Serialize(packet, jsonOptions));
await File.WriteAllTextAsync(Path.Combine(destination, "answer-key.json"), JsonSerializer.Serialize(key, jsonOptions));
await output.WriteLineAsync($"Exported {ordered.Count} blinded questions to {destination}.");
return 0;
}
}
@@ -0,0 +1,22 @@
namespace QuestionsApi.Services;
public static class DynamicReviewPolicy
{
private static readonly int[] IntervalsDays = [1, 3, 7, 21, 45];
public static DateTime FirstDue(DateTime nowUtc) => nowUtc.AddDays(IntervalsDays[0]);
public static (int Stage, DateTime? DueUtc, bool Mastered) AfterReview(
int currentStage,
bool isCorrect,
DateTime nowUtc)
{
if (!isCorrect)
return (0, FirstDue(nowUtc), false);
var nextStage = Math.Clamp(currentStage + 1, 1, IntervalsDays.Length);
if (nextStage >= IntervalsDays.Length)
return (nextStage, null, true);
return (nextStage, nowUtc.AddDays(IntervalsDays[nextStage]), false);
}
}
@@ -454,8 +454,8 @@ public class ExamSimulationStore
try
{
shouldRate = await _attempts.TryAcquireRatingLeaseAsync(attemptId, owner, TimeSpan.FromSeconds(30));
await _ratings.ApplyFirstResultAsync(simulation.UserId, question, record.Attempt.IsCorrect, shouldRate);
if (shouldRate) await _attempts.MarkRatingAppliedAsync(attemptId, owner);
var rating = await _ratings.ApplyFirstResultAsync(simulation.UserId, question, record.Attempt.IsCorrect, shouldRate);
if (shouldRate) await _attempts.MarkRatingAppliedAsync(attemptId, owner, rating.UserOverallElo);
}
catch
{
@@ -0,0 +1,21 @@
namespace QuestionsApi.Services;
// One-time sweep: flags every question that currently has at least one image so it can be
// pulled from student rotation for review, without touching IsPublished. Idempotent (only
// touches questions not already flagged, via QuestionStore.FlagAllWithMediaAsync's filter) -
// safe to re-run.
public static class FlagMediaQuestionsCommand
{
private const string Reason = "Bulk-flagged for image review";
private const string FlaggedBy = "system";
public static async Task<int> RunAsync(IConfiguration configuration, TextWriter output)
{
var mongo = new MongoDatabaseProvider(configuration);
var questions = new QuestionStore(mongo);
var flagged = await questions.FlagAllWithMediaAsync(FlaggedBy, Reason);
await output.WriteLineAsync($"Flagged {flagged} question(s) with images for review.");
return 0;
}
}
@@ -0,0 +1,57 @@
using System.Text.RegularExpressions;
namespace QuestionsApi.Services.Flashcards;
public sealed record ClozeDeletion(int Number, string Answer, string? Hint);
// {{c1::answer}} or {{c1::answer::hint}}. Numbers need not be contiguous or start at 1;
// each distinct number becomes its own independently scheduled card (a "sibling").
public static partial class FlashcardClozeParser
{
[GeneratedRegex(@"\{\{c(\d+)::((?:(?!\}\}).)*)\}\}", RegexOptions.Singleline)]
private static partial Regex ClozeRegex();
public static List<ClozeDeletion> Parse(string text)
{
if (string.IsNullOrEmpty(text)) return [];
var results = new List<ClozeDeletion>();
foreach (Match match in ClozeRegex().Matches(text))
{
if (!int.TryParse(match.Groups[1].Value, out var number) || number < 1)
continue;
var inner = match.Groups[2].Value;
var parts = inner.Split("::", 2);
var answer = parts[0].Trim();
var hint = parts.Length > 1 ? parts[1].Trim() : null;
if (answer.Length == 0) continue;
results.Add(new ClozeDeletion(number, answer, string.IsNullOrEmpty(hint) ? null : hint));
}
return results;
}
public static List<int> DistinctClozeNumbers(string text) =>
Parse(text).Select(v => v.Number).Distinct().OrderBy(v => v).ToList();
public static bool HasValidCloze(string text) => DistinctClozeNumbers(text).Count > 0;
// Renders the front for a given cloze number: that number's answer is hidden (replaced
// by its hint or "..."), all other cloze numbers are revealed as their answer text.
public static string RenderFront(string text, int clozeNumber) =>
ClozeRegex().Replace(text, match =>
{
if (!int.TryParse(match.Groups[1].Value, out var number)) return match.Value;
var inner = match.Groups[2].Value;
var parts = inner.Split("::", 2);
if (number != clozeNumber) return parts[0].Trim();
var hint = parts.Length > 1 ? parts[1].Trim() : "";
return hint.Length > 0 ? $"[{hint}]" : "[...]";
});
public static string RenderBack(string text, int clozeNumber) =>
ClozeRegex().Replace(text, match =>
{
var inner = match.Groups[2].Value;
var parts = inner.Split("::", 2);
return parts[0].Trim();
});
}
@@ -0,0 +1,64 @@
using System.Text;
namespace QuestionsApi.Services.Flashcards;
// Minimal RFC 4180 CSV reader/writer (quoted fields, embedded commas/newlines, "" escaping).
// UTF-8, text-only, matches the flashcard CSV contract exactly.
public static class FlashcardCsv
{
public static readonly string[] Headers =
["stable_id", "deck", "type", "prompt", "answer", "extra", "tags", "source_question_external_id"];
public static List<string[]> ParseRows(string text)
{
var rows = new List<string[]>();
var field = new StringBuilder();
var row = new List<string>();
var inQuotes = false;
var i = 0;
var n = text.Length;
while (i < n)
{
var c = text[i];
if (inQuotes)
{
if (c == '"')
{
if (i + 1 < n && text[i + 1] == '"') { field.Append('"'); i += 2; continue; }
inQuotes = false; i++; continue;
}
field.Append(c); i++; continue;
}
switch (c)
{
case '"':
inQuotes = true; i++; break;
case ',':
row.Add(field.ToString()); field.Clear(); i++; break;
case '\r':
i++; break;
case '\n':
row.Add(field.ToString()); field.Clear();
rows.Add(row.ToArray()); row = [];
i++; break;
default:
field.Append(c); i++; break;
}
}
if (field.Length > 0 || row.Count > 0)
{
row.Add(field.ToString());
rows.Add(row.ToArray());
}
return rows;
}
public static string WriteRow(IEnumerable<string> fields) => string.Join(",", fields.Select(Escape));
private static string Escape(string field) =>
field.IndexOfAny(['"', ',', '\n', '\r']) >= 0
? "\"" + field.Replace("\"", "\"\"") + "\""
: field;
}
@@ -0,0 +1,143 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public sealed record FlashcardDeckUpdateResult(FlashcardDeck? Deck, FlashcardDeck? Current)
{
public bool Succeeded => Deck is not null;
}
public sealed class FlashcardDeckStore
{
private readonly IMongoCollection<FlashcardDeck> _decks;
public FlashcardDeckStore(MongoDatabaseProvider mongo)
{
_decks = mongo.Database.GetCollection<FlashcardDeck>("FlashcardDecks");
_decks.Indexes.CreateMany([
new CreateIndexModel<FlashcardDeck>(
Builders<FlashcardDeck>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.NormalizedName),
new CreateIndexOptions { Unique = true, Name = "flashcard_deck_unique_user_name" }),
new CreateIndexModel<FlashcardDeck>(
Builders<FlashcardDeck>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.StableId),
new CreateIndexOptions { Unique = true, Name = "flashcard_deck_unique_user_stable" })
]);
}
public static string Normalize(string name) => name.Trim().ToLowerInvariant();
public Task<List<FlashcardDeck>> ListAsync(string userId) =>
_decks.Find(v => v.UserId == userId && v.ArchivedUtc == null).SortBy(v => v.Name).ToListAsync();
public async Task<FlashcardDeck?> GetAsync(string userId, string deckId) =>
await _decks.Find(v => v.UserId == userId && v.Id == deckId).FirstOrDefaultAsync();
// Read-only: the caller's own decks plus every system (shared, admin-curated) deck. Never
// used to back a write path - mutations must stay strictly own-user via ListAsync/GetAsync.
public Task<List<FlashcardDeck>> ListVisibleAsync(string userId) =>
_decks.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.ArchivedUtc == null)
.SortBy(v => v.Name).ToListAsync();
public async Task<FlashcardDeck?> GetVisibleAsync(string userId, string deckId) =>
await _decks.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.Id == deckId)
.FirstOrDefaultAsync();
// No owner filter at all - used only by the controller's mutation guard to check IsSystem
// regardless of who's asking.
public async Task<FlashcardDeck?> GetAnyAsync(string deckId) =>
await _decks.Find(v => v.Id == deckId).FirstOrDefaultAsync();
public async Task<FlashcardDeck> GetOrCreateDefaultDeckAsync(string userId)
{
var existing = await _decks.Find(v => v.UserId == userId && v.IsDefault).FirstOrDefaultAsync();
if (existing is not null) return existing;
var deck = new FlashcardDeck
{
UserId = userId,
Name = "Default",
NormalizedName = Normalize("Default"),
IsDefault = true
};
try
{
await _decks.InsertOneAsync(deck);
return deck;
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return await _decks.Find(v => v.UserId == userId && v.IsDefault).FirstOrDefaultAsync()
?? throw new InvalidOperationException("default deck race with no winner");
}
}
public async Task<FlashcardDeckUpdateResult> UpsertAsync(string userId, UpsertFlashcardDeckRequest request, string? deckId)
{
var now = DateTime.UtcNow;
var normalized = Normalize(request.Name);
if (string.IsNullOrWhiteSpace(deckId))
{
var created = new FlashcardDeck
{
UserId = userId,
Name = request.Name.Trim(),
NormalizedName = normalized,
Description = request.Description.Trim(),
NewCardsPerDay = request.NewCardsPerDay,
ReviewsPerDay = request.ReviewsPerDay,
DesiredRetention = request.DesiredRetention,
CreatedUtc = now,
UpdatedUtc = now
};
try
{
await _decks.InsertOneAsync(created);
return new FlashcardDeckUpdateResult(created, null);
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return new FlashcardDeckUpdateResult(null, await GetByNameAsync(userId, normalized));
}
}
var expectedRevision = request.ExpectedRevision ?? 0;
var updated = await _decks.FindOneAndUpdateAsync(
v => v.UserId == userId && v.Id == deckId && v.Revision == expectedRevision,
Builders<FlashcardDeck>.Update
.Set(v => v.Name, request.Name.Trim())
.Set(v => v.NormalizedName, normalized)
.Set(v => v.Description, request.Description.Trim())
.Set(v => v.NewCardsPerDay, request.NewCardsPerDay)
.Set(v => v.ReviewsPerDay, request.ReviewsPerDay)
.Set(v => v.DesiredRetention, request.DesiredRetention)
.Set(v => v.UpdatedUtc, now)
.Inc(v => v.Revision, 1),
new FindOneAndUpdateOptions<FlashcardDeck> { ReturnDocument = ReturnDocument.After });
return updated is not null
? new FlashcardDeckUpdateResult(updated, null)
: new FlashcardDeckUpdateResult(null, await GetAsync(userId, deckId));
}
public async Task<FlashcardDeck?> GetByNameAsync(string userId, string normalizedName) =>
await _decks.Find(v => v.UserId == userId && v.NormalizedName == normalizedName).FirstOrDefaultAsync();
public async Task<FlashcardDeckUpdateResult> ArchiveAsync(string userId, string deckId, bool archived)
{
var deck = await GetAsync(userId, deckId);
if (deck is not null && deck.IsDefault) return new FlashcardDeckUpdateResult(null, deck);
var updated = await _decks.FindOneAndUpdateAsync(
v => v.UserId == userId && v.Id == deckId && !v.IsDefault,
Builders<FlashcardDeck>.Update
.Set(v => v.ArchivedUtc, archived ? DateTime.UtcNow : null)
.Set(v => v.UpdatedUtc, DateTime.UtcNow)
.Inc(v => v.Revision, 1),
new FindOneAndUpdateOptions<FlashcardDeck> { ReturnDocument = ReturnDocument.After });
return updated is not null
? new FlashcardDeckUpdateResult(updated, null)
: new FlashcardDeckUpdateResult(null, deck);
}
}
@@ -0,0 +1,393 @@
using System.Text;
using System.Text.Json;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public sealed class FlashcardImportExportService
{
private const int MaxNotesPerImport = 10_000;
private const long MaxImportBytes = 25 * 1024 * 1024;
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
private readonly FlashcardDeckStore _decks;
private readonly FlashcardNoteStore _notes;
private readonly FlashcardScheduleStore _schedules;
private readonly FlashcardMediaStore _media;
private readonly FlashcardSettingsStore _settings;
private readonly IMongoCollection<FlashcardImportPreview> _previews;
public FlashcardImportExportService(
MongoDatabaseProvider mongo,
FlashcardDeckStore decks,
FlashcardNoteStore notes,
FlashcardScheduleStore schedules,
FlashcardMediaStore media,
FlashcardSettingsStore settings)
{
_decks = decks;
_notes = notes;
_schedules = schedules;
_media = media;
_settings = settings;
_previews = mongo.Database.GetCollection<FlashcardImportPreview>("FlashcardImportPreviews");
_previews.Indexes.CreateOne(new CreateIndexModel<FlashcardImportPreview>(
Builders<FlashcardImportPreview>.IndexKeys.Ascending(v => v.ExpiresUtc),
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "flashcard_import_preview_ttl" }));
}
public static void ValidateSize(long byteLength)
{
if (byteLength > MaxImportBytes)
throw new FlashcardValidationException("import file must be 25 MB or smaller");
}
public async Task<(string Csv, List<FlashcardExportOmission> Omissions)> ExportCsvAsync(string userId)
{
var deckList = await _decks.ListAsync(userId);
var deckNames = deckList.ToDictionary(v => v.Id!, v => v.Name, StringComparer.Ordinal);
var allNotes = await _notes.ListAllActiveAsync(userId);
var sb = new StringBuilder();
sb.Append(FlashcardCsv.WriteRow(FlashcardCsv.Headers)).Append('\n');
foreach (var note in allNotes.OrderBy(v => v.CreatedUtc))
{
var deckName = deckNames.GetValueOrDefault(note.DeckId, "Default");
sb.Append(FlashcardCsv.WriteRow([
note.StableId, deckName, note.Type, note.Prompt, note.Answer, note.Extra,
string.Join(';', note.Tags), note.SourceQuestionExternalId
])).Append('\n');
}
var omissions = new List<FlashcardExportOmission>
{
new() { Kind = "schedule", Reason = "CSV export omits FSRS scheduling state" },
new() { Kind = "history", Reason = "CSV export omits review history" }
};
if (allNotes.Any(v => v.Images.Count > 0))
omissions.Insert(0, new FlashcardExportOmission { Kind = "images", Reason = "CSV export omits attached images" });
return (sb.ToString(), omissions);
}
public async Task<FlashcardJsonExport> ExportJsonAsync(string userId)
{
var export = new FlashcardJsonExport
{
Settings = await _settings.GetAsync(userId),
Decks = await _decks.ListAsync(userId)
};
foreach (var note in await _notes.ListAllActiveAsync(userId))
{
var cards = await _schedules.GetByNoteIdAsync(userId, note.Id!);
var logs = new List<FlashcardReviewLog>();
foreach (var card in cards)
logs.AddRange(await _schedules.GetReviewLogsForCardAsync(userId, card.Id!));
var images = new List<FlashcardJsonExportImage>();
foreach (var image in note.Images)
{
var asset = await _media.GetAsync(userId, image.AssetId);
if (asset is null) continue;
images.Add(new FlashcardJsonExportImage
{
AssetId = asset.Id!,
FileName = asset.FileName,
ContentType = asset.ContentType,
Base64Bytes = Convert.ToBase64String(asset.Bytes)
});
}
export.Notes.Add(new FlashcardJsonExportNote { Note = note, Cards = cards, ReviewLogs = logs, Images = images });
}
return export;
}
public async Task<FlashcardImportPreviewResponse> PreviewCsvAsync(string userId, string csvText)
{
var rows = FlashcardCsv.ParseRows(csvText);
if (rows.Count == 0) throw new FlashcardValidationException("CSV file is empty");
if (!rows[0].Select(v => v.Trim()).SequenceEqual(FlashcardCsv.Headers, StringComparer.OrdinalIgnoreCase))
throw new FlashcardValidationException($"CSV header must be: {string.Join(",", FlashcardCsv.Headers)}");
if (rows.Count - 1 > MaxNotesPerImport)
throw new FlashcardValidationException($"import limited to {MaxNotesPerImport:N0} notes");
var existingNotes = await _notes.ListAllActiveAsync(userId);
var byStableId = existingNotes.Where(v => v.StableId.Length > 0)
.ToDictionary(v => v.StableId, StringComparer.Ordinal);
var fingerprints = existingNotes.Select(v => v.ContentFingerprint).ToHashSet(StringComparer.Ordinal);
var records = new List<FlashcardImportPendingRecord>();
for (var r = 1; r < rows.Count; r++)
{
var cols = rows[r];
var record = new FlashcardImportPendingRecord { RowNumber = r + 1 };
if (cols.Length != FlashcardCsv.Headers.Length)
{
record.Action = FlashcardImportRowActions.Error;
record.Error = $"expected {FlashcardCsv.Headers.Length} columns, found {cols.Length}";
records.Add(record);
continue;
}
var stableId = cols[0].Trim();
var deckName = cols[1].Trim();
var type = cols[2].Trim().ToLowerInvariant();
var prompt = cols[3];
var answer = cols[4];
var extra = cols[5];
var tags = cols[6].Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
var sourceQuestionId = cols[7].Trim();
record.StableId = stableId;
record.DeckName = deckName.Length == 0 ? "Default" : deckName;
record.Type = type;
record.Prompt = prompt;
record.Answer = answer;
record.Extra = extra;
record.Tags = tags;
record.SourceQuestionExternalId = sourceQuestionId;
if (type != FlashcardTypes.Basic && type != FlashcardTypes.Cloze)
{
record.Action = FlashcardImportRowActions.Error;
record.Error = "type must be 'basic' or 'cloze'";
}
else if (prompt.Trim().Length == 0)
{
record.Action = FlashcardImportRowActions.Error;
record.Error = "prompt is required";
}
else if (type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(prompt))
{
record.Action = FlashcardImportRowActions.Error;
record.Error = "cloze prompt must contain at least one {{cN::answer}} marker";
}
else
{
var fingerprint = FlashcardNoteStore.Fingerprint(type, prompt, answer);
if (stableId.Length > 0 && byStableId.TryGetValue(stableId, out var existing))
{
record.Action = existing.ContentFingerprint == fingerprint
? FlashcardImportRowActions.Skip
: FlashcardImportRowActions.Update;
}
else if (fingerprints.Contains(fingerprint))
{
record.Action = FlashcardImportRowActions.Skip;
}
else
{
record.Action = FlashcardImportRowActions.Create;
if (stableId.Length == 0) record.StableId = Guid.NewGuid().ToString();
}
}
records.Add(record);
}
return await SavePreviewAsync(userId, FlashcardImportFormats.Csv, records);
}
public async Task<FlashcardImportPreviewResponse> PreviewJsonAsync(string userId, string jsonText)
{
FlashcardJsonExport parsed;
try
{
parsed = JsonSerializer.Deserialize<FlashcardJsonExport>(jsonText, JsonOptions)
?? throw new FlashcardValidationException("empty JSON document");
}
catch (JsonException exception)
{
throw new FlashcardValidationException($"invalid JSON: {exception.Message}");
}
if (parsed.Notes.Count > MaxNotesPerImport)
throw new FlashcardValidationException($"import limited to {MaxNotesPerImport:N0} notes");
var existingNotes = await _notes.ListAllActiveAsync(userId);
var byStableId = existingNotes.Where(v => v.StableId.Length > 0)
.ToDictionary(v => v.StableId, StringComparer.Ordinal);
var records = new List<FlashcardImportPendingRecord>();
var rowNumber = 0;
foreach (var item in parsed.Notes)
{
rowNumber++;
var note = item.Note;
var stableId = string.IsNullOrWhiteSpace(note.StableId) ? Guid.NewGuid().ToString() : note.StableId;
var type = FlashcardTypes.All.Contains(note.Type) ? note.Type : FlashcardTypes.Basic;
var record = new FlashcardImportPendingRecord
{
RowNumber = rowNumber,
StableId = stableId,
DeckName = parsed.Decks.FirstOrDefault(d => d.Id == note.DeckId)?.Name ?? "Default",
Type = type,
Prompt = note.Prompt,
Answer = note.Answer,
Extra = note.Extra,
Tags = note.Tags,
SourceQuestionExternalId = note.SourceQuestionExternalId,
Cards = item.Cards,
ReviewLogs = item.ReviewLogs
};
record.Action = type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(note.Prompt)
? FlashcardImportRowActions.Error
: byStableId.ContainsKey(stableId) ? FlashcardImportRowActions.Update : FlashcardImportRowActions.Create;
if (record.Action == FlashcardImportRowActions.Error)
record.Error = "cloze prompt must contain at least one {{cN::answer}} marker";
records.Add(record);
}
return await SavePreviewAsync(userId, FlashcardImportFormats.Json, records);
}
public async Task<FlashcardImportCommitResponse> CommitAsync(string userId, string importToken)
{
var preview = await _previews.Find(v => v.Id == importToken && v.UserId == userId).FirstOrDefaultAsync();
if (preview is null) throw new FlashcardValidationException("import preview not found or expired; re-run preview");
var deckCache = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var response = new FlashcardImportCommitResponse();
foreach (var record in preview.Records)
{
if (record.Action == FlashcardImportRowActions.Error)
{
response.Errored++;
response.Errors.Add(ToRow(record));
continue;
}
if (record.Action == FlashcardImportRowActions.Skip)
{
response.Skipped++;
continue;
}
var deckId = await ResolveDeckIdAsync(userId, record.DeckName, deckCache);
var existing = await _notes.GetByStableIdAsync(userId, record.StableId);
var request = new UpsertFlashcardNoteRequest
{
DeckId = deckId,
StableId = record.StableId,
Type = record.Type,
Prompt = record.Prompt,
Answer = record.Answer,
Extra = record.Extra,
Tags = record.Tags,
SourceQuestionExternalId = record.SourceQuestionExternalId,
ExpectedRevision = existing?.Revision
};
FlashcardNoteUpdateResult result;
try
{
result = await _notes.UpsertAsync(userId, request, existing?.Id);
}
catch (FlashcardValidationException exception)
{
response.Errored++;
response.Errors.Add(new FlashcardImportPreviewRow
{
RowNumber = record.RowNumber, Action = FlashcardImportRowActions.Error,
Error = exception.Message, StableId = record.StableId
});
continue;
}
if (!result.Succeeded)
{
response.Errored++;
response.Errors.Add(new FlashcardImportPreviewRow
{
RowNumber = record.RowNumber, Action = FlashcardImportRowActions.Error,
Error = "note changed since preview; re-run import", StableId = record.StableId
});
continue;
}
if (record.Action == FlashcardImportRowActions.Create) response.Created++; else response.Updated++;
foreach (var card in record.Cards)
await _schedules.ImportScheduleStateAsync(userId, result.Note!.Id!, card);
foreach (var log in record.ReviewLogs)
{
var targetCard = (await _schedules.GetByNoteIdAsync(userId, result.Note!.Id!))
.FirstOrDefault(v => v.Variant == record.Cards.FirstOrDefault(c => c.Id == log.CardId)?.Variant);
if (targetCard is not null)
await _schedules.ImportReviewLogAsync(userId, targetCard.Id!, log);
}
}
await _previews.DeleteOneAsync(v => v.Id == importToken);
return response;
}
private async Task<FlashcardImportPreviewResponse> SavePreviewAsync(
string userId, string format, List<FlashcardImportPendingRecord> records)
{
var preview = new FlashcardImportPreview
{
Id = Guid.NewGuid().ToString(),
UserId = userId,
Format = format,
Records = records
};
await _previews.InsertOneAsync(preview);
return new FlashcardImportPreviewResponse
{
ImportToken = preview.Id,
CreateCount = records.Count(v => v.Action == FlashcardImportRowActions.Create),
UpdateCount = records.Count(v => v.Action == FlashcardImportRowActions.Update),
SkipCount = records.Count(v => v.Action == FlashcardImportRowActions.Skip),
ErrorCount = records.Count(v => v.Action == FlashcardImportRowActions.Error),
Rows = records.Select(ToRow).ToList()
};
}
private static FlashcardImportPreviewRow ToRow(FlashcardImportPendingRecord record) => new()
{
RowNumber = record.RowNumber,
Action = record.Action,
Error = record.Error,
StableId = record.StableId,
Deck = record.DeckName,
Type = record.Type,
Prompt = Truncate(record.Prompt),
Answer = Truncate(record.Answer)
};
private static string Truncate(string value) => value.Length > 200 ? value[..200] + "…" : value;
private async Task<string> ResolveDeckIdAsync(string userId, string deckName, Dictionary<string, string> cache)
{
var key = deckName.Trim();
if (key.Length == 0) key = "Default";
if (cache.TryGetValue(key, out var cached)) return cached;
if (string.Equals(key, "Default", StringComparison.OrdinalIgnoreCase))
{
var defaultDeck = await _decks.GetOrCreateDefaultDeckAsync(userId);
cache[key] = defaultDeck.Id!;
return defaultDeck.Id!;
}
var normalized = FlashcardDeckStore.Normalize(key);
var existing = await _decks.GetByNameAsync(userId, normalized);
if (existing is not null)
{
cache[key] = existing.Id!;
return existing.Id!;
}
var created = await _decks.UpsertAsync(userId, new UpsertFlashcardDeckRequest { Name = key }, null);
var deckId = created.Deck?.Id ?? (await _decks.GetByNameAsync(userId, normalized))?.Id
?? throw new InvalidOperationException("deck resolution failed during import");
cache[key] = deckId;
return deckId;
}
}
@@ -0,0 +1,41 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public sealed class FlashcardMediaStore
{
private readonly IMongoCollection<FlashcardMediaAsset> _assets;
public FlashcardMediaStore(MongoDatabaseProvider mongo)
{
_assets = mongo.Database.GetCollection<FlashcardMediaAsset>("FlashcardMediaAssets");
_assets.Indexes.CreateOne(new CreateIndexModel<FlashcardMediaAsset>(
Builders<FlashcardMediaAsset>.IndexKeys.Ascending(v => v.UserId),
new CreateIndexOptions { Name = "flashcard_media_user" }));
}
public async Task<FlashcardMediaAsset> CreateAsync(string userId, string fileName, string contentType, byte[] bytes)
{
var asset = new FlashcardMediaAsset
{
UserId = userId,
FileName = fileName,
ContentType = contentType,
Bytes = bytes,
CreatedUtc = DateTime.UtcNow
};
await _assets.InsertOneAsync(asset);
return asset;
}
public async Task<FlashcardMediaAsset?> GetAsync(string userId, string id)
{
if (!ObjectId.TryParse(id, out _)) return null;
return await _assets.Find(v => v.UserId == userId && v.Id == id).FirstOrDefaultAsync();
}
public Task<long> DeleteAsync(string userId, string id) =>
_assets.DeleteOneAsync(v => v.UserId == userId && v.Id == id).ContinueWith(t => t.Result.DeletedCount);
}
@@ -0,0 +1,239 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public sealed record FlashcardNoteUpdateResult(FlashcardNote? Note, FlashcardNote? Current)
{
public bool Succeeded => Note is not null;
}
public sealed class FlashcardNoteStore
{
private readonly IMongoCollection<FlashcardNote> _notes;
private readonly FlashcardScheduleStore _schedules;
public FlashcardNoteStore(MongoDatabaseProvider mongo, FlashcardScheduleStore schedules)
{
_notes = mongo.Database.GetCollection<FlashcardNote>("FlashcardNotes");
_schedules = schedules;
_notes.Indexes.CreateMany([
new CreateIndexModel<FlashcardNote>(
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.StableId),
new CreateIndexOptions { Unique = true, Name = "flashcard_note_unique_user_stable" }),
new CreateIndexModel<FlashcardNote>(
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.DeckId),
new CreateIndexOptions { Name = "flashcard_note_user_deck" }),
new CreateIndexModel<FlashcardNote>(
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.SourceQuestionExternalId),
new CreateIndexOptions { Name = "flashcard_note_user_source_question" }),
new CreateIndexModel<FlashcardNote>(
Builders<FlashcardNote>.IndexKeys.Ascending(v => v.UserId).Ascending(v => v.ContentFingerprint),
new CreateIndexOptions { Name = "flashcard_note_user_fingerprint" })
]);
}
public static string Fingerprint(string type, string prompt, string answer) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{type}{prompt.Trim()}{answer.Trim()}")));
public async Task<FlashcardNote?> GetAsync(string userId, string noteId) =>
await _notes.Find(v => v.UserId == userId && v.Id == noteId).FirstOrDefaultAsync();
public async Task<FlashcardNote?> GetByStableIdAsync(string userId, string stableId) =>
await _notes.Find(v => v.UserId == userId && v.StableId == stableId).FirstOrDefaultAsync();
public Task<List<FlashcardNote>> ListByDeckAsync(string userId, string deckId) =>
_notes.Find(v => v.UserId == userId && v.DeckId == deckId && v.ArchivedUtc == null).ToListAsync();
public Task<List<FlashcardNote>> ListAllActiveAsync(string userId) =>
_notes.Find(v => v.UserId == userId && v.ArchivedUtc == null).ToListAsync();
// Read-only: the caller's own notes plus every system (shared, admin-curated) note. Never
// used to back a write path - mutations must stay strictly own-user via the methods above.
public Task<List<FlashcardNote>> ListByDeckVisibleAsync(string userId, string deckId) =>
_notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System)
&& v.DeckId == deckId && v.ArchivedUtc == null).ToListAsync();
public Task<List<FlashcardNote>> ListAllVisibleActiveAsync(string userId) =>
_notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.ArchivedUtc == null)
.ToListAsync();
public async Task<FlashcardNote?> GetVisibleAsync(string userId, string noteId) =>
await _notes.Find(v => (v.UserId == userId || v.UserId == FlashcardOwners.System) && v.Id == noteId)
.FirstOrDefaultAsync();
// No owner filter at all - used only by the controller's mutation guard to check IsSystem
// regardless of who's asking.
public async Task<FlashcardNote?> GetAnyAsync(string noteId) =>
await _notes.Find(v => v.Id == noteId).FirstOrDefaultAsync();
public async Task<(List<FlashcardNote> Items, int Total)> SearchVisibleAsync(string userId, FlashcardLibraryQuery query)
{
var filter = Builders<FlashcardNote>.Filter.Or(
Builders<FlashcardNote>.Filter.Eq(v => v.UserId, userId),
Builders<FlashcardNote>.Filter.Eq(v => v.UserId, FlashcardOwners.System));
if (query.State != FlashcardArchiveFilter.IncludeArchived)
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.ArchivedUtc, null);
if (!string.IsNullOrWhiteSpace(query.DeckId))
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.DeckId, query.DeckId);
if (!string.IsNullOrWhiteSpace(query.Type))
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.Type, query.Type);
if (!string.IsNullOrWhiteSpace(query.Tag))
filter &= Builders<FlashcardNote>.Filter.AnyEq(v => v.Tags, query.Tag);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var pattern = new MongoDB.Bson.BsonRegularExpression(Regex.Escape(query.Search), "i");
filter &= Builders<FlashcardNote>.Filter.Or(
Builders<FlashcardNote>.Filter.Regex(v => v.Prompt, pattern),
Builders<FlashcardNote>.Filter.Regex(v => v.Answer, pattern),
Builders<FlashcardNote>.Filter.Regex(v => v.Extra, pattern));
}
var total = checked((int)await _notes.CountDocumentsAsync(filter));
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 200);
var items = await _notes.Find(filter)
.SortByDescending(v => v.UpdatedUtc)
.Skip((page - 1) * pageSize)
.Limit(pageSize)
.ToListAsync();
return (items, total);
}
public async Task<(List<FlashcardNote> Items, int Total)> SearchAsync(string userId, FlashcardLibraryQuery query)
{
var filter = Builders<FlashcardNote>.Filter.Eq(v => v.UserId, userId);
if (query.State != FlashcardArchiveFilter.IncludeArchived)
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.ArchivedUtc, null);
if (!string.IsNullOrWhiteSpace(query.DeckId))
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.DeckId, query.DeckId);
if (!string.IsNullOrWhiteSpace(query.Type))
filter &= Builders<FlashcardNote>.Filter.Eq(v => v.Type, query.Type);
if (!string.IsNullOrWhiteSpace(query.Tag))
filter &= Builders<FlashcardNote>.Filter.AnyEq(v => v.Tags, query.Tag);
if (!string.IsNullOrWhiteSpace(query.Search))
{
var pattern = new MongoDB.Bson.BsonRegularExpression(Regex.Escape(query.Search), "i");
filter &= Builders<FlashcardNote>.Filter.Or(
Builders<FlashcardNote>.Filter.Regex(v => v.Prompt, pattern),
Builders<FlashcardNote>.Filter.Regex(v => v.Answer, pattern),
Builders<FlashcardNote>.Filter.Regex(v => v.Extra, pattern));
}
var total = checked((int)await _notes.CountDocumentsAsync(filter));
var page = Math.Max(1, query.Page);
var pageSize = Math.Clamp(query.PageSize, 1, 200);
var items = await _notes.Find(filter)
.SortByDescending(v => v.UpdatedUtc)
.Skip((page - 1) * pageSize)
.Limit(pageSize)
.ToListAsync();
return (items, total);
}
public async Task<FlashcardNoteUpdateResult> UpsertAsync(string userId, UpsertFlashcardNoteRequest request, string? noteId)
{
var type = FlashcardTypes.All.Contains(request.Type) ? request.Type : FlashcardTypes.Basic;
if (type == FlashcardTypes.Cloze && !FlashcardClozeParser.HasValidCloze(request.Prompt))
throw new FlashcardValidationException("cloze note must contain at least one {{cN::answer}} marker");
var now = DateTime.UtcNow;
var fingerprint = Fingerprint(type, request.Prompt, request.Answer);
FlashcardNote? saved;
if (string.IsNullOrWhiteSpace(noteId))
{
var created = new FlashcardNote
{
UserId = userId,
StableId = string.IsNullOrWhiteSpace(request.StableId) ? Guid.NewGuid().ToString() : request.StableId,
DeckId = request.DeckId,
Type = type,
Prompt = request.Prompt,
Answer = request.Answer,
Extra = request.Extra,
Tags = NormalizeTags(request.Tags),
Images = request.Images,
SourceQuestionExternalId = request.SourceQuestionExternalId,
ContentFingerprint = fingerprint,
CreatedUtc = now,
UpdatedUtc = now
};
try
{
await _notes.InsertOneAsync(created);
saved = created;
}
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey)
{
return new FlashcardNoteUpdateResult(null, await GetByStableIdAsync(userId, created.StableId));
}
}
else
{
var expectedRevision = request.ExpectedRevision ?? 0;
var updated = await _notes.FindOneAndUpdateAsync(
v => v.UserId == userId && v.Id == noteId && v.Revision == expectedRevision,
Builders<FlashcardNote>.Update
.Set(v => v.DeckId, request.DeckId)
.Set(v => v.Type, type)
.Set(v => v.Prompt, request.Prompt)
.Set(v => v.Answer, request.Answer)
.Set(v => v.Extra, request.Extra)
.Set(v => v.Tags, NormalizeTags(request.Tags))
.Set(v => v.Images, request.Images)
.Set(v => v.SourceQuestionExternalId, request.SourceQuestionExternalId)
.Set(v => v.ContentFingerprint, fingerprint)
.Set(v => v.UpdatedUtc, now)
.Inc(v => v.Revision, 1),
new FindOneAndUpdateOptions<FlashcardNote> { ReturnDocument = ReturnDocument.After });
if (updated is null)
return new FlashcardNoteUpdateResult(null, await GetAsync(userId, noteId));
saved = updated;
}
await _schedules.SyncVariantsAsync(saved);
return new FlashcardNoteUpdateResult(saved, null);
}
public async Task<FlashcardNoteUpdateResult> ArchiveAsync(string userId, string noteId, bool archived)
{
var updated = await _notes.FindOneAndUpdateAsync(
v => v.UserId == userId && v.Id == noteId,
Builders<FlashcardNote>.Update
.Set(v => v.ArchivedUtc, archived ? DateTime.UtcNow : null)
.Set(v => v.UpdatedUtc, DateTime.UtcNow)
.Inc(v => v.Revision, 1),
new FindOneAndUpdateOptions<FlashcardNote> { ReturnDocument = ReturnDocument.After });
return updated is not null
? new FlashcardNoteUpdateResult(updated, null)
: new FlashcardNoteUpdateResult(null, await GetAsync(userId, noteId));
}
public async Task<long> MoveAsync(string userId, IReadOnlyCollection<string> noteIds, string targetDeckId)
{
var result = await _notes.UpdateManyAsync(
v => v.UserId == userId && noteIds.Contains(v.Id!),
Builders<FlashcardNote>.Update.Set(v => v.DeckId, targetDeckId).Set(v => v.UpdatedUtc, DateTime.UtcNow).Inc(v => v.Revision, 1));
return result.ModifiedCount;
}
public Task<long> CountInDeckAsync(string userId, string deckId) =>
_notes.CountDocumentsAsync(v => v.UserId == userId && v.DeckId == deckId && v.ArchivedUtc == null);
private static List<string> NormalizeTags(List<string> tags) => tags
.Select(v => v.Trim())
.Where(v => v.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
public sealed class FlashcardValidationException(string message) : Exception(message);
public static class FlashcardArchiveFilter
{
public const string IncludeArchived = "archived";
}
@@ -0,0 +1,8 @@
namespace QuestionsApi.Services.Flashcards;
// Pure authorization decision for mutating a flashcard deck/note, kept dependency-free (like
// FlashcardScheduling) so it's unit testable without a controller or Mongo.
public static class FlashcardOwnershipGuard
{
public static bool CanMutate(bool isSuper, bool targetIsSystem) => isSuper || !targetIsSystem;
}
@@ -0,0 +1,392 @@
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
};
}
@@ -0,0 +1,116 @@
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";
}
}
@@ -0,0 +1,54 @@
namespace QuestionsApi.Services.Flashcards;
// Small pure helpers pulled out of FlashcardStudyService/FlashcardScheduleStore so the
// daily-limit and sibling-bury rules can be unit tested without a live Mongo instance.
public static class FlashcardScheduling
{
// A card reviewed on a given local study day buries its siblings until the next local day.
public static DateTime NextStudyDayStart(DateTime studyDayStartUtc) => studyDayStartUtc.Date.AddDays(1);
// Effective per-day budget: deck override wins over the global default, minus what's
// already been consumed on the client's current local study day.
public static int ResolveDailyBudget(int globalLimit, int? deckOverride, int consumedToday) =>
Math.Max(0, (deckOverride ?? globalLimit) - consumedToday);
// A streak counts consecutive calendar days with at least one review, ending either today or
// yesterday (so the streak doesn't visibly reset to 0 the moment a new day starts before
// today's first review) - the same semantics as most habit-streak trackers.
public static int ComputeStreakDays(IReadOnlyCollection<DateTime> reviewTimestamps, DateTime todayUtc)
{
var reviewDays = reviewTimestamps.Select(v => v.Date).ToHashSet();
var cursor = todayUtc.Date;
if (!reviewDays.Contains(cursor))
{
cursor = cursor.AddDays(-1);
if (!reviewDays.Contains(cursor)) return 0;
}
var streak = 0;
while (reviewDays.Contains(cursor))
{
streak++;
cursor = cursor.AddDays(-1);
}
return streak;
}
// Picks which candidate in an already-sorted pool becomes the next card. Non-shuffle: the
// first not already excluded this session (today's deterministic oldest-first behavior).
// Shuffle: a random pick among the non-excluded candidates - `randomIndex(n)` must return a
// value in [0, n); production passes Random.Shared.Next, tests pass a fixed stub so the
// excluded-id-to-original-index mapping is verifiable without relying on real randomness.
public static int? PickIndex(IReadOnlyList<string> poolIds, ISet<string> exclude, bool shuffle, Func<int, int> randomIndex)
{
if (!shuffle)
{
for (var i = 0; i < poolIds.Count; i++)
if (!exclude.Contains(poolIds[i])) return i;
return null;
}
var candidates = new List<int>();
for (var i = 0; i < poolIds.Count; i++)
if (!exclude.Contains(poolIds[i])) candidates.Add(i);
return candidates.Count == 0 ? null : candidates[randomIndex(candidates.Count)];
}
}
@@ -0,0 +1,36 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
public sealed class FlashcardSettingsStore
{
private readonly IMongoCollection<FlashcardSettings> _settings;
public FlashcardSettingsStore(MongoDatabaseProvider mongo)
{
_settings = mongo.Database.GetCollection<FlashcardSettings>("FlashcardSettings");
_settings.Indexes.CreateOne(new CreateIndexModel<FlashcardSettings>(
Builders<FlashcardSettings>.IndexKeys.Ascending(v => v.UserId),
new CreateIndexOptions { Unique = true, Name = "flashcard_settings_unique_user" }));
}
public async Task<FlashcardSettings> GetAsync(string userId) =>
await _settings.Find(v => v.UserId == userId).FirstOrDefaultAsync()
?? new FlashcardSettings { UserId = userId };
public async Task<FlashcardSettings> UpdateAsync(string userId, UpdateFlashcardSettingsRequest request)
{
var now = DateTime.UtcNow;
return await _settings.FindOneAndUpdateAsync(
v => v.UserId == userId,
Builders<FlashcardSettings>.Update
.Set(v => v.NewCardsPerDay, Math.Clamp(request.NewCardsPerDay, 0, 2000))
.Set(v => v.ReviewsPerDay, Math.Clamp(request.ReviewsPerDay, 0, 5000))
.Set(v => v.DesiredRetention, FsrsFlashcardScheduler.DesiredRetention)
.Set(v => v.UpdatedUtc, now)
.Inc(v => v.Revision, 1)
.SetOnInsert(v => v.UserId, userId),
new FindOneAndUpdateOptions<FlashcardSettings> { IsUpsert = true, ReturnDocument = ReturnDocument.After });
}
}
@@ -0,0 +1,158 @@
using QuestionsApi.Models;
namespace QuestionsApi.Services.Flashcards;
// Composes decks/notes/schedules/settings to answer "what's next" and daily-count questions.
// Kept separate from the individual stores to avoid a circular dependency (notes need to
// sync schedule variants; schedules must not need to know about notes/decks).
public sealed class FlashcardStudyService(
FlashcardDeckStore decks,
FlashcardNoteStore notes,
FlashcardScheduleStore schedules,
FlashcardSettingsStore settingsStore)
{
// Extra rows fetched beyond the budget when shuffling, so there's an actual pool to
// randomize over instead of just enough rows to skip past this session's exclusions.
private const int ShuffleCandidatePoolPadding = 40;
public async Task<FlashcardStudyCardResponse> GetNextCardAsync(string userId, FlashcardStudyRequest request)
{
var now = DateTime.UtcNow;
var studyDayStart = request.StudyDayStartUtc.Date;
var noteIds = await ResolveNoteIdsAsync(userId, request.DeckId);
var (newBudget, reviewBudget) = await ResolveBudgetsAsync(userId, request.DeckId, studyDayStart);
var exclude = request.ExcludeCardIds.Where(v => !string.IsNullOrWhiteSpace(v)).ToHashSet(StringComparer.Ordinal);
// Overdue/due cards first (oldest due first, or shuffled within the due pool), then new
// cards (oldest first, or shuffled within the new pool) - shuffle never lets a new card
// jump ahead of a due one, it only randomizes selection within each pool.
FlashcardSchedule? card = null;
if (reviewBudget > 0)
{
var dueLimit = (request.Shuffle ? Math.Max(reviewBudget, ShuffleCandidatePoolPadding) : reviewBudget) + exclude.Count + 1;
var due = await schedules.GetDueAsync(userId, noteIds, now, studyDayStart, dueLimit);
var pickedIndex = FlashcardScheduling.PickIndex(due.Select(v => v.Id!).ToList(), exclude, request.Shuffle, Random.Shared.Next);
if (pickedIndex is int dueIndex) card = due[dueIndex];
}
if (card is null && newBudget > 0)
{
var newLimit = (request.Shuffle ? Math.Max(newBudget, ShuffleCandidatePoolPadding) : newBudget) + exclude.Count + 1;
var fresh = await schedules.GetNewAsync(userId, noteIds, studyDayStart, newLimit);
var pickedIndex = FlashcardScheduling.PickIndex(fresh.Select(v => v.Id!).ToList(), exclude, request.Shuffle, Random.Shared.Next);
if (pickedIndex is int newIndex) card = fresh[newIndex];
}
var dueRemaining = checked((int)await schedules.CountDueAsync(userId, noteIds, now, studyDayStart));
var newRemaining = checked((int)await schedules.CountNewAsync(userId, noteIds, studyDayStart));
var response = new FlashcardStudyCardResponse
{
DueRemaining = Math.Min(dueRemaining, reviewBudget),
NewRemaining = Math.Min(newRemaining, newBudget)
};
if (card is null) return response;
var note = await notes.GetVisibleAsync(userId, card.NoteId);
if (note is null) return response;
response.Note = note;
response.Card = card;
response.RatingOptions = schedules.PreviewRatingOptions(card, now);
return response;
}
public async Task<FlashcardDashboardCounts> GetDashboardCountsAsync(string userId, DateTime nowUtc, DateTime weekStartUtc)
{
var studyDayStart = nowUtc.Date;
var noteIds = await ResolveNoteIdsAsync(userId, "");
var (newBudget, _) = await ResolveBudgetsAsync(userId, "", studyDayStart);
var due = checked((int)await schedules.CountDueAsync(userId, noteIds, nowUtc, studyDayStart));
var newAvailable = Math.Min(checked((int)await schedules.CountNewAsync(userId, noteIds, studyDayStart)), newBudget);
var reviewedThisWeek = checked((int)await schedules.CountReviewsSinceAsync(userId, weekStartUtc));
var learning = checked((int)await schedules.CountByStateAsync(userId, noteIds, FlashcardScheduleStates.Learning))
+ checked((int)await schedules.CountByStateAsync(userId, noteIds, FlashcardScheduleStates.Relearning));
var review = checked((int)await schedules.CountByStateAsync(userId, noteIds, FlashcardScheduleStates.Review));
var reviewsToday = checked((int)await schedules.CountReviewsSinceAsync(userId, studyDayStart));
var streakTimestamps = await schedules.GetReviewTimestampsSinceAsync(userId, nowUtc.AddDays(-400));
var settings = await settingsStore.GetAsync(userId);
return new FlashcardDashboardCounts
{
Due = due,
NewAvailable = newAvailable,
ReviewedThisWeek = reviewedThisWeek,
Learning = learning,
Review = review,
CurrentStreakDays = FlashcardScheduling.ComputeStreakDays(streakTimestamps, nowUtc.Date),
ReviewsToday = reviewsToday,
DailyGoal = settings.ReviewsPerDay
};
}
public async Task<List<FlashcardDeckSummary>> GetDeckSummariesAsync(string userId, DateTime nowUtc, DateTime studyDayStart)
{
var deckList = await decks.ListVisibleAsync(userId);
var result = new List<FlashcardDeckSummary>();
foreach (var deck in deckList)
{
var deckNotes = await notes.ListByDeckVisibleAsync(userId, deck.Id!);
await EnsureSystemScheduleRowsAsync(userId, deckNotes);
var deckNoteIds = deckNotes.Select(v => v.Id!).ToList();
var due = checked((int)await schedules.CountDueAsync(userId, deckNoteIds, nowUtc, studyDayStart));
var fresh = checked((int)await schedules.CountNewAsync(userId, deckNoteIds, studyDayStart));
var learning = checked((int)await schedules.CountByStateAsync(userId, deckNoteIds, FlashcardScheduleStates.Learning))
+ checked((int)await schedules.CountByStateAsync(userId, deckNoteIds, FlashcardScheduleStates.Relearning));
var review = checked((int)await schedules.CountByStateAsync(userId, deckNoteIds, FlashcardScheduleStates.Review));
var mastered = checked((int)await schedules.CountMasteredAsync(userId, deckNoteIds));
result.Add(new FlashcardDeckSummary
{
Deck = deck,
Total = deckNoteIds.Count,
Due = due,
New = fresh,
Learning = learning,
Review = review,
Mastered = mastered
});
}
return result;
}
private async Task<List<string>> ResolveNoteIdsAsync(string userId, string deckId)
{
var activeNotes = string.IsNullOrWhiteSpace(deckId)
? await notes.ListAllVisibleActiveAsync(userId)
: await notes.ListByDeckVisibleAsync(userId, deckId);
await EnsureSystemScheduleRowsAsync(userId, activeNotes);
return activeNotes.Select(v => v.Id!).ToList();
}
// Lazily materializes this user's own FlashcardSchedule rows for any note they don't own
// (i.e. system notes) before it's counted/served. A no-op once a user has touched a given
// system note (SyncVariantsForStudyingUserAsync is duplicate-key-tolerant and additive-only).
private async Task EnsureSystemScheduleRowsAsync(string userId, List<FlashcardNote> activeNotes)
{
foreach (var note in activeNotes.Where(v => v.IsSystem))
await schedules.SyncVariantsForStudyingUserAsync(userId, note);
}
private async Task<(int NewBudget, int ReviewBudget)> ResolveBudgetsAsync(string userId, string deckId, DateTime studyDayStart)
{
var settings = await settingsStore.GetAsync(userId);
var newLimit = settings.NewCardsPerDay;
var reviewLimit = settings.ReviewsPerDay;
if (!string.IsNullOrWhiteSpace(deckId))
{
var deck = await decks.GetVisibleAsync(userId, deckId);
if (deck is not null)
{
newLimit = deck.NewCardsPerDay ?? newLimit;
reviewLimit = deck.ReviewsPerDay ?? reviewLimit;
}
}
var reviewedToday = checked((int)await schedules.CountReviewsSinceAsync(userId, studyDayStart));
var newIntroducedToday = await schedules.CountNewIntroducedSinceAsync(userId, studyDayStart);
return (
FlashcardScheduling.ResolveDailyBudget(newLimit, null, newIntroducedToday),
FlashcardScheduling.ResolveDailyBudget(reviewLimit, null, reviewedToday));
}
}
@@ -103,11 +103,13 @@ public class QuestionAttemptStore
return leased?.RatingLeaseOwner == owner;
}
public Task MarkRatingAppliedAsync(string attemptId, string owner) =>
public Task MarkRatingAppliedAsync(string attemptId, string owner, int userOverallElo) =>
_col.UpdateOneAsync(
attempt => attempt.Id == attemptId && attempt.RatingLeaseOwner == owner,
Builders<QuestionAttempt>.Update
.Set(attempt => attempt.RatingApplied, true)
.Set(attempt => attempt.UserOverallEloAfter, userOverallElo)
.Set(attempt => attempt.RatingAppliedUtc, DateTime.UtcNow)
.Set(attempt => attempt.RatingLeaseUntilUtc, DateTime.MinValue));
public Task ReleaseRatingLeaseAsync(string attemptId, string owner) =>
@@ -105,7 +105,11 @@ public sealed class QuestionBookmarkStore
}
var questionMatch = new BsonDocument();
if (!includeUnpublished) questionMatch["Question.IsPublished"] = true;
if (!includeUnpublished)
{
questionMatch["Question.IsPublished"] = true;
questionMatch["Question.FlaggedForReviewUtc"] = BsonNull.Value;
}
if (!string.IsNullOrWhiteSpace(system) && system != "All Systems") questionMatch["Question.System"] = system.Trim();
if (!string.IsNullOrWhiteSpace(discipline)) questionMatch["Question.Discipline"] = discipline.Trim();
if (!string.IsNullOrWhiteSpace(query))
@@ -172,7 +176,11 @@ public sealed class QuestionBookmarkStore
new("$unwind", "$Question")
};
if (!includeUnpublished)
pipeline.Add(new BsonDocument("$match", new BsonDocument("Question.IsPublished", true)));
pipeline.Add(new BsonDocument("$match", new BsonDocument
{
{ "Question.IsPublished", true },
{ "Question.FlaggedForReviewUtc", BsonNull.Value }
}));
pipeline.Add(new BsonDocument("$group", new BsonDocument
{
{ "_id", BsonNull.Value },
@@ -0,0 +1,309 @@
using MongoDB.Bson;
using MongoDB.Driver;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class QuestionPromotionCommand
{
public const string Confirmation = "PROMOTE_TO_PRD";
internal const string ProductionMediaReviewReason = "Contains media - pending production review";
internal const string ProductionMediaReviewActor = "system:production-promotion";
public static async Task<int> RunAsync(string[] args, TextWriter output)
{
try
{
var options = Parse(args);
if (options.Apply && options.Confirmation != Confirmation)
throw new ArgumentException($"--confirm {Confirmation} is required with --apply");
if (options.SourceDatabase.Equals(options.TargetDatabase, StringComparison.OrdinalIgnoreCase)
&& options.SourceConnection == options.TargetConnection)
throw new ArgumentException("source and target databases must be different");
var source = new MongoClient(options.SourceConnection).GetDatabase(options.SourceDatabase);
var target = new MongoClient(options.TargetConnection).GetDatabase(options.TargetDatabase);
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
var questionFilter = Builders<BsonDocument>.Filter.Eq("IsPublished", true)
& Builders<BsonDocument>.Filter.Eq("FlaggedForReviewUtc", BsonNull.Value);
var sourceQuestions = await source.GetCollection<BsonDocument>("Questions")
.Find(questionFilter).ToListAsync();
var duplicateIds = sourceQuestions
.GroupBy(ExternalId, StringComparer.Ordinal)
.Where(group => group.Count() > 1)
.Select(group => group.Key)
.ToList();
if (duplicateIds.Count > 0)
throw new InvalidOperationException($"duplicate eligible ExternalIds: {string.Join(", ", duplicateIds)}");
var questions = sourceQuestions.Select(PrepareForProduction).ToList();
var mediaIds = CollectMediaIds(questions);
var media = mediaIds.Count == 0
? []
: await source.GetCollection<BsonDocument>("QuestionMediaAssets")
.Find(Builders<BsonDocument>.Filter.In("_id", mediaIds)).ToListAsync();
if (media.Count != mediaIds.Count)
throw new InvalidOperationException($"{mediaIds.Count - media.Count} referenced question media assets are missing");
var sourceIds = questions.Select(ExternalId).ToHashSet(StringComparer.Ordinal);
var targetCollection = target.GetCollection<BsonDocument>("Questions");
var targetQuestions = sourceIds.Count == 0
? []
: await targetCollection.Find(
Builders<BsonDocument>.Filter.In("ExternalId", sourceIds)).ToListAsync();
var targetTotal = await targetCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
var comparison = ClassifyQuestions(questions, targetQuestions);
var summary = new PromotionSummary(
questions.Count,
media.Count,
comparison.Inserted,
comparison.Updated,
comparison.Unchanged,
checked((int)Math.Max(0, targetTotal - targetQuestions.Count)),
HashQuestions(questions));
await WriteSummaryAsync(output, options.Apply ? "apply" : "dry-run", options, summary);
if (!options.Apply)
return 0;
await UpsertMediaAsync(target, media);
await UpsertQuestionsAsync(target, questions);
var verifiedQuestions = sourceIds.Count == 0
? []
: await targetCollection.Find(
Builders<BsonDocument>.Filter.In("ExternalId", sourceIds)).ToListAsync();
var verification = ClassifyQuestions(questions, verifiedQuestions);
if (verification.Inserted != 0 || verification.Updated != 0)
throw new InvalidOperationException(
$"target verification failed: {verification.Inserted} missing and {verification.Updated} mismatched questions");
var verifiedMedia = mediaIds.Count == 0
? 0
: checked((int)await target.GetCollection<BsonDocument>("QuestionMediaAssets")
.CountDocumentsAsync(Builders<BsonDocument>.Filter.In("_id", mediaIds)));
if (verifiedMedia != mediaIds.Count)
throw new InvalidOperationException(
$"target verification failed: {mediaIds.Count - verifiedMedia} media assets are missing");
await target.GetCollection<BsonDocument>("ContentPromotions").InsertOneAsync(new BsonDocument
{
["PromotionId"] = Guid.NewGuid().ToString("N"),
["AppliedUtc"] = DateTime.UtcNow,
["Operator"] = options.Operator,
["BuildRevision"] = options.BuildRevision,
["SourceDatabase"] = options.SourceDatabase,
["TargetDatabase"] = options.TargetDatabase,
["Scope"] = "questions",
["Eligibility"] = "published_unflagged; media_requires_production_review",
["Questions"] = summary.EligibleQuestions,
["Inserted"] = summary.Inserted,
["Updated"] = summary.Updated,
["Unchanged"] = summary.Unchanged,
["MediaAssets"] = summary.MediaAssets,
["TargetExtras"] = summary.TargetExtras,
["ContentHash"] = summary.ContentHash
});
await output.WriteLineAsync("Question promotion completed and verified.");
return 0;
}
catch (Exception exception)
{
await output.WriteLineAsync($"Question promotion failed: {exception.Message}");
return 1;
}
}
internal static bool IsEligible(BsonDocument question) =>
question.GetValue("IsPublished", false).ToBoolean()
&& (!question.TryGetValue("FlaggedForReviewUtc", out var flagged) || flagged.IsBsonNull);
internal static BsonDocument PrepareForProduction(BsonDocument source)
{
var prepared = new BsonDocument(source);
if (!HasMedia(prepared))
return prepared;
var reviewUtc = prepared.TryGetValue("UpdatedUtc", out var updated) && updated.IsValidDateTime
? updated
: new BsonDateTime(DateTime.UnixEpoch);
prepared["FlaggedForReviewUtc"] = reviewUtc;
prepared["FlagReason"] = ProductionMediaReviewReason;
prepared["FlaggedByUserId"] = ProductionMediaReviewActor;
return prepared;
}
internal static HashSet<ObjectId> CollectMediaIds(IEnumerable<BsonDocument> questions)
{
var ids = new HashSet<ObjectId>();
foreach (var question in questions)
{
if (!question.TryGetValue("Media", out var raw) || !raw.IsBsonArray)
continue;
foreach (var value in raw.AsBsonArray)
{
if (!value.IsString)
continue;
var reference = value.AsString.Trim();
var candidate = reference.TrimEnd('/').Split('/').LastOrDefault() ?? "";
if (ObjectId.TryParse(reference, out var direct))
ids.Add(direct);
else if (ObjectId.TryParse(candidate, out var fromPath))
ids.Add(fromPath);
else if (reference.Contains("/api/questions/media/", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
$"question {ExternalId(question)} has an invalid local media reference: {reference}");
}
}
return ids;
}
internal static QuestionComparison ClassifyQuestions(
IEnumerable<BsonDocument> sourceQuestions,
IEnumerable<BsonDocument> targetQuestions)
{
var target = targetQuestions.ToDictionary(ExternalId, StringComparer.Ordinal);
var inserted = 0;
var updated = 0;
var unchanged = 0;
foreach (var source in sourceQuestions)
{
var externalId = ExternalId(source);
if (!target.TryGetValue(externalId, out var existing))
inserted++;
else if (EquivalentQuestion(source, existing))
unchanged++;
else
updated++;
}
return new QuestionComparison(inserted, updated, unchanged);
}
internal static string HashQuestions(IEnumerable<BsonDocument> questions)
{
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
foreach (var document in questions.OrderBy(ExternalId, StringComparer.Ordinal))
hash.AppendData(Encoding.UTF8.GetBytes(CanonicalQuestion(document).ToJson()));
return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
}
private static bool EquivalentQuestion(BsonDocument source, BsonDocument target) =>
CanonicalQuestion(source).Equals(CanonicalQuestion(target));
private static BsonDocument CanonicalQuestion(BsonDocument source) =>
Canonicalize(new BsonDocument(source.Where(element => element.Name != "_id"))).AsBsonDocument;
private static BsonValue Canonicalize(BsonValue value)
{
if (value.IsBsonDocument)
return new BsonDocument(value.AsBsonDocument
.OrderBy(element => element.Name, StringComparer.Ordinal)
.Select(element => new BsonElement(element.Name, Canonicalize(element.Value))));
if (value.IsBsonArray)
return new BsonArray(value.AsBsonArray.Select(Canonicalize));
return value;
}
private static string ExternalId(BsonDocument document) =>
document.GetValue("ExternalId", "").AsString;
private static bool HasMedia(BsonDocument question) =>
question.TryGetValue("Media", out var media)
&& media.IsBsonArray
&& media.AsBsonArray.Count > 0;
private static async Task UpsertQuestionsAsync(IMongoDatabase target, IEnumerable<BsonDocument> questions)
{
var collection = target.GetCollection<BsonDocument>("Questions");
foreach (var source in questions)
{
var replacement = new BsonDocument(source.Where(element => element.Name != "_id"));
await collection.ReplaceOneAsync(
Builders<BsonDocument>.Filter.Eq("ExternalId", source["ExternalId"]),
replacement,
new ReplaceOptions { IsUpsert = true });
}
}
private static async Task UpsertMediaAsync(IMongoDatabase target, IEnumerable<BsonDocument> media)
{
var collection = target.GetCollection<BsonDocument>("QuestionMediaAssets");
foreach (var source in media)
await collection.ReplaceOneAsync(
Builders<BsonDocument>.Filter.Eq("_id", source["_id"]),
source,
new ReplaceOptions { IsUpsert = true });
}
private static Task WriteSummaryAsync(
TextWriter output,
string mode,
PromotionOptions options,
PromotionSummary summary) =>
output.WriteLineAsync(JsonSerializer.Serialize(new
{
mode,
scope = "questions",
eligibility = "published_unflagged; media_requires_production_review",
source = options.SourceDatabase,
target = options.TargetDatabase,
eligibleQuestions = summary.EligibleQuestions,
mediaAssets = summary.MediaAssets,
inserted = summary.Inserted,
updated = summary.Updated,
unchanged = summary.Unchanged,
targetExtras = summary.TargetExtras,
contentHash = summary.ContentHash
}));
private static PromotionOptions Parse(IReadOnlyList<string> args)
{
string Required(string name, string environmentName)
{
var index = args.IndexOf($"--{name}");
var value = index >= 0 && index + 1 < args.Count
? args[index + 1]
: Environment.GetEnvironmentVariable(environmentName);
return string.IsNullOrWhiteSpace(value)
? throw new ArgumentException($"--{name} is required")
: value;
}
string Optional(string name, string fallback)
{
var index = args.IndexOf($"--{name}");
return index >= 0 && index + 1 < args.Count ? args[index + 1] : fallback;
}
return new PromotionOptions(
Required("source-connection", "SOURCE_MONGODB_CONNECTION_STRING"),
Required("source-database", "SOURCE_MONGODB_DATABASE"),
Required("target-connection", "TARGET_MONGODB_CONNECTION_STRING"),
Required("target-database", "TARGET_MONGODB_DATABASE"),
args.Contains("--apply"),
Optional("confirm", ""),
Optional("operator", "unknown"),
Optional("build-revision", "unknown"));
}
private sealed record PromotionOptions(
string SourceConnection,
string SourceDatabase,
string TargetConnection,
string TargetDatabase,
bool Apply,
string Confirmation,
string Operator,
string BuildRevision);
internal sealed record QuestionComparison(int Inserted, int Updated, int Unchanged);
internal sealed record PromotionSummary(
int EligibleQuestions,
int MediaAssets,
int Inserted,
int Updated,
int Unchanged,
int TargetExtras,
string ContentHash);
}
@@ -79,6 +79,39 @@ public class QuestionReportStore
return report;
}
public async Task<QuestionReport> CreateDynamicAsync(
DynamicGenerationClaims claims,
string userId,
string category,
string details)
{
var now = DateTime.UtcNow;
var report = new QuestionReport
{
QuestionExternalId = $"dynamic:{claims.BlueprintExternalId}:v{claims.BlueprintVersion}",
UserId = userId,
Category = category,
Details = details,
Status = QuestionReportStatus.Open,
SourceType = "dynamic",
DynamicGeneration = new DynamicGenerationDescriptor
{
BlueprintExternalId = claims.BlueprintExternalId,
BlueprintVersion = claims.BlueprintVersion,
GeneratorVersion = claims.GeneratorVersion,
Seed = claims.Seed,
CaseFamilyId = claims.CaseFamilyId,
DifficultyTier = claims.DifficultyTier,
FamilyElo = claims.FamilyElo,
EffectiveAbilityElo = claims.EffectiveAbilityElo
},
CreatedUtc = now,
UpdatedUtc = now
};
await _reports.InsertOneAsync(report);
return report;
}
public async Task<QuestionReport?> GetByIdAsync(string id)
{
if (!ObjectId.TryParse(id, out _))
+68 -2
View File
@@ -6,6 +6,9 @@ namespace QuestionsApi.Services;
public class QuestionStore
{
private const string MediaReviewReason = "Contains media - pending review";
private const string MediaReviewFlaggedBy = "system";
private readonly IMongoCollection<Question> _col;
public QuestionStore(MongoDatabaseProvider mongo)
@@ -26,7 +29,10 @@ public class QuestionStore
var filters = new List<FilterDefinition<Question>>();
if (!includeUnpublished)
{
filters.Add(builder.Eq(q => q.IsPublished, true));
filters.Add(builder.Eq(q => q.FlaggedForReviewUtc, null));
}
if (!string.IsNullOrWhiteSpace(req.System) && req.System != "All Systems")
filters.Add(builder.Eq(q => q.System, req.System.Trim()));
if (!string.IsNullOrWhiteSpace(req.Discipline))
@@ -65,7 +71,7 @@ public class QuestionStore
await _col.Find(Builders<Question>.Filter.Empty).ToListAsync();
public async Task<List<Question>> GetPublishedAsync() =>
await _col.Find(question => question.IsPublished).ToListAsync();
await _col.Find(question => question.IsPublished && question.FlaggedForReviewUtc == null).ToListAsync();
public async Task<Dictionary<string, Question>> GetByExternalIdsAsync(IEnumerable<string> externalIds)
{
@@ -102,6 +108,7 @@ public class QuestionStore
var question = FromRequest(req);
question.CreatedUtc = DateTime.UtcNow;
question.UpdatedUtc = DateTime.UtcNow;
ApplyMediaReviewGate(question);
try
{
@@ -125,6 +132,10 @@ public class QuestionStore
return null;
question.CreatedUtc = existing.CreatedUtc;
question.FlaggedForReviewUtc = existing.FlaggedForReviewUtc;
question.FlagReason = existing.FlagReason;
question.FlaggedByUserId = existing.FlaggedByUserId;
ApplyMediaReviewGate(question);
var result = await _col.ReplaceOneAsync(q => q.Id == id, question);
return result.MatchedCount == 0 ? null : question;
}
@@ -143,12 +154,16 @@ public class QuestionStore
{
question.Id = existing.Id;
question.CreatedUtc = existing.CreatedUtc;
question.FlaggedForReviewUtc = existing.FlaggedForReviewUtc;
question.FlagReason = existing.FlagReason;
question.FlaggedByUserId = existing.FlaggedByUserId;
}
else
{
question.Id = ObjectId.GenerateNewId().ToString();
question.CreatedUtc = DateTime.UtcNow;
}
ApplyMediaReviewGate(question);
await _col.ReplaceOneAsync(filter, question, new ReplaceOptions { IsUpsert = true });
count++;
@@ -167,7 +182,7 @@ public class QuestionStore
{
var filter = includeUnpublished
? Builders<Question>.Filter.Empty
: Builders<Question>.Filter.Eq(q => q.IsPublished, true);
: Builders<Question>.Filter.Eq(q => q.IsPublished, true) & Builders<Question>.Filter.Eq(q => q.FlaggedForReviewUtc, null);
var systems = await _col.Distinct(q => q.System, filter).ToListAsync();
return systems.Where(s => !string.IsNullOrWhiteSpace(s)).OrderBy(s => s).ToList();
}
@@ -181,6 +196,45 @@ public class QuestionStore
return requested.Except(existing, StringComparer.Ordinal).OrderBy(id => id).ToList();
}
// One-time bulk sweep: flags every question that has at least one image and isn't already
// flagged, in a single update rather than fetching everything into memory.
public async Task<long> FlagAllWithMediaAsync(string flaggedByUserId, string reason)
{
var filter = Builders<Question>.Filter.Exists("Media.0", true)
& Builders<Question>.Filter.Eq(q => q.FlaggedForReviewUtc, null);
var update = Builders<Question>.Update
.Set(q => q.FlaggedForReviewUtc, DateTime.UtcNow)
.Set(q => q.FlagReason, reason)
.Set(q => q.FlaggedByUserId, flaggedByUserId);
var result = await _col.UpdateManyAsync(filter, update);
return result.ModifiedCount;
}
public async Task<Question?> FlagAsync(string id, string reason, string flaggedByUserId)
{
var update = Builders<Question>.Update
.Set(q => q.FlaggedForReviewUtc, DateTime.UtcNow)
.Set(q => q.FlagReason, reason)
.Set(q => q.FlaggedByUserId, flaggedByUserId);
return await _col.FindOneAndUpdateAsync(q => q.Id == id, update,
new FindOneAndUpdateOptions<Question> { ReturnDocument = ReturnDocument.After });
}
public async Task<Question?> UnflagAsync(string id)
{
var update = Builders<Question>.Update
.Set(q => q.FlaggedForReviewUtc, null)
.Set(q => q.FlagReason, "")
.Set(q => q.FlaggedByUserId, "");
return await _col.FindOneAndUpdateAsync(q => q.Id == id, update,
new FindOneAndUpdateOptions<Question> { ReturnDocument = ReturnDocument.After });
}
public async Task<List<Question>> GetFlaggedAsync() =>
await _col.Find(q => q.FlaggedForReviewUtc != null)
.SortByDescending(q => q.FlaggedForReviewUtc)
.ToListAsync();
public async Task<int> UpdateReferencesByExternalIdAsync(IEnumerable<QuestionReferencesUpdate> updates)
{
var now = DateTime.UtcNow;
@@ -198,6 +252,18 @@ public class QuestionStore
return (int)result.MatchedCount;
}
// Questions with media must be reviewed before reaching students - gate any question that
// gains media and isn't already flagged, so newly created/imported/edited questions can't
// slip past the media-review requirement the way a one-time sweep alone would miss.
internal static void ApplyMediaReviewGate(Question question)
{
if (question.Media.Count == 0 || question.FlaggedForReviewUtc is not null)
return;
question.FlaggedForReviewUtc = DateTime.UtcNow;
question.FlagReason = MediaReviewReason;
question.FlaggedByUserId = MediaReviewFlaggedBy;
}
private static Question FromRequest(UpsertQuestionRequest req) => new()
{
ExternalId = req.ExternalId.Trim(),
@@ -68,11 +68,31 @@ public static class QuestionValidator
if (string.IsNullOrWhiteSpace(req.System)) return "system required";
if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required";
if (req.Difficulty is < 1 or > 5) return "difficulty must be between 1 and 5";
var labValidation = ValidateLabs(req.Labs, req.IsPublished);
if (labValidation is not null) return labValidation;
var referenceValidation = ValidateReferences(req.References, req.IsPublished);
if (referenceValidation is not null) return referenceValidation;
return null;
}
public static string? ValidateLabs(IReadOnlyCollection<QuestionLab>? labs, bool requireComplete)
{
if (labs is null)
return "labs must be an array";
if (labs.Count > 50)
return "questions support at most fifty lab results";
foreach (var lab in labs)
{
if (string.IsNullOrWhiteSpace(lab.Name) || string.IsNullOrWhiteSpace(lab.Value))
return "lab name and value are required";
if (requireComplete && string.IsNullOrWhiteSpace(lab.Reference))
return $"published question lab '{lab.Name.Trim()}' requires a reference range";
}
return null;
}
public static string? ValidateReferences(IReadOnlyCollection<QuestionReference>? references, bool required)
{
if (references is null)
@@ -0,0 +1,9 @@
namespace QuestionsApi.Services;
// Pure student-visibility decision for a question, kept dependency-free (like FlashcardScheduling)
// so it's unit testable without a controller or Mongo. Consolidates what used to be duplicated
// inline across QuestionsController/QuestionReportsController.
public static class QuestionVisibility
{
public static bool IsVisibleToStudent(bool isPublished, bool isFlagged) => isPublished && !isFlagged;
}
+57 -1
View File
@@ -27,14 +27,49 @@ public class RatingStore
public async Task<RatingProfileResponse> GetProfileAsync(string userId)
{
var rating = await GetOrCreateUserRatingAsync(userId);
var (tierName, tierDivision) = GetTier(rating.OverallElo);
return new RatingProfileResponse
{
OverallElo = rating.OverallElo,
RatedQuestionCount = rating.RatedQuestionCount,
CategoryElos = rating.CategoryElos
CategoryElos = rating.CategoryElos,
Tier = tierName,
TierDivision = tierDivision
};
}
private static readonly (string Name, int FloorElo)[] TierBands =
[
("Bronze", MinElo),
("Silver", 1000),
("Gold", 1300),
("Platinum", 1900),
("Diamond", 2200)
];
private static (string Name, int Division) GetTier(int elo)
{
var bandIndex = 0;
for (var i = 0; i < TierBands.Length; i++)
{
if (elo >= TierBands[i].FloorElo)
bandIndex = i;
}
var band = TierBands[bandIndex];
var nextFloor = bandIndex + 1 < TierBands.Length ? TierBands[bandIndex + 1].FloorElo : band.FloorElo + 300;
var bandWidth = nextFloor - band.FloorElo;
var progress = bandWidth > 0 ? (elo - band.FloorElo) / (double)bandWidth : 0;
var division = 3 - Math.Clamp((int)(progress * 3), 0, 2);
return (band.Name, division);
}
public async Task<UserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
{
var rating = await GetOrCreateUserRatingAsync(userId);
return await EnsureHistoryBaselineAsync(rating, nowUtc);
}
public async Task<Dictionary<string, int>> GetQuestionElosAsync(IEnumerable<string> externalIds)
{
var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
@@ -71,6 +106,7 @@ public class RatingStore
};
}
userRating = await EnsureHistoryBaselineAsync(userRating, DateTime.UtcNow);
var actual = isCorrect ? 1.0 : 0.0;
var userK = userRating.RatedQuestionCount < 20 ? 40 : 24;
var questionK = questionRating.AttemptCount < 20 ? 32 : 24;
@@ -164,6 +200,26 @@ public class RatingStore
return rating;
}
private async Task<UserRating> EnsureHistoryBaselineAsync(UserRating rating, DateTime nowUtc)
{
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
return rating;
var result = await _users.UpdateOneAsync(
value => value.UserId == rating.UserId && value.HistoryStartedUtc == null,
Builders<UserRating>.Update
.Set(value => value.HistoryBaselineElo, rating.OverallElo)
.Set(value => value.HistoryStartedUtc, nowUtc));
if (result.ModifiedCount > 0)
{
rating.HistoryBaselineElo = rating.OverallElo;
rating.HistoryStartedUtc = nowUtc;
return rating;
}
return await _users.Find(value => value.UserId == rating.UserId).FirstAsync();
}
private async Task<QuestionRating> GetOrCreateQuestionRatingAsync(string questionExternalId)
{
var create = Builders<QuestionRating>.Update
@@ -129,7 +129,7 @@ public sealed class ReviewQueueStore
foreach (var candidate in candidates)
{
var question = await _questions.GetByExternalIdAsync(candidate.QuestionExternalId);
if (question is { IsPublished: true }) return candidate;
if (question is { IsPublished: true, FlaggedForReviewUtc: null }) return candidate;
await _items.UpdateOneAsync(
value => value.Id == candidate.Id && value.State == ReviewQueueStates.Pending,
Builders<ReviewQueueItem>.Update
@@ -0,0 +1,62 @@
using QuestionsApi.Models;
using QuestionsApi.Services.Flashcards;
namespace QuestionsApi.Services;
// Idempotent, single-target seed for testing "system" (shared, admin-curated) flashcards.
// Unlike ContentPromotionCommand (cross-environment copy, source+target connections), this only
// ever writes to the one database the app is already configured against, using the exact same
// store methods a real user's create/edit path uses - just under the reserved FlashcardOwners.System
// identity - so seeded content is created/updated through the same validation and revision
// handling as any other deck/note.
public static class SeedFlashcardsCommand
{
public static async Task<int> RunAsync(IConfiguration configuration, TextWriter output)
{
var mongo = new MongoDatabaseProvider(configuration);
var scheduleStore = new FlashcardScheduleStore(mongo, new FsrsFlashcardScheduler());
var deckStore = new FlashcardDeckStore(mongo);
var noteStore = new FlashcardNoteStore(mongo, scheduleStore);
var deck = await UpsertSystemDeckAsync(deckStore, "Step 3 Essentials", "Official curated review deck");
await UpsertSystemNoteAsync(noteStore, deck.Id!, FlashcardTypes.Basic,
"A 62-year-old woman presents with sudden, painless, monocular vision loss. What is the diagnosis?",
"Central retinal artery occlusion", "\"Cherry-red spot\" on fundoscopy is the classic finding.");
await UpsertSystemNoteAsync(noteStore, deck.Id!, FlashcardTypes.Basic,
"Thyroid storm: correct order of pharmacologic treatment?",
"Beta-blocker (propranolol) first -> thionamide (PTU/methimazole) -> iodine solution (started >=1hr after thionamide) -> glucocorticoids",
"Giving iodine before the thionamide can fuel new hormone synthesis (Wolff-Chaikoff escape risk).");
await UpsertSystemNoteAsync(noteStore, deck.Id!, FlashcardTypes.Cloze,
"First-line treatment for uncomplicated hypertension in a Black patient is {{c1::a thiazide diuretic or calcium channel blocker}}.",
"", "");
await output.WriteLineAsync($"Seeded system flashcards into deck '{deck.Name}' ({deck.Id}).");
return 0;
}
private static async Task<FlashcardDeck> UpsertSystemDeckAsync(FlashcardDeckStore deckStore, string name, string description)
{
var existing = await deckStore.GetByNameAsync(FlashcardOwners.System, FlashcardDeckStore.Normalize(name));
var request = new UpsertFlashcardDeckRequest { Name = name, Description = description };
var result = await deckStore.UpsertAsync(FlashcardOwners.System, request, existing?.Id);
return result.Deck ?? existing ?? throw new InvalidOperationException($"failed to seed deck '{name}'");
}
private static async Task UpsertSystemNoteAsync(
FlashcardNoteStore noteStore, string deckId, string type, string prompt, string answer, string extra)
{
var fingerprint = FlashcardNoteStore.Fingerprint(type, prompt, answer);
var existingNotes = await noteStore.ListByDeckAsync(FlashcardOwners.System, deckId);
var existing = existingNotes.FirstOrDefault(v => v.ContentFingerprint == fingerprint);
var request = new UpsertFlashcardNoteRequest
{
DeckId = deckId,
Type = type,
Prompt = prompt,
Answer = answer,
Extra = extra,
ExpectedRevision = existing?.Revision
};
await noteStore.UpsertAsync(FlashcardOwners.System, request, existing?.Id);
}
}
@@ -13,6 +13,10 @@ public sealed class ServiceMetrics
private long _httpRequests;
private long _httpDurationMicroseconds;
private long _answerStatisticsBackfillComplete;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicSelections = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicAttempts = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicCorrect = new();
private long _dynamicTargetGapTotal;
public void AttemptCreated() => Interlocked.Increment(ref _attemptsCreated);
public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates);
@@ -29,6 +33,16 @@ public sealed class ServiceMetrics
Interlocked.Increment(ref _httpRequests);
Interlocked.Add(ref _httpDurationMicroseconds, (long)(duration.TotalMilliseconds * 1000));
}
public void DynamicSelected(string tier, int targetGap)
{
_dynamicSelections.AddOrUpdate(tier, 1, (_, value) => value + 1);
Interlocked.Add(ref _dynamicTargetGapTotal, Math.Abs(targetGap));
}
public void DynamicAttempt(string tier, bool correct)
{
_dynamicAttempts.AddOrUpdate(tier, 1, (_, value) => value + 1);
if (correct) _dynamicCorrect.AddOrUpdate(tier, 1, (_, value) => value + 1);
}
public string RenderPrometheus()
{
@@ -39,6 +53,16 @@ public sealed class ServiceMetrics
AppendCounter(output, "step3_rating_failures_total", Interlocked.Read(ref _ratingFailures));
AppendCounter(output, "step3_answer_reconciliations_total", Interlocked.Read(ref _reconciliations));
AppendCounter(output, "step3_http_requests_total", Interlocked.Read(ref _httpRequests));
output.AppendLine("# TYPE step3_dynamic_selections_total counter");
output.AppendLine("# TYPE step3_dynamic_attempts_total counter");
output.AppendLine("# TYPE step3_dynamic_correct_total counter");
foreach (var tier in _dynamicSelections.Keys.Order())
{
AppendLabeledSample(output, "step3_dynamic_selections_total", tier, _dynamicSelections.GetValueOrDefault(tier));
AppendLabeledSample(output, "step3_dynamic_attempts_total", tier, _dynamicAttempts.GetValueOrDefault(tier));
AppendLabeledSample(output, "step3_dynamic_correct_total", tier, _dynamicCorrect.GetValueOrDefault(tier));
}
AppendCounter(output, "step3_dynamic_target_gap_points_total", Interlocked.Read(ref _dynamicTargetGapTotal));
output.AppendLine("# TYPE step3_http_request_duration_seconds summary");
output.Append("step3_http_request_duration_seconds_count ")
.Append(Interlocked.Read(ref _httpRequests))
@@ -62,4 +86,9 @@ public sealed class ServiceMetrics
output.Append("# TYPE ").Append(name).AppendLine(" counter");
output.Append(name).Append(' ').Append(value).AppendLine();
}
private static void AppendLabeledSample(StringBuilder output, string name, string tier, long value)
{
output.Append(name).Append("{tier=\"").Append(tier.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
}
}
+19 -1
View File
@@ -14,9 +14,13 @@ public sealed class UserProfileStore
[
"QuestionAttempts", "UserRatings", "QuestionBookmarks", "UserNotepads",
"ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates",
"DynamicQuestionAttempts", "DynamicObjectiveMastery", "DynamicReviewItems",
"CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations",
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
"ExternalIdentities", "ExternalAuthTransactions"
"ExternalIdentities", "ExternalAuthTransactions",
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
"UserCreditAccounts"
];
public UserProfileStore(MongoDatabaseProvider mongo)
@@ -48,6 +52,20 @@ public sealed class UserProfileStore
new FindOneAndUpdateOptions<UserStudyProfile> { IsUpsert = true, ReturnDocument = ReturnDocument.After });
}
public async Task<UserStudyProfile> UpdateTextScaleAsync(string userId, UpdateTextScaleRequest request)
{
var update = Builders<UserStudyProfile>.Update.Set(value => value.UpdatedUtc, DateTime.UtcNow);
if (request.QuestionTextScale is { } questionScale)
update = update.Set(value => value.QuestionTextScale, questionScale);
if (request.CcsTextScale is { } ccsScale)
update = update.Set(value => value.CcsTextScale, ccsScale);
update = update.SetOnInsert(value => value.UserId, userId);
return await _profiles.FindOneAndUpdateAsync(
value => value.UserId == userId,
update,
new FindOneAndUpdateOptions<UserStudyProfile> { IsUpsert = true, ReturnDocument = ReturnDocument.After });
}
public Task<bool> IsDeletedAsync(string userId) =>
_deletedUsers.Find(value => value.UserId == userId).AnyAsync();
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 972 KiB

After

Width:  |  Height:  |  Size: 894 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 554 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">
<path fill="#FFC107" d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20c11.045 0 20-8.955 20-20 0-1.341-.138-2.65-.389-3.917z"/>
<path fill="#FF3D00" d="M6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z"/>
<path fill="#4CAF50" d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238C29.211 35.091 26.715 36 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z"/>
<path fill="#1976D2" d="M43.611 20.083H42V20H24v8h11.303c-.792 2.237-2.231 4.166-4.087 5.571l6.19 5.238C39.971 34.205 44 29 44 24c0-1.341-.138-2.65-.389-3.917z"/>
</svg>

After

Width:  |  Height:  |  Size: 903 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bc3ttuqtwb8e8"
path="res://.godot/imported/google.svg-96c1802a2f0dcbabfd95c7970a307f34.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/brands/google.svg"
dest_files=["res://.godot/imported/google.svg-96c1802a2f0dcbabfd95c7970a307f34.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ffffff">
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.456-.397.957-1.497 1.41-2.454 1.012L7.54 18.21zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b5f60sr0rt5tw"
path="res://.godot/imported/steam-dark.svg-0b8589df363e0bd197044b86e8158d1c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/brands/steam-dark.svg"
dest_files=["res://.godot/imported/steam-dark.svg-0b8589df363e0bd197044b86e8158d1c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#0F172A">
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.456-.397.957-1.497 1.41-2.454 1.012L7.54 18.21zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b8sp3ihkkfidy"
path="res://.godot/imported/steam-light.svg-43e1d1f7b2cea83339ab43c99a461f9e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/brands/steam-light.svg"
dest_files=["res://.godot/imported/steam-light.svg-43e1d1f7b2cea83339ab43c99a461f9e.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+14
View File
@@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#ffffff"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 7v14" />
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
</svg>

After

Width:  |  Height:  |  Size: 373 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bgv42gr8xm8rf"
path="res://.godot/imported/book-open.svg-683be4c8f98553ef842aedfa9c35759c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/lucide/book-open.svg"
dest_files=["res://.godot/imported/book-open.svg-683be4c8f98553ef842aedfa9c35759c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false
+12
View File
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="16" height="20" x="4" y="2" rx="2"/>
<line x1="8" x2="16" y1="6" y2="6"/>
<line x1="16" x2="16" y1="14" y2="18"/>
<path d="M16 10h.01"/>
<path d="M12 10h.01"/>
<path d="M8 10h.01"/>
<path d="M12 14h.01"/>
<path d="M8 14h.01"/>
<path d="M12 18h.01"/>
<path d="M8 18h.01"/>
</svg>

After

Width:  |  Height:  |  Size: 494 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dy8lr6snglfpm"
path="res://.godot/imported/calculator.svg-0f64f216f45259cc59d80d23ea9a79a8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/lucide/calculator.svg"
dest_files=["res://.godot/imported/calculator.svg-0f64f216f45259cc59d80d23ea9a79a8.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

Some files were not shown because too many files have changed in this diff Show More