Author SHA1 Message Date
hz 030bcf6d7a Fix graphical final orders workflow
Deploy STEP3 UAT / deploy (push) Canceled after 0s
2026-08-14 19:31:31 -05:00
hz 25c2188264 Schedule graphical CCS procedures 2026-08-14 17:54:03 -05:00
hz 375b3ea62f Clarify and filter graphical orders 2026-08-14 17:02:35 -05:00
hz 325d27eec5 Add graphical CCS orders and finalization 2026-08-14 00:53:21 -05:00
hz 7da07cb2a4 Refine physical exam chart layout 2026-08-13 22:21:02 -05:00
hz 6312b45a92 Preserve dialogue after examinations 2026-08-13 20:54:50 -05:00
hz 07195dd24a Prioritize examination modal input 2026-08-12 01:48:15 -05:00
hz 026f0be174 Restore terminal examination review 2026-08-11 22:28:21 -05:00
hz 9a54bfc6d6 Integrate examinations into CCS interview 2026-08-11 22:27:37 -05:00
hz 6108011f74 Refresh stale CCS exam attempts 2026-08-10 21:30:43 -05:00
hz 07516784d2 Add graphical CCS physical examination 2026-08-10 03:27:45 -05:00
hz 579e1e9be4 Refine CCS desktop shortcuts 2026-08-10 01:05:06 -05:00
hz 71f33453d3 Polish CCS computer desktop 2026-08-10 00:45:01 -05:00
hz 33b55753cb Expand CCS patient history 2026-08-09 23:59:28 -05:00
hz d349998d12 Make CCS vitals window draggable 2026-08-09 22:20:20 -05:00
hz 63b9d3c557 Fix CCS vitals layout 2026-08-09 21:35:41 -05:00
hz 065db96211 Add CCS computer vitals app 2026-08-07 22:44:30 -05:00
hz 2b25e25424 Remove CCS viewport letterboxing 2026-08-07 19:17:20 -05:00
hz 91690b2fe9 Scale CCS experience to 16:9 2026-08-07 19:04:16 -05:00
hz e50a97ba91 Add simulated CCS computer 2026-08-07 18:31:00 -05:00
hz 41b3092552 Polish fullscreen CCS dialogue 2026-08-07 17:56:49 -05:00
hz bab73b53ff Pad CCS continuation prompt 2026-08-07 02:33:25 -05:00
hz a4115f4971 Fix CCS scene-wide dialogue clicks 2026-08-06 23:41:42 -05:00
hz 6f81e5af62 Improve CCS dialogue advancement 2026-08-06 22:51:25 -05:00
hz 6e039f00a7 Size CCS speech bubbles to content 2026-08-06 20:39:40 -05:00
hz dfc224660e Constrain CCS patient dialogue 2026-08-06 05:13:21 -05:00
hz 2c7c5dd387 Refine graphical CCS interview flow 2026-08-06 04:58:15 -05:00
hz ee8e8cf787 Improve CCS dialogue layout 2026-08-06 04:41:44 -05:00
hz 4792ff2620 Prevent hidden CCS opening overlays 2026-08-06 04:23:53 -05:00
hz 38d62918d4 Resume stale graphical CCS interviews 2026-08-06 03:34:07 -05:00
hz fa981f68ee Run UAT deploys for smoke test changes 2026-08-06 02:57:44 -05:00
hz 0c330380da Keep UAT CCS smoke on management cases 2026-08-06 02:57:08 -05:00
hz a0dae8e478 Make graphical CCS dialogue choice first 2026-08-06 02:48:44 -05:00
hz 58dd370149 Add graphical duodenal ulcer interview 2026-08-05 23:14:51 -05:00
hz 7709d16018 Show legacy CCS cases in interactive scene 2026-08-05 20:46:55 -05:00
hz af2bbb673d Connect graphical CCS interviews to cases 2026-08-05 20:14:27 -05:00
hz 31093ab80d Open CCS Cases 2 in doctor scene 2026-08-05 18:53:00 -05:00
hz d76b2c809e Add graphical CCS Cases 2 prototype 2026-08-05 18:17:12 -05:00
hz 976cb70d4a Restrict progression features to UAT 2026-08-04 08:08:47 -05:00
hz c140b9a96c Fix CCS popup lifecycle and contrast 2026-08-04 07:10:36 -05:00
hz 690c4a8903 Remove redundant incorrect follow-up label 2026-08-02 20:49:49 -05:00
hz c7f5006cb6 Restore distractor-specific follow-up questions 2026-08-01 18:06:52 -05:00
hz 8c5aa17d89 Add clinical progression ranks and records 2026-08-01 15:17:42 -05:00
hzandClaude Sonnet 5 bc9f68aaf2 Restyle the simulation break and review screens to match the results screen
Both were still on the legacy flat-panel Style system, which made
"Review Questions" on the redesigned results screen drop into a
visibly older-looking screen. Rebuilt both with the same Brand card/
badge/accent-color language: a hero card for the break screen, and
color-coded (green/red-tinted, bordered) choice cards, accent-colored
filter chips, and icon headings for the review screen. Pure re-skin -
same functions, same data flow, only what they render into changed.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-01 04:21:02 -05:00
hz 300b230c03 Add weekly consistency goals 2026-08-01 04:14:39 -05:00
hz ecd5da9162 Add daily study missions 2026-08-01 04:02:24 -05:00
hz 2bd2628009 Add study session goals and summaries 2026-08-01 02:34:58 -05:00
hz ddc36a8048 Add server-authoritative RZ Coin rewards 2026-08-01 01:28:38 -05:00
hz 8c439c6294 Add question bank mastery paths and gap questions 2026-07-31 23:46:46 -05:00
hzandClaude Sonnet 5 012d0cf196 Feature-flag RZ Shop to UAT and remove the redundant account menu
RZ Shop joins the other UAT-only features (slot machine, flashcards,
CCS, dynamic questions) - hidden from the sidebar and no longer
reachable by clicking the header coin/rank badge in production. Also
drop the header's account avatar+chevron dropdown entirely: its only
contents (Profile, Settings, Log out) already live in the sidebar.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-31 08:33:17 -05:00
hz fe54f7b417 Return question runs to dashboard 2026-07-31 08:22:46 -05:00
hz 53b85942d3 Merge main into production 2026-07-30 17:08:31 -05:00
hz 5488dec2c7 Lock submitted simulation answers 2026-07-30 16:54:12 -05:00
hzandClaude Sonnet 5 9709bab9a9 Remove the redundant return-to-study-modes button from exam results
Review Questions is the only action this screen needs - leaving via
the sidebar already covers navigating elsewhere.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-30 16:44:51 -05:00
hz a08d0d9bf9 Fix long answer choice layout 2026-07-30 16:28:44 -05:00
hzandClaude Sonnet 5 be144b272a Restyle exam simulation results to match the RZ design system
The results screen was still the legacy VBoxContainer-of-plain-Labels
placeholder from before the Brand/medmind_style migration - no cards,
icons, or colored bars. Rebuild it with a score hero card (medallion
badge, predicted-range pill), three stat cards (correct, pass
probability, previously seen), and a two-column color-coded
content-area breakdown with progress bars, mirroring patterns already
used in progress_profile.gd and simulation_history.gd. No backend or
data changes - every field was already being fetched, just rendered
as bare text.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-30 16:15:14 -05:00
hz 778da32e73 Fix exam simulation score prediction 2026-07-30 16:00:29 -05:00
hz ae6b37eb7d Make simulation block submission idempotent 2026-07-30 15:30:54 -05:00
hz d5048c155a Add difficulty 4 gap question batch 2026-07-30 15:30:53 -05:00
hzandClaude Sonnet 5 e5f29f0f38 Show the actual time remaining during exam simulations
timer_label and timer_caption_label were fully driven (text, color,
visibility) but never parented into the scene tree, so only the bare
progress bar rendered - no MM:SS readout or "Time remaining"/"Time
elapsed" caption in any session mode. Parent them into timer_footer
alongside the existing bar.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-30 15:22:28 -05:00
hz c63227fcf5 Merge pull request 'Main' (#16) from main into production
Reviewed-on: admin/step3#16
2026-07-30 15:04:21 -05:00
hzandClaude Sonnet 5 9f150baecc Simplify exam simulations and strip the slot machine down to just the machine
Simulations no longer color-code correct/incorrect choices, retry a
failed per-question autosave, or show the RZ coin/ELO badge - users
move on immediately after submitting a timed block, same as a real
exam. The slot machine panel now shows only the cabinet/reels/lever
and a status line (title, banner, controls row, rewards list, and
footer removed), the reels fill the cabinet window instead of
floating in a black border, the coin-burst animation no longer
starts oversized, and the toolbar button is UAT-only via
Brand.is_uat_environment().

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-30 14:50:12 -05:00
hz 94de696af1 Fix shield icon rendering, move slot machine to a movable panel
The shield icon's dark/mushy rendering was caused by disabled mipmaps
on all 6 slot-machine icon textures (inherited from the flat lucide
icon convention, wrong for these gradient-heavy assets) - at small
display sizes this caused aliasing that hit the shield's fine detail
(RZ lettering, metallic bevels) hardest since the other icons are
bolder/simpler shapes. Enabling mipmaps/generate on all 6 fixes it;
confirmed by rendering the actual in-game presentation size before
and after, not just eyeballing the source file.

Also converts the slot machine from a full standalone screen (sidebar
nav entry, main.gd-managed) into a movable/draggable floating panel
opened from the question-player toolbar, mirroring the existing
calculator panel exactly: same top_level+custom title-bar-drag+
viewport-clamp mechanics, same open/close/toggle API, instantiated
once alongside the calculator and closed on session hide. Shrunk the
cabinet and restacked the rewards list below it (was beside it) to
fit a toolbox-sized floating panel instead of a full page, wrapped in
a ScrollContainer for any overflow.

Removed the sidebar entry and screens[] registration from main.gd and
the now-dead routing case from navigation_routes.gd.
2026-07-30 05:18:51 -05:00
hz 98518e8d39 Keep answered follow-ups expanded 2026-07-30 05:11:54 -05:00
hz fa5df8795f Reduce answer review scroll redraws 2026-07-30 05:02:03 -05:00
hz a58aa401c8 Fix slot machine: real spin animation, working lever, honest reels
Three real bugs from user testing:
- The lever was a plain TextureRect with mouse_filter set to IGNORE -
  it visually looked interactive but could never receive a click.
  Swapped it for a TextureButton wired to the same spin action, plus
  a quick pull-and-spring-back rotation for tactile feedback.
- The "reel animation" was just rapidly swapping each cell's texture
  in place - no actual motion. Reels are now real scrolling strips
  (a clipped viewport + a taller VBoxContainer strip that scrolls and
  recycles its front icon to the back), which is what an actual
  spinning reel look/feel requires.
- Every reel unconditionally settled on the coin icon regardless of
  what was actually won, so a jackpot and a QBank Reset Token looked
  identical - the two facts users need to trust the mechanic. Reels
  now settle with the real reward's icon landing in the middle
  (payline) row on all three columns, with random decorative icons
  above/below, so the visual result always matches the actual payout.

Verified the settle math directly (a reel-only probe confirming the
middle icon matches the requested target after recycling) and the
lever's click wiring (confirms _spinning toggles and the disabled
state round-trips), plus a real rendered screenshot of a settled
spin, before cleaning up the probes.
2026-07-30 04:26:42 -05:00
hz 09502944ec Require production approval for question media 2026-07-30 04:16:18 -05:00
hz 3fd670d111 Replace hand-coded cabinet/lever/icons with real generated art
The user generated actual 3D-rendered assets (cabinet frame, lever,
reel-symbol sprite sheet) via an image model - swaps out the
StyleBoxFlat-approximated cabinet, Tween-animated marquee lights/glow,
and gradient-SVG icons from the last pass for the real thing.

The generated PNGs had no real alpha channel (a baked checkerboard
pattern standing in for transparency, a known limitation of some
image-gen output) - added a flood-fill background remover (BFS from
the image border, matching near-white/grey checkerboard tones) to
produce genuinely transparent PNGs, then sliced the sprite sheet into
6 individual icons and measured the cabinet's empty reel-window
rect as a fraction of the frame image so the real 3x3 reel grid and
lever can be positioned precisely over the static art via anchors.

Verified with actual rendered screenshots at each step (window
alignment, then lever sizing/position) rather than just structural
checks, since this is exactly the kind of thing that looks fine in
code and wrong on screen.
2026-07-30 04:06:35 -05:00
hz 0bd74fcabf Temporarily make slot machine spins free for testing
SpinCost set to 0 so spins don't require RZ Coins - revert to 50
once testing is done (marked with a TODO). The frontend already
reads spin cost from GET /api/credits/spin-config rather than
hardcoding it, so the "Cost per spin" display and the Spin button's
enabled state update automatically.
2026-07-30 03:39:44 -05:00
hz 1bc7fbfb3c Generate real glossy reel-symbol art and close the visual gap further
Hand-authored 6 gradient-shaded SVG icons (gold coin, purple gem,
green potion, blue RZ shield, coin stack, gift box) to replace the
flat monochrome placeholder icons in the reels and reward list - no
image-generation tool is available here, so this is genuine vector
art with radial/linear gradients and highlight layers rather than a
literal asset match to the mockup's renders.

Combined with real Godot techniques: a layered-label glow effect on
the "RZ" marquee, chase-lit marquee bulbs down both sides of the
reel screen (looping Tweens), a much more pronounced arch silhouette
and neon border on the cabinet, and an overall scale-up (bigger
reels, marquee, lever) so the machine reads as the dominant element
instead of a cramped placeholder. Verified with an actual rendered
screenshot (not just structural checks) at each iteration.
2026-07-30 01:54:45 -05:00
hz bcb411bed8 Make the slot machine its own sidebar entry; rework its visuals
Moves Slot Machine into the sidebar's account section (alongside RZ
Shop/Settings/Log out) instead of being reachable only from the shop -
removes the shop's promo banner and the screen's "Back to Shop" link
accordingly.

Also closes a chunk of the visual gap against the mockup: a proper
cabinet-style panel (arched top corners, glowing border), a light
"screen" panel holding a real 3x3 reel grid instead of 3 single icons,
a decorative lever, an "RZ" marquee, and the cost/spin/auto-spin
controls each in their own bordered box. Added two new vendored icons
(gem, flask-conical) for reel variety and reward-list icons.
2026-07-30 01:20:24 -05:00
hz 1860e6ede6 Add an RZ Coin slot machine
Adds a spend-coins-for-a-random-reward mini-game reachable from the
shop. Backend mirrors the store's existing atomic/idempotent purchase
pattern (SpinAsync in CreditStore.cs) with a server-authoritative,
weighted reward roll and a rolling per-user spin-rate guard; odds are
served from GET /api/credits/spin-config so the client never hardcodes
numbers that could drift from the real payout table. The mockup's
"Streak Protector" reward was swapped for a real Exam Simulation Token
since streaks aren't currently a persisted, protectable concept.

Also fixes two latent bugs surfaced while building this:
- credits_service.gd's operation-id builder referenced a nonexistent
  AuthState.user_id (should be .username), throwing on every credit
  action.
- _post() silently no-op'd with no signal when signed out, which
  combined with a GDScript closure gotcha (lambdas capture outer
  locals by value, so reassigning inside one never reached the
  caller) meant a spin attempted while unauthenticated could hang
  forever instead of surfacing an error.
2026-07-30 00:29:49 -05:00
hz 4909cf0b71 Move RZ Shop nav entry to the lower account section, rename it
Groups it with Profile/Settings/Log out instead of the primary study
nav, and shortens the label from "RZ Coin Shop" to "RZ Shop".
2026-07-29 21:39:04 -05:00
hz a162ce31c6 Add tab row, feature bullets, and a Popular badge to the RZ Coin Shop
Matches the latest reference more closely: a Featured/Tokens/Boosts/
Content/Customization/Bundles tab row (only Featured is populated -
the rest are inert labels since there's no real data behind them
yet), per-card checkmark feature bullets, and a Popular badge on the
Exam Simulation Token card. Client-side presentation only, no backend
changes.
2026-07-29 20:52:59 -05:00
hz 19371b7a93 Restyle the RZ Coin Shop screen as a card grid
Replaces the plain title/list layout with a header, a coin-balance
summary card, and a product card grid (icon, description, price,
solid Buy button) matching the app's existing card/badge visual
language. Only the two real products render - no fabricated
categories or bundles, since the backend doesn't have that data yet.
2026-07-29 19:01:23 -05:00
hz da641a8d1d Add Rz Coin utility store 2026-07-29 18:34:54 -05:00
180 changed files with 90794 additions and 710 deletions
+4
View File
@@ -98,6 +98,10 @@ if [ "${DEPLOYMENT_ENVIRONMENT}" = "uat" ]; then
echo "Dynamic question promotion failed after 3 attempts." >&2
exit 1
fi
kubectl exec -n "${KUBE_NAMESPACE}" "${API_POD}" -- \
dotnet QuestionsApi.dll promote-ccs-cases \
--source /app/content/ccs/duodenal-ulcer-office-001.json \
--apply
fi
kubectl rollout status deployment/step3-web -n "${KUBE_NAMESPACE}" --timeout=240s
+1 -1
View File
@@ -7,7 +7,7 @@ on:
paths-ignore:
- '**.md'
- 'tools/**'
- 'tests/**'
- 'tests/*.gd'
workflow_dispatch: {}
concurrency:
+35
View File
@@ -77,6 +77,41 @@ Promotion is idempotent and does not delete or implicitly unpublish PRD content.
## Verification and rollback
## Step 3 mastery system backfill
The mastery dashboard uses a canonical 14-system classification stored on each
question. After deploying the mastery-capable API, inspect and apply the
idempotent backfill in UAT:
```bash
kubectl exec deployment/step3-api -n step3-uat -- \
dotnet QuestionsApi.dll backfill-step3-systems
kubectl exec deployment/step3-api -n step3-uat -- \
dotnet QuestionsApi.dll backfill-step3-systems --apply --confirm BACKFILL_STEP3_SYSTEMS
```
Review the reported unmapped count before acceptance testing. Repeat the dry run
and confirmed apply in `step3-prd` after the tested revision is promoted to the
production branch. The command is safe to rerun and does not modify attempts or
user data.
## Rz Coin Store attempt-cycle migration
The QBank reset preserves every attempt and increments a user's eligibility cycle. After deploying
the store-capable API to an environment, inspect and then apply the one-time index migration before
using QBank reset tokens:
```bash
kubectl exec deployment/step3-api -n step3-uat -- \
dotnet QuestionsApi.dll migrate-question-attempt-cycles
kubectl exec deployment/step3-api -n step3-uat -- \
dotnet QuestionsApi.dll migrate-question-attempt-cycles --apply --confirm MIGRATE_ATTEMPT_CYCLES
```
Repeat with namespace `step3-prd` after the tested revision is promoted to `production`. The dry run
reports missing cycle fields and both index states. Apply backfills existing attempts to cycle zero,
creates the three-field unique index, and only then removes the legacy two-field unique index.
Check both internal and public readiness after every rollout:
```bash
+219
View File
@@ -91,6 +91,52 @@ public sealed class CcsCaseTests
Assert.True(request!.Cases.Single().ScoringCriteria.Count >= 12);
}
[Fact]
public void DuodenalUlcerInterviewArtifactMatchesTheCaseSchema()
{
var path = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../../content/ccs/duodenal-ulcer-office-001.json"));
var request = JsonSerializer.Deserialize<CcsImportRequest>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
Assert.NotNull(request);
Assert.Empty(CcsCaseValidator.ValidateImport(request!, new HashSet<string>()));
Assert.Equal(CcsExperienceModes.GraphicalInterview, request.Cases.Single().ExperienceMode);
var topics = request.Cases.Single().InitialInterview.Topics;
Assert.Equal([
"chief_concern", "dizziness_character", "fatigue_course", "abdominal_character",
"relieving_factors", "associated_gi", "exposure_history", "past_medical_history"
], topics.Select(value => value.Id));
Assert.Equal(6, topics.Count(value => value.Required));
Assert.Equal(["fatigue_course"], topics.Single(value => value.Id == "abdominal_character").PrerequisiteTopicIds);
Assert.All(topics.Where(value => value.Id is "relieving_factors" or "associated_gi" or "exposure_history"),
value => Assert.Equal(["abdominal_character"], value.PrerequisiteTopicIds));
Assert.False(topics.Single(value => value.Id == "past_medical_history").Required);
Assert.Contains("cholesterol was high", topics.Single(value => value.Id == "past_medical_history").PatientText,
StringComparison.OrdinalIgnoreCase);
Assert.Equal(4, request.Cases.Single().GraphicalPresentation.InterviewRevision);
Assert.Equal("Hyperlipidemia", CcsStore.MedicalHistory(request.Cases.Single()));
var exams = request.Cases.Single().Exams;
Assert.Equal([
"physical_appearance", "skin", "breasts", "lymph_nodes", "heent_neck", "chest_lungs",
"heart_cardiovascular", "abdomen", "genitalia", "rectal", "extremities_spine", "neuro_psych"
], exams.Select(value => value.Code));
Assert.All(exams, value => Assert.Equal(1, value.DurationMinutes));
Assert.Contains("mild epigastric tenderness", exams.Single(value => value.Code == "abdomen").Finding,
StringComparison.OrdinalIgnoreCase);
Assert.Contains("brown stool", exams.Single(value => value.Code == "rectal").Finding,
StringComparison.OrdinalIgnoreCase);
Assert.Contains(request.Orders, value => value.Code == "h_pylori_breath");
Assert.Contains(request.Orders, value => value.Code == "bismuth_quadruple");
Assert.Contains(request.Cases.Single().OrderResponses,
value => value.OrderCode == "upper_endoscopy" && value.Result.Contains("duodenal", StringComparison.OrdinalIgnoreCase));
var endoscopy = request.Cases.Single().OrderResponses.Single(value => value.OrderCode == "upper_endoscopy");
Assert.Equal("endoscopy_suite", endoscopy.PerformanceLocation);
Assert.True(endoscopy.RequiresPatientVisit);
Assert.All(request.Cases.Single().OrderResponses.Where(value => value.RequiresPatientVisit),
value => Assert.False(string.IsNullOrWhiteSpace(value.PerformanceLocation)));
Assert.Contains(request.Cases.Single().ScoringCriteria,
value => value.TargetCode == "ibuprofen" && value.Points < 0);
}
[Fact]
public void PublishedCaseRequiresReferencesAndKnownOrders()
{
@@ -135,6 +181,150 @@ public sealed class CcsCaseTests
Assert.Empty(CcsCaseValidator.ValidateImport(ValidImport(), new HashSet<string>()));
}
[Fact]
public void InteractiveInterviewRequiresAReachableAcyclicGraph()
{
var request = ValidImport();
request.Cases[0].InitialInterview = ValidInterview();
Assert.Empty(CcsCaseValidator.ValidateImport(request, new HashSet<string>()));
request.Cases[0].InitialInterview.Nodes[0].Choices[0].NextNodeId = "missing";
var errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
Assert.Contains(errors, value => value.Contains("unknown next node", StringComparison.OrdinalIgnoreCase));
request = ValidImport();
request.Cases[0].InitialInterview = ValidInterview();
request.Cases[0].InitialInterview.Nodes[1].IsTerminal = false;
request.Cases[0].InitialInterview.Nodes[1].Choices = [new() { Id = "loop", Text = "Start over", NextNodeId = "greeting" }];
errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
Assert.Contains(errors, value => value.Contains("cycles", StringComparison.OrdinalIgnoreCase));
request = ValidImport();
request.Cases[0].InitialInterview = ValidInterview();
request.Cases[0].InitialInterview.Nodes.Add(new() { Id = "orphan", PatientText = "Unreachable", IsTerminal = true });
errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
Assert.Contains(errors, value => value.Contains("reachable", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void DialogueChoicesPersistAndCompleteWithoutAdvancingSimulatedTime()
{
var definition = ValidImport().Cases[0];
definition.InitialInterview = ValidInterview();
var attempt = new CcsAttempt { InterviewNodeId = "greeting", SimulatedMinute = 7, OpeningCompleted = false };
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow);
Assert.Equal("done", attempt.InterviewNodeId);
Assert.Equal(["ask_reason"], attempt.InterviewChoiceIds);
Assert.Equal(7, attempt.SimulatedMinute);
Assert.True(attempt.OpeningCompleted);
Assert.Contains(attempt.Actions, value => value.Type == "dialogue_choice" && value.Code == "ask_reason");
Assert.Throws<InvalidOperationException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow));
}
[Fact]
public void LegacyCaseGetsInterviewFromExistingChartNarrative()
{
var definition = ValidImport().Cases[0];
definition.InitialInterview = new();
definition.InitialChart.HistorySections =
[
new() { Code = "hpi", Title = "History of present illness", Text = "The symptoms began this morning." },
new() { Code = "medications", Title = "Medications", Text = "I take no regular medicines." }
];
var interview = CcsStore.EffectiveInterview(definition);
var attempt = new CcsAttempt { InterviewNodeId = interview.StartNodeId, OpeningCompleted = false };
Assert.Equal("opening", interview.StartNodeId);
Assert.Equal(5, interview.Nodes.Count);
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_reason", DateTime.UtcNow);
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_history_1", DateTime.UtcNow);
CcsStore.ApplyDialogueChoice(attempt, definition, "ask_history_2", DateTime.UtcNow);
CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow);
Assert.Equal("complete", attempt.InterviewNodeId);
Assert.True(attempt.OpeningCompleted);
}
[Fact]
public void AuthoredInterviewRemainsPreferredOverGeneratedFallback()
{
var definition = ValidImport().Cases[0];
definition.InitialInterview = ValidInterview();
Assert.Same(definition.InitialInterview, CcsStore.EffectiveInterview(definition));
}
[Fact]
public void TopicInterviewBranchesAndUnlocksFinishAfterRequiredTopics()
{
var definition = ValidImport().Cases[0];
definition.InitialInterview = TopicInterview();
var attempt = new CcsAttempt { InterviewNodeId = "opening", OpeningCompleted = false };
var opening = CcsStore.CurrentInterviewNode(attempt, definition)!;
Assert.True(opening.SuppressPatientText);
Assert.Equal(["chief_concern"], opening.Choices.Select(value => value.Id));
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "required_history", DateTime.UtcNow));
CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow);
var chiefConcern = CcsStore.CurrentInterviewNode(attempt, definition)!;
Assert.False(chiefConcern.SuppressPatientText);
Assert.Equal("I feel tired.", chiefConcern.PatientText);
Assert.Equal(["required_history", "optional_history"], chiefConcern.Choices.Select(value => value.Id));
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow));
CcsStore.ApplyDialogueChoice(attempt, definition, "required_history", DateTime.UtcNow);
var finishable = CcsStore.CurrentInterviewNode(attempt, definition)!;
Assert.Contains(finishable.Choices, value => value.Id == "finish_interview");
Assert.DoesNotContain(finishable.Choices, value => value.Id == "chief_concern");
Assert.Throws<ArgumentException>(() => CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow));
CcsStore.ApplyDialogueChoice(attempt, definition, "finish_interview", DateTime.UtcNow);
Assert.True(attempt.OpeningCompleted);
Assert.True(CcsStore.CurrentInterviewNode(attempt, definition)!.IsTerminal);
}
[Fact]
public void TopicInterviewRepairsLegacyOpeningCompletionFlag()
{
var definition = ValidImport().Cases[0];
definition.InitialInterview = TopicInterview();
var attempt = new CcsAttempt { InterviewNodeId = "opening", OpeningCompleted = true };
CcsStore.ApplyDialogueChoice(attempt, definition, "chief_concern", DateTime.UtcNow);
Assert.False(attempt.OpeningCompleted);
Assert.Equal("chief_concern", attempt.InterviewNodeId);
}
[Fact]
public void TopicInterviewRejectsPrerequisiteCycles()
{
var request = ValidImport();
request.Cases[0].InitialInterview = TopicInterview();
request.Cases[0].InitialInterview.Topics[0].PrerequisiteTopicIds = ["required_history"];
var errors = CcsCaseValidator.ValidateImport(request, new HashSet<string>());
Assert.Contains(errors, value => value.Contains("prerequisites cannot contain cycles", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void CaseContentComparisonIgnoresRevisionMetadata()
{
var first = ValidImport().Cases[0];
var second = ValidImport().Cases[0];
first.Id = "507f1f77bcf86cd799439011";
first.Version = 4;
first.IsCurrent = false;
first.CreatedUtc = DateTime.UtcNow.AddDays(-2);
second.InitialChart.SimulatedStartUtc = first.InitialChart.SimulatedStartUtc;
Assert.Equal(CcsStore.CaseContentDocument(first), CcsStore.CaseContentDocument(second));
second.Title = "Changed title";
Assert.NotEqual(CcsStore.CaseContentDocument(first), CcsStore.CaseContentDocument(second));
}
[Fact]
public void CcsExpectedScoreUsesSeparateUserAndCaseElo()
{
@@ -201,4 +391,33 @@ public sealed class CcsCaseTests
}
]
};
private static CcsInitialInterview ValidInterview() => new()
{
StartNodeId = "greeting",
Nodes =
[
new() { Id = "greeting", PatientText = "Hello, doctor.", Choices = [new() { Id = "ask_reason", Text = "What brings you in?", NextNodeId = "done" }] },
new() { Id = "done", PatientText = "Thank you.", IsTerminal = true }
]
};
private static CcsInitialInterview TopicInterview() => new()
{
OpeningPatientText = "Good morning.", ClosingPatientText = "Thank you.", FinishChoiceText = "Finish",
Topics =
[
new() { Id = "chief_concern", ClinicianText = "What brings you in?", PatientText = "I feel tired." },
new()
{
Id = "required_history", ClinicianText = "Tell me more.", PatientText = "It started recently.", Required = true,
PrerequisiteTopicIds = ["chief_concern"]
},
new()
{
Id = "optional_history", ClinicianText = "Anything else?", PatientText = "No.",
PrerequisiteTopicIds = ["chief_concern"]
}
]
};
}
+32 -4
View File
@@ -92,6 +92,33 @@ public sealed class ChoicePoolTests
Assert.Contains("every incorrect choice", QuestionValidator.Validate(request));
}
[Theory]
[InlineData("prompt")]
[InlineData("teaching")]
[InlineData("correct")]
[InlineData("choices")]
public void PublishedQuestionRejectsClonedDistractorFollowups(string duplicate)
{
var request = BuildRequest(5);
var first = request.WrongAnswerFollowups["B"];
var second = request.WrongAnswerFollowups["C"];
if (duplicate == "prompt") second.Prompt = first.Prompt;
if (duplicate == "teaching") second.TeachingPoint = first.TeachingPoint;
if (duplicate == "correct") second.Choices.Single(x => x.Id == second.CorrectChoiceId).Text =
first.Choices.Single(x => x.Id == first.CorrectChoiceId).Text;
if (duplicate == "choices") second.Choices = first.Choices.Select(x => new QuestionChoice { Id=x.Id, Text=x.Text }).ToList();
Assert.Contains("different", QuestionValidator.Validate(request));
}
[Fact]
public void PublishedQuestionRequiresThreeDistractorFollowupChoices()
{
var request = BuildRequest(5);
request.WrongAnswerFollowups["B"].Choices.RemoveAt(2);
Assert.Contains("exactly three", QuestionValidator.Validate(request));
}
[Fact]
public void OptionalReviewFollowupsRequireValidAuthoredContent()
{
@@ -275,14 +302,15 @@ public sealed class ChoicePoolTests
id => id,
id => new WrongAnswerFollowup
{
Prompt = "Which finding makes this less appropriate?",
Prompt = $"Which property of Choice {id} makes it less appropriate?",
Choices =
[
new QuestionChoice { Id = "A", Text = "Finding one" },
new QuestionChoice { Id = "B", Text = "Finding two" }
new QuestionChoice { Id = "A", Text = $"Disqualifying property of Choice {id}" },
new QuestionChoice { Id = "B", Text = $"Plausible property one for Choice {id}" },
new QuestionChoice { Id = "C", Text = $"Plausible property two for Choice {id}" }
],
CorrectChoiceId = "A",
TeachingPoint = "Teaching point"
TeachingPoint = $"Choice {id} fails because of its specific disqualifying property."
}),
ChoiceSelectionPercentages = ids.ToDictionary(id => id, _ => 0),
Discipline = "Medicine",
+47 -1
View File
@@ -8,14 +8,60 @@ public sealed class CreditEconomyTests
[Fact]
public void EarningPolicy_UsesTheAgreedRollingLimit()
{
Assert.Equal(20, CreditEconomy.RollingEarningLimit);
Assert.Equal(30, CreditEconomy.RollingEarningLimit);
Assert.Equal(TimeSpan.FromHours(24), CreditEconomy.EarningWindow);
}
[Theory]
[InlineData(1, 1)]
[InlineData(3, 1)]
[InlineData(4, 2)]
[InlineData(5, 3)]
public void FirstCorrectAwardsScaleWithStoredDifficulty(int difficulty, int expected) =>
Assert.Equal(expected, CreditEconomy.FirstCorrectAmount(difficulty));
[Fact]
public void DelayedRecoveryReplacesTheNormalReviewAward()
{
Assert.Equal(1, CreditEconomy.ReviewAmount(false));
Assert.Equal(3, CreditEconomy.ReviewAmount(true));
}
[Fact]
public void CreditStatus_DoesNotExposeUserOrOperationIdentifiers()
{
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.UserId)));
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.RecentEarnings)));
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.Purchases)));
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.AppliedOperations)));
}
[Fact]
public void PublicLedgerEntry_DoesNotExposeUserOrOperationIdentifiers()
{
Assert.Null(typeof(CreditLedgerEntryResponse).GetProperty(nameof(CreditLedgerEntry.UserId)));
Assert.Null(typeof(CreditLedgerEntryResponse).GetProperty(nameof(CreditLedgerEntry.OperationId)));
Assert.Null(typeof(CreditLedgerEntryResponse).GetProperty(nameof(CreditLedgerEntry.SourceId)));
}
[Fact]
public void CatalogHasStableCoinOnlyUtilityPrices()
{
var exam = CreditEconomy.FindProduct(CreditEconomy.ExamTokenSku);
var reset = CreditEconomy.FindProduct(CreditEconomy.QBankResetTokenSku);
Assert.NotNull(exam);
Assert.Equal(100, exam.CoinPrice);
Assert.Equal(RzStoreGrant.ExamToken, exam.Grant);
Assert.NotNull(reset);
Assert.Equal(300, reset.CoinPrice);
Assert.Equal(RzStoreGrant.QBankResetToken, reset.Grant);
Assert.Equal(2, CreditEconomy.Products.Count);
}
[Fact]
public void UnknownSkuIsRejectedByCatalog()
{
Assert.Null(CreditEconomy.FindProduct("cash_purchase"));
}
}
@@ -0,0 +1,78 @@
using QuestionsApi.Models;
using QuestionsApi.Services;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class DailyMissionPolicyTests
{
private static readonly DateTime Now = new(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void DueReviewsArePrioritizedAndCappedAtTen()
{
var candidates = DailyMissionStore.BuildCandidates(Dashboard(17), Questions(), Now);
Assert.Equal(DailyMissionTypes.DueReviews, candidates[0].Type);
Assert.Equal(10, candidates[0].Target);
}
[Fact]
public void NoReviewsFallsBackToCoverageGaps()
{
var candidates = DailyMissionStore.BuildCandidates(Dashboard(0), Questions(), Now);
Assert.DoesNotContain(candidates, value => value.Type == DailyMissionTypes.DueReviews);
Assert.True(candidates.Count >= 3);
Assert.Contains(candidates.Take(3), value => value.Type == DailyMissionTypes.DifficultyFourCorrect);
Assert.All(candidates.Where(value => value.Type == DailyMissionTypes.WeakSystemCorrect),
value => Assert.Equal(3, value.Target));
}
[Fact]
public void HardMissionRequiresThreeEligiblePublishedQuestions()
{
var questions = Questions();
questions.RemoveAll(value => value.Step3System == Step3SystemCatalog.All[1] && value.Difficulty >= 4);
var candidates = DailyMissionStore.BuildCandidates(Dashboard(0), questions, Now);
Assert.DoesNotContain(candidates, value => value.Type == DailyMissionTypes.DifficultyFourCorrect
&& value.Step3System == Step3SystemCatalog.All[1]);
}
[Fact]
public void QuestionProgressRequiresCorrectEligibleActivityAfterMissionStart()
{
var system = Step3SystemCatalog.All[0];
var mission = new DailyMission
{
Type = DailyMissionTypes.DifficultyFourCorrect, Step3System = system, StartsUtc = Now
};
var question = new Question { Step3System = system, Difficulty = 4 };
Assert.True(DailyMissionStore.QualifiesQuestion(mission,
new QuestionAttempt { IsCorrect = true, CreatedUtc = Now }, question));
Assert.False(DailyMissionStore.QualifiesQuestion(mission,
new QuestionAttempt { IsCorrect = false, CreatedUtc = Now }, question));
Assert.False(DailyMissionStore.QualifiesQuestion(mission,
new QuestionAttempt { IsCorrect = true, IsSkip = true, CreatedUtc = Now }, question));
Assert.False(DailyMissionStore.QualifiesQuestion(mission,
new QuestionAttempt { IsCorrect = true, CreatedUtc = Now.AddSeconds(-1) }, question));
question.Difficulty = 3;
Assert.False(DailyMissionStore.QualifiesQuestion(mission,
new QuestionAttempt { IsCorrect = true, CreatedUtc = Now }, question));
}
private static DashboardResponse Dashboard(int pending) => new()
{
MissedQuestionCount = pending,
Systems = Step3SystemCatalog.All.Take(4).Select((system, index) => new DashboardSystemPerformance
{
System = system, MasteryState = index == 0 ? MasteryStates.Unseen : MasteryStates.Learning,
MasteryScore = index * 10, DistinctQuestions = index, Difficulty4PlusSuccessful = index
}).ToList()
};
private static List<Question> Questions() => Step3SystemCatalog.All.Take(4)
.SelectMany(system => Enumerable.Range(0, 6).Select(index => new Question
{
ExternalId = $"{system}-{index}", Step3System = system, Difficulty = index < 3 ? 4 : 2,
IsPublished = true
})).ToList();
}
+3
View File
@@ -72,6 +72,9 @@ public sealed class DashboardTests
[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")]
[InlineData("Integumentary System", "Skin & Subcutaneous Tissue")]
[InlineData("Eye & Adnexa", "Nervous System & Special Senses")]
[InlineData("Toxicology", "Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders")]
public void SystemAliasesMapToOfficialCategories(string source, string expected)
{
Assert.Equal(expected, DashboardStore.NormalizeStep3System(source));
+154 -3
View File
@@ -44,8 +44,104 @@ public sealed class ExamSimulationTests
Assert.Equal(0, failing.Correct);
}
[Theory]
[InlineData(0, 50)]
[InlineData(20, 100)]
[InlineData(60, 200)]
[InlineData(100, 300)]
public void NeutralDifficultyUsesTransparentScoreAnchors(int correctCount, int expectedScore)
{
var items = BuildItems(100);
var questions = BuildQuestions(100);
var responses = items.Take(correctCount).ToDictionary(
item => item.QuestionExternalId,
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
var score = ExamSimulationScoring.Calculate(items, responses, questions);
Assert.Equal(expectedScore, score.PredictedScore);
Assert.Equal(correctCount / 100.0, score.RawAccuracy, 4);
Assert.Equal(correctCount / 100.0, score.DifficultyAdjustedAccuracy, 4);
}
[Fact]
public void SeenItemsAreReportedAndCarryLessScoringWeight()
public void CorrectHarderQuestionsProduceAHigherAdjustedPrediction()
{
var questions = BuildQuestions(10);
var hardCorrect = BuildItems(10);
var easyCorrect = BuildItems(10);
for (var index = 0; index < 10; index++)
{
hardCorrect[index].QuestionDifficulty = index < 5 ? 5 : 1;
easyCorrect[index].QuestionDifficulty = index < 5 ? 1 : 5;
}
var responses = hardCorrect.Take(5).ToDictionary(
item => item.QuestionExternalId,
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
var hardScore = ExamSimulationScoring.Calculate(hardCorrect, responses, questions);
var easyScore = ExamSimulationScoring.Calculate(easyCorrect, responses, questions);
Assert.Equal(hardScore.RawAccuracy, easyScore.RawAccuracy);
Assert.True(hardScore.DifficultyAdjustedAccuracy > easyScore.DifficultyAdjustedAccuracy);
Assert.True(hardScore.PredictedScore > easyScore.PredictedScore);
}
[Fact]
public void EmpiricalQuestionEloDoesNotAffectPrediction()
{
var questions = BuildQuestions(20);
var lowElos = BuildItems(20);
var highElos = BuildItems(20);
foreach (var item in lowElos) item.QuestionElo = 100;
foreach (var item in highElos) item.QuestionElo = 1900;
var responses = lowElos.Take(10).ToDictionary(
item => item.QuestionExternalId,
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
var lowEloScore = ExamSimulationScoring.Calculate(lowElos, responses, questions);
var highEloScore = ExamSimulationScoring.Calculate(highElos, responses, questions);
Assert.Equal(lowEloScore.PredictedScore, highEloScore.PredictedScore);
Assert.Equal(lowEloScore.PassProbability, highEloScore.PassProbability);
}
[Fact]
public void SkippedAndUnansweredQuestionsCountAsIncorrect()
{
var items = BuildItems(10);
var questions = BuildQuestions(10);
var responses = new Dictionary<string, ExamSimulationResponse>
{
[items[0].QuestionExternalId] = new() { QuestionExternalId = items[0].QuestionExternalId, ChoiceId = "A" },
[items[1].QuestionExternalId] = new() { QuestionExternalId = items[1].QuestionExternalId, ChoiceId = "" }
};
var score = ExamSimulationScoring.Calculate(items, responses, questions);
Assert.Equal(1, score.Correct);
Assert.Equal(10, score.Total);
Assert.Equal(0.1, score.RawAccuracy, 4);
}
[Fact]
public void LowAccuracyFullLengthSimulationIsNotInflated()
{
var items = BuildItems(232);
var questions = BuildQuestions(232);
var responses = items.Take(49).ToDictionary(
item => item.QuestionExternalId,
item => new ExamSimulationResponse { QuestionExternalId = item.QuestionExternalId, ChoiceId = "A" });
var score = ExamSimulationScoring.Calculate(items, responses, questions);
Assert.Equal(103, score.PredictedScore);
Assert.True(score.PassProbability < 0.001);
Assert.True(score.RangeHigh < 130);
}
[Fact]
public void SeenItemsAreReportedButDoNotChangeScoring()
{
var questions = BuildQuestions(20);
var unseen = BuildItems(20);
@@ -59,7 +155,9 @@ public sealed class ExamSimulationTests
Assert.Equal(0, unseenScore.SeenCount);
Assert.Equal(20, seenScore.SeenCount);
Assert.True(unseenScore.AbilityElo > seenScore.AbilityElo);
Assert.Equal(unseenScore.PredictedScore, seenScore.PredictedScore);
Assert.Null(unseenScore.AbilityElo);
Assert.Null(seenScore.AbilityElo);
}
[Theory]
@@ -111,11 +209,64 @@ public sealed class ExamSimulationTests
Assert.False(ExamSimulationStore.TryDecodeHistoryCursor("not-a-cursor", out _));
}
[Theory]
[InlineData(ExamSimulationStates.BetweenBlocks, true)]
[InlineData(ExamSimulationStates.Completed, true)]
[InlineData(ExamSimulationStates.BlockActive, false)]
[InlineData(ExamSimulationStates.Abandoned, false)]
public void RepeatedBlockSubmissionIsIdempotentOnlyAfterSubmission(string state, bool expected)
{
Assert.Equal(expected, ExamSimulationStore.IsAlreadySubmittedState(state));
}
[Fact]
public void SubmittedSimulationResponsesAreLocked()
{
Assert.True(ExamSimulationStore.IsResponseLocked(
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = true }));
Assert.False(ExamSimulationStore.IsResponseLocked(
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = false }));
Assert.True(ExamSimulationStore.IsResponseLocked(
new ExamSimulationResponse { ChoiceId = "A", IsSubmitted = null }));
Assert.False(ExamSimulationStore.IsResponseLocked(
new ExamSimulationResponse { ChoiceId = "", IsSubmitted = null }));
}
[Fact]
public void SubmittedResponseRetriesMustPreserveAnswerContent()
{
var response = new ExamSimulationResponse
{
ChoiceId = "B",
EliminatedChoiceIds = ["A"],
Flagged = true,
IsSubmitted = true
};
var same = new SaveSimulationResponseRequest
{
ChoiceId = "B",
EliminatedChoiceIds = ["A"],
Flagged = true,
IsSubmitted = true
};
var changed = new SaveSimulationResponseRequest
{
ChoiceId = "C",
EliminatedChoiceIds = ["A"],
Flagged = true,
IsSubmitted = true
};
Assert.True(ExamSimulationStore.ResponseContentMatches(response, same, ["A", "B", "C"]));
Assert.False(ExamSimulationStore.ResponseContentMatches(response, changed, ["A", "B", "C"]));
}
private static List<ExamSimulationItem> BuildItems(int count, bool wasSeen = false) =>
Enumerable.Range(0, count).Select(index => new ExamSimulationItem
{
QuestionExternalId = $"question-{index}",
QuestionElo = 1000,
QuestionDifficulty = 3,
WasSeen = wasSeen,
BlueprintCategory = "Cardiovascular"
}).ToList();
@@ -123,5 +274,5 @@ public sealed class ExamSimulationTests
private static Dictionary<string, Question> BuildQuestions(int count) =>
Enumerable.Range(0, count).ToDictionary(
index => $"question-{index}",
index => new Question { ExternalId = $"question-{index}", CorrectChoiceId = "A" });
index => new Question { ExternalId = $"question-{index}", CorrectChoiceId = "A", Difficulty = 3 });
}
@@ -0,0 +1,86 @@
using QuestionsApi.Models;
using QuestionsApi.Services;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class MasteryCalculatorTests
{
private static readonly DateTime Now = new(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void NoEvidenceIsUnseen()
{
var result = MasteryCalculator.Calculate([], [], [], new Dictionary<string, Question>(), Now);
Assert.All(result, item => Assert.Equal(MasteryStates.Unseen, item.MasteryState));
}
[Fact]
public void EvidenceGatesStrongAndMasteredStates()
{
var questions = Enumerable.Range(0, 25).ToDictionary(
index => $"q{index}",
index => Question($"q{index}", index < 5 ? 4 : 3));
var attempts = questions.Keys.Select(id => Attempt(id, true, 0, Now.AddDays(-1))).ToList();
var strong = MasteryCalculator.Calculate(attempts.Take(15), [], [], questions, Now)[6];
var mastered = MasteryCalculator.Calculate(attempts, [], [], questions, Now)[6];
Assert.Equal(MasteryStates.Strong, strong.MasteryState);
Assert.Equal(MasteryStates.Mastered, mastered.MasteryState);
}
[Fact]
public void PendingReviewBlocksMasteredAndBecomesRecommendedAction()
{
var questions = Enumerable.Range(0, 25).ToDictionary(
index => $"q{index}", index => Question($"q{index}", index < 5 ? 4 : 3));
var attempts = questions.Keys.Select(id => Attempt(id, true, 0, Now)).ToList();
var queue = new[] { new ReviewQueueItem { QuestionExternalId = "q0", State = ReviewQueueStates.Pending } };
var result = MasteryCalculator.Calculate(attempts, [], queue, questions, Now)[6];
Assert.Equal(MasteryStates.Strong, result.MasteryState);
Assert.Equal("review", result.RecommendedAction.Type);
}
[Fact]
public void DelayedReviewReceivesPartialRecoveryAndOldReviewDoesNotRecoverNewCycle()
{
var questions = new Dictionary<string, Question> { ["q"] = Question("q", 3) };
var first = Attempt("q", false, 0, Now.AddDays(-10));
var second = Attempt("q", false, 1, Now.AddDays(-2));
var reviews = new[]
{
new QuestionReviewAttempt { QuestionExternalId = "q", IsCorrect = true, CreatedUtc = Now.AddDays(-8) }
};
var result = MasteryCalculator.Calculate([first, second], reviews, [], questions, Now)[6];
Assert.Equal(25, result.MasteryScore);
}
[Fact]
public void StrongMasteryIsMarkedDueWithoutDecayingState()
{
var questions = Enumerable.Range(0, 15).ToDictionary(index => $"q{index}", index => Question($"q{index}", 4));
var attempts = questions.Keys.Select(id => Attempt(id, true, 0, Now.AddDays(-31))).ToList();
var result = MasteryCalculator.Calculate(attempts, [], [], questions, Now)[6];
Assert.Equal(MasteryStates.Strong, result.MasteryState);
Assert.True(result.RefreshDue);
}
[Fact]
public void SkipsDoNotCountAsEvidence()
{
var questions = new Dictionary<string, Question> { ["q"] = Question("q", 5) };
var skip = Attempt("q", false, 0, Now);
skip.IsSkip = true;
var result = MasteryCalculator.Calculate([skip], [], [], questions, Now)[6];
Assert.Equal(MasteryStates.Unseen, result.MasteryState);
}
private static Question Question(string id, int difficulty) => new()
{
ExternalId = id, System = "Cardiovascular System", Step3System = "Cardiovascular System", Difficulty = difficulty
};
private static QuestionAttempt Attempt(string id, bool correct, int cycle, DateTime created) => new()
{
QuestionExternalId = id, UserId = "user", IsCorrect = correct, BankCycle = cycle, CreatedUtc = created
};
}
@@ -0,0 +1,57 @@
using QuestionsApi.Models;
using QuestionsApi.Services;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class ProgressionPolicyTests
{
[Fact]
public void New_learner_starts_as_case_scout_three()
{
Assert.Equal(0, ProgressionPolicy.Qualified([], 0));
Assert.Equal("Case Scout", ProgressionPolicy.Name(0));
Assert.Equal("III", ProgressionPolicy.Division(0));
}
[Fact]
public void Pattern_hunter_requires_every_visible_gate()
{
var systems = Systems(learning: 6, practicing: 2, strong: 0, mastered: 0, hard: 6);
Assert.Equal(1, ProgressionPolicy.Qualified(systems, 0));
Assert.Equal(3, ProgressionPolicy.Qualified(systems, 1));
Assert.Equal("Pattern Hunter", ProgressionPolicy.Name(3));
Assert.Equal("III", ProgressionPolicy.Division(3));
}
[Fact]
public void Highest_division_requires_breadth_challenge_and_retention()
{
var systems = Systems(learning: 14, practicing: 14, strong: 14, mastered: 12, hard: 105);
Assert.Equal(13, ProgressionPolicy.Qualified(systems, 20));
Assert.Equal(14, ProgressionPolicy.Qualified(systems, 21));
Assert.Equal("Clinical Vanguard", ProgressionPolicy.Name(14));
Assert.Equal("I", ProgressionPolicy.Division(14));
}
[Theory]
[InlineData("unseen", 0)] [InlineData("learning", 1)] [InlineData("practicing", 2)]
[InlineData("strong", 3)] [InlineData("mastered", 4)]
public void Mastery_states_have_stable_order(string state, int expected) =>
Assert.Equal(expected, ProgressionPolicy.State(state));
private static List<DashboardSystemPerformance> Systems(int learning, int practicing, int strong, int mastered, int hard)
{
var result = new List<DashboardSystemPerformance>();
for (var i = 0; i < 14; i++)
{
var state = i < mastered ? MasteryStates.Mastered
: i < strong ? MasteryStates.Strong
: i < practicing ? MasteryStates.Practicing
: i < learning ? MasteryStates.Learning : MasteryStates.Unseen;
result.Add(new DashboardSystemPerformance { System = $"s{i}", MasteryState = state });
}
for (var i = 0; i < hard; i++) result[i % result.Count].Difficulty4PlusSuccessful++;
return result;
}
}
@@ -46,4 +46,15 @@ public sealed class QuestionStoreTests
Assert.Equal("Reported by student", question.FlagReason);
Assert.Equal("user-42", question.FlaggedByUserId);
}
[Fact]
public void StudentResponseIncludesCanonicalStep3SystemForLegacyQuestion()
{
var response = QuestionResponse.FromModel(new Question
{
ExternalId = "legacy", System = "Pulmonary", Discipline = "Medicine"
});
Assert.Equal("Respiratory System", response.Step3System);
}
}
@@ -0,0 +1,51 @@
using QuestionsApi.Models;
using QuestionsApi.Services;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class WeeklyGoalPolicyTests
{
[Theory]
[InlineData("2026-08-03", "2026-08-03")]
[InlineData("2026-08-09", "2026-08-03")]
[InlineData("2026-08-10", "2026-08-10")]
public void WeekStartsOnMondayUtc(string value, string expected) =>
Assert.Equal(DateTime.Parse(expected).Date, WeeklyGoalStore.WeekStart(DateTime.Parse(value)));
[Fact]
public void ActiveDaysAreDistinctUtcDates()
{
var values = new[]
{
new DateTime(2026, 8, 3, 1, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 8, 3, 22, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 8, 4, 1, 0, 0, DateTimeKind.Utc)
};
Assert.Equal(2, WeeklyGoalStore.CountActiveDays(values));
}
[Theory]
[InlineData(0, WeeklyRemediationTypes.HardCorrect, 5)]
[InlineData(4, WeeklyRemediationTypes.HardCorrect, 5)]
[InlineData(5, WeeklyRemediationTypes.Reviews, 5)]
[InlineData(25, WeeklyRemediationTypes.Reviews, 15)]
public void RemediationIsFeasibleAndCapped(int available, string type, int target) =>
Assert.Equal((type, target), WeeklyGoalStore.ChooseRemediation(available));
[Fact]
public void HardProgressRequiresCorrectEligibleDifficultyFourAttempt()
{
var system = Step3SystemCatalog.All[0];
var start = new DateTime(2026, 8, 3, 0, 0, 0, DateTimeKind.Utc);
var goal = new WeeklyGoal { WeekStartUtc = start, ExpiresUtc = start.AddDays(7), EligibleSystems = [system] };
var attempt = new QuestionAttempt { IsCorrect = true, CreatedUtc = start.AddDays(1) };
var question = new Question { Step3System = system, Difficulty = 4 };
Assert.True(WeeklyGoalStore.QualifiesHardAttempt(goal, attempt, question));
attempt.IsCorrect = false;
Assert.False(WeeklyGoalStore.QualifiesHardAttempt(goal, attempt, question));
attempt.IsCorrect = true;
question.Difficulty = 3;
Assert.False(WeeklyGoalStore.QualifiesHardAttempt(goal, attempt, question));
}
}
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Services;
using QuestionsApi.Models;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
@@ -8,13 +9,84 @@ namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/credits")]
[Authorize(Roles = "USER,SUPER")]
public sealed class CreditsController(CreditStore credits) : ControllerBase
public sealed class CreditsController(CreditStore credits, ExamSimulationStore simulations) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId()));
[HttpGet("ledger")]
public async Task<IActionResult> Ledger([FromQuery] int limit = 20, [FromQuery] string? before = null)
{
DateTime? cursor = null;
if (!string.IsNullOrWhiteSpace(before))
{
if (!DateTime.TryParse(before, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
return BadRequest(new { code = "invalid_cursor", message = "The ledger cursor is invalid." });
cursor = parsed;
}
return Ok(await credits.GetLedgerAsync(UserId(), limit, cursor));
}
[HttpPost("purchases")]
public async Task<IActionResult> Purchase([FromBody] CreditPurchaseRequest request)
{
var product = CreditEconomy.FindProduct(request.Sku);
if (product is null) return BadRequest(new { code = "unknown_sku", message = "Unknown store product." });
if (!ValidOperationId(request.OperationId))
return BadRequest(new { code = "invalid_operation_id", message = "A valid operation ID is required." });
var result = await credits.PurchaseAsync(UserId(), product, request.OperationId);
return result.Result switch
{
CreditOperationResult.Succeeded => Ok(result.Status),
CreditOperationResult.InsufficientCredits => Conflict(new
{ code = "insufficient_coins", message = "Not enough Rz Coins.", credits = result.Status }),
_ => Conflict(new
{ code = "operation_conflict", message = "That operation ID was already used.", credits = result.Status })
};
}
[HttpPost("qbank-reset")]
public async Task<IActionResult> ResetQBank([FromBody] QBankResetRequest request)
{
if (request.Confirmation != "RESET_QBANK")
return BadRequest(new { code = "confirmation_required", message = "Enter RESET_QBANK to confirm." });
if (!ValidOperationId(request.OperationId))
return BadRequest(new { code = "invalid_operation_id", message = "A valid operation ID is required." });
if (await simulations.FindActiveAsync(UserId()) is not null)
return Conflict(new { code = "active_exam", message = "Finish or end the active exam before resetting the QBank." });
var result = await credits.ApplyQBankResetAsync(UserId(), request.OperationId);
return result.Result == CreditOperationResult.Succeeded
? Ok(result.Status)
: Conflict(new
{ code = "reset_token_required", message = "A QBank Reset Token is required.", credits = result.Status });
}
[HttpGet("spin-config")]
public IActionResult SpinConfig() => Ok(new SpinConfigResponse());
[HttpPost("spin")]
public async Task<IActionResult> Spin([FromBody] SpinRequest request)
{
if (!ValidOperationId(request.OperationId))
return BadRequest(new { code = "invalid_operation_id", message = "A valid operation ID is required." });
var result = await credits.SpinAsync(UserId(), request.OperationId);
return result.Result switch
{
CreditOperationResult.Succeeded => Ok(result),
CreditOperationResult.InsufficientCredits => Conflict(new
{ code = "insufficient_coins", message = "Not enough Rz Coins.", credits = result.Status }),
CreditOperationResult.RateLimited => Conflict(new
{ code = "rate_limited", message = "Too many spins - try again later.", credits = result.Status }),
_ => Conflict(new
{ code = "operation_conflict", message = "That operation ID was already used.", credits = result.Status })
};
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name
?? throw new UnauthorizedAccessException();
private static bool ValidOperationId(string value) =>
!string.IsNullOrWhiteSpace(value) && value.Length <= 100;
}
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/daily-missions")]
[Authorize(Roles = "USER,SUPER")]
public sealed class DailyMissionsController(
DailyMissionStore missions, DashboardStore dashboard, QuestionStore questions) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get()
{
var now = DateTime.UtcNow;
return Ok(await missions.GetOrCreateAsync(UserId(), await dashboard.GetAsync(UserId(), now),
await questions.GetPublishedAsync()));
}
[HttpPost("{missionId}/replace")]
public async Task<IActionResult> Replace(string missionId)
{
try
{
var result = await missions.ReplaceAsync(UserId(), missionId);
return result is null ? NotFound() : Ok(result);
}
catch (ArgumentException exception) { return NotFound(exception.Message); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? throw new UnauthorizedAccessException();
}
@@ -8,7 +8,7 @@ namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/dashboard")]
[Authorize(Roles = "USER,SUPER")]
public sealed class DashboardController(DashboardStore dashboard) : ControllerBase
public sealed class DashboardController(DashboardStore dashboard, ProgressionStore progression) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get()
@@ -18,6 +18,8 @@ public sealed class DashboardController(DashboardStore dashboard) : ControllerBa
?? User.Identity?.Name
?? "";
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
return Ok(await dashboard.GetAsync(userId, DateTime.UtcNow));
var response = await dashboard.GetAsync(userId, DateTime.UtcNow);
response.Progression = await progression.GetAsync(userId);
return Ok(response);
}
}
@@ -9,7 +9,12 @@ namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/exam-simulations")]
[Authorize(Roles = "USER,SUPER")]
public class ExamSimulationsController(ExamSimulationStore simulations) : ControllerBase
public class ExamSimulationsController(
ExamSimulationStore simulations,
CreditStore credits,
MasteryAwardService masteryAwards,
StudySessionStore studySessions,
ProgressionStore progression) : ControllerBase
{
[HttpGet("readiness")]
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
@@ -17,12 +22,33 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
[HttpPost]
public async Task<IActionResult> Create()
{
var userId = UserId();
if (await simulations.FindActiveAsync(userId) is not null)
return Conflict(new { code = "active_exam", message = "An active Step 3 simulation already exists." });
var operationId = $"exam-create:{Guid.NewGuid():N}";
var bypass = User.IsInRole("SUPER");
if (!bypass && !await credits.TryConsumeExamTokenAsync(userId, operationId))
return Conflict(new
{
code = "exam_token_required",
message = "An Exam Simulation Token is required.",
credits = await credits.GetStatusAsync(userId)
});
try
{
var simulation = await simulations.CreateAsync(UserId());
var simulation = await simulations.CreateAsync(userId);
return Ok(await simulations.ToResponseAsync(simulation));
}
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
catch (InvalidOperationException exception)
{
if (!bypass) await credits.RefundExamTokenAsync(userId, operationId);
return Conflict(new { code = "exam_creation_failed", message = exception.Message });
}
catch
{
if (!bypass) await credits.RefundExamTokenAsync(userId, operationId);
throw;
}
}
[HttpGet("preview-results")]
@@ -92,7 +118,12 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
{
var simulation = await simulations.GetOwnedAsync(id, UserId());
if (simulation is null) return NotFound();
try { return Ok(await simulations.ToResponseAsync(await simulations.StartNextBlockAsync(simulation))); }
try
{
simulation = await simulations.StartNextBlockAsync(simulation);
await studySessions.EnsureSimulationBlockAsync(UserId(), simulation.Id ?? id, simulation.CurrentBlock);
return Ok(await simulations.ToResponseAsync(simulation));
}
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
@@ -111,7 +142,36 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
{
var simulation = await simulations.GetOwnedAsync(id, UserId());
if (simulation is null) return NotFound();
try { return Ok(await simulations.ToResponseAsync(await simulations.SubmitBlockAsync(simulation))); }
try
{
var userId = UserId();
await masteryAwards.PrepareAsync(userId);
await progression.PrepareAsync(userId);
simulation = await simulations.SubmitBlockAsync(simulation);
var response = await simulations.ToResponseAsync(simulation);
var session = await studySessions.CompleteSimulationBlockAsync(userId, simulation.Id ?? id, simulation.CurrentBlock);
response.StudySessionProgress = session.Progress;
response.SessionSummary = session.Summary;
if (session.Summary is not null)
response.CreditAwards.AddRange(session.Summary.CoinAwards.Where(value =>
value.ReasonCode == CreditReasonCodes.SessionGoalComplete));
if (simulation.State == ExamSimulationStates.Completed && !simulation.IsSample)
{
var result = await credits.AwardAsync(userId, new CreditAwardRequest(
$"simulation:{simulation.Id}", 10, CreditReasonCodes.ExamSimulationComplete,
"Completed a Step 3 exam simulation", "exam_simulation", simulation.Id ?? "", false));
if (result.Award is not null) response.CreditAwards.Add(result.Award);
response.CreditAwards.AddRange(await masteryAwards.AwardPromotionsAsync(userId));
response.Credits = await credits.GetStatusAsync(userId);
}
else if (session.Summary is not null)
response.Credits = await credits.GetStatusAsync(userId);
var progressionResult = await progression.RefreshAsync(userId);
response.Progression = progressionResult.State;
response.CreditAwards.AddRange(progressionResult.Awards);
if (progressionResult.Awards.Count > 0) response.Credits = await credits.GetStatusAsync(userId);
return Ok(response);
}
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
@@ -0,0 +1,18 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController, Route("api/progression"), Authorize(Roles = "USER,SUPER")]
public sealed class ProgressionController(ProgressionStore progression) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name ?? "";
return string.IsNullOrWhiteSpace(userId) ? Unauthorized() : Ok(await progression.GetAsync(userId));
}
}
@@ -20,6 +20,10 @@ public class QuestionsController : ControllerBase
private readonly QuestionBookmarkStore _bookmarks;
private readonly ReviewQueueStore _reviewQueue;
private readonly CreditStore _credits;
private readonly MasteryAwardService _masteryAwards;
private readonly StudySessionStore _studySessions;
private readonly DailyMissionStore _dailyMissions;
private readonly ProgressionStore _progression;
public QuestionsController(
QuestionStore questions,
@@ -30,7 +34,11 @@ public class QuestionsController : ControllerBase
ServiceMetrics metrics,
QuestionBookmarkStore bookmarks,
ReviewQueueStore reviewQueue,
CreditStore credits)
CreditStore credits,
MasteryAwardService masteryAwards,
StudySessionStore studySessions,
DailyMissionStore dailyMissions,
ProgressionStore progression)
{
_questions = questions;
_attempts = attempts;
@@ -41,6 +49,10 @@ public class QuestionsController : ControllerBase
_bookmarks = bookmarks;
_reviewQueue = reviewQueue;
_credits = credits;
_masteryAwards = masteryAwards;
_studySessions = studySessions;
_dailyMissions = dailyMissions;
_progression = progression;
}
[HttpGet("systems")]
@@ -55,7 +67,9 @@ public class QuestionsController : ControllerBase
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Search([FromBody] QuestionSearchRequest req)
{
var questions = await _questions.SearchAsync(req, User.IsInRole("SUPER"));
List<Question> questions;
try { questions = await _questions.SearchAsync(req, User.IsInRole("SUPER")); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
var percentages = await _statistics.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId));
var questionElos = await _ratings.GetQuestionElosAsync(questions.Select(q => q.ExternalId));
var bookmarkedIds = await _bookmarks.GetBookmarkedIdsAsync(UserId(), questions.Select(q => q.ExternalId));
@@ -150,6 +164,9 @@ public class QuestionsController : ControllerBase
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
await _masteryAwards.PrepareAsync(userId);
await _progression.PrepareAsync(userId);
var record = await _attempts.RecordFirstAttemptAsync(
question.ExternalId,
userId,
@@ -157,7 +174,8 @@ public class QuestionsController : ControllerBase
req.ChoiceId.Trim() == question.CorrectChoiceId,
false,
presentedChoiceIds,
req.DurationSeconds);
req.DurationSeconds,
await _credits.GetBankCycleAsync(userId));
if (record.Inserted)
{
_metrics.AttemptCreated();
@@ -175,8 +193,18 @@ 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 creditAwards = new List<CreditAward>();
if (record.Inserted && record.Attempt.IsCorrect)
{
var amount = CreditEconomy.FirstCorrectAmount(question.Difficulty);
var difficult = question.Difficulty >= 4;
var award = await _credits.AwardAsync(userId, new CreditAwardRequest(
$"answer:{record.Attempt.Id}", amount,
difficult ? CreditReasonCodes.DifficultCorrect : CreditReasonCodes.FirstCorrect,
difficult ? $"Difficulty {question.Difficulty} first correct answer" : "First correct answer",
"question_attempt", record.Attempt.Id ?? ""));
if (award.Award is not null) creditAwards.Add(award.Award);
}
var ratingOwner = Guid.NewGuid().ToString("N");
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
@@ -202,6 +230,19 @@ public class QuestionsController : ControllerBase
throw;
}
creditAwards.AddRange(await _masteryAwards.AwardPromotionsAsync(userId));
StudySessionEnvelope? studySession = null;
if (!string.IsNullOrWhiteSpace(req.StudySessionId))
studySession = await _studySessions.RecordQuestionAsync(
req.StudySessionId, userId, record.Attempt, question, creditAwards);
if (studySession?.Summary is not null)
creditAwards.AddRange(studySession.Summary.CoinAwards.Where(value =>
value.ReasonCode == CreditReasonCodes.SessionGoalComplete
&& creditAwards.All(existing => existing.ReasonCode != value.ReasonCode)));
var dailyMissions = await _dailyMissions.RecordQuestionAsync(userId, record.Attempt, question);
if (dailyMissions is not null) creditAwards.AddRange(dailyMissions.CreditAwards);
var progression = await _progression.RefreshAsync(userId);
creditAwards.AddRange(progression.Awards);
return Ok(new FirstQuestionAttemptResponse
{
QuestionExternalId = question.ExternalId,
@@ -210,7 +251,12 @@ public class QuestionsController : ControllerBase
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo,
Rating = rating,
Credits = await _credits.GetStatusAsync(userId)
Credits = await _credits.GetStatusAsync(userId),
CreditAwards = creditAwards,
StudySessionProgress = studySession?.Progress,
SessionSummary = studySession?.Summary,
DailyMissions = dailyMissions?.State,
Progression = progression.State
});
}
@@ -271,12 +317,15 @@ public class QuestionsController : ControllerBase
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
await _progression.PrepareAsync(userId);
var record = await _attempts.RecordFirstAttemptAsync(
question.ExternalId,
userId,
"",
false,
true);
true,
bankCycle: await _credits.GetBankCycleAsync(userId));
if (record.Inserted)
_metrics.AttemptCreated();
else
@@ -302,6 +351,7 @@ public class QuestionsController : ControllerBase
throw;
}
var progression = await _progression.RefreshAsync(userId);
return Ok(new SkipQuestionResponse
{
QuestionExternalId = question.ExternalId,
@@ -310,7 +360,8 @@ public class QuestionsController : ControllerBase
question,
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo,
Rating = rating
Rating = rating,
Progression = progression.State
});
}
@@ -14,7 +14,12 @@ public sealed class ReviewQueueController(
QuestionStore questions,
AnswerStatisticsStore statistics,
RatingStore ratings,
QuestionBookmarkStore bookmarks) : ControllerBase
QuestionBookmarkStore bookmarks,
CreditStore credits,
MasteryAwardService masteryAwards,
StudySessionStore studySessions,
DailyMissionStore dailyMissions,
ProgressionStore progression) : ControllerBase
{
[HttpPost("next")]
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
@@ -22,9 +27,11 @@ public sealed class ReviewQueueController(
var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
await queue.EnsureUserQueueAsync(userId);
var item = await queue.GetRandomPendingAsync(userId, req.ExcludeExternalIds);
ReviewQueueItem? item;
try { item = await queue.GetRandomPendingAsync(userId, req.ExcludeExternalIds, req.Step3System); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
if (item is null)
return Ok(new ReviewQueueNextResponse { PendingCount = checked((int)await queue.CountPendingAsync(userId)) });
return Ok(new ReviewQueueNextResponse { PendingCount = checked((int)await queue.CountPendingAsync(userId, req.Step3System)) });
var question = await questions.GetByExternalIdAsync(item.QuestionExternalId);
if (question is null || !question.IsPublished) return NotFound();
@@ -39,7 +46,7 @@ public sealed class ReviewQueueController(
{
Question = response,
Cycle = item.Cycle,
PendingCount = checked((int)await queue.CountPendingAsync(userId))
PendingCount = checked((int)await queue.CountPendingAsync(userId, req.Step3System))
});
}
@@ -69,6 +76,8 @@ public sealed class ReviewQueueController(
return BadRequest("presentedChoiceIds must include valid selected and correct choices");
}
await masteryAwards.PrepareAsync(userId);
await progression.PrepareAsync(userId);
var result = await queue.RecordReviewAsync(
userId,
req.ClientAttemptId,
@@ -79,12 +88,44 @@ public sealed class ReviewQueueController(
choiceId == question.CorrectChoiceId,
req.DurationSeconds);
if (result is null) return Conflict("review cycle is no longer active");
var awards = new List<CreditAward>();
var delayed = false;
if (result.Attempt.IsCorrect)
{
delayed = result.Item is not null
&& result.Attempt.CreatedUtc - result.Item.CreatedUtc >= TimeSpan.FromHours(24);
var award = await credits.AwardAsync(userId, new CreditAwardRequest(
$"review:{result.Attempt.Id}", CreditEconomy.ReviewAmount(delayed),
delayed ? CreditReasonCodes.DelayedRecovery : CreditReasonCodes.ScheduledReview,
delayed ? "Delayed recovery of a missed question" : "Correct scheduled review",
"question_review", result.Attempt.Id));
if (award.Award is not null) awards.Add(award.Award);
}
awards.AddRange(await masteryAwards.AwardPromotionsAsync(userId));
StudySessionEnvelope? studySession = null;
if (!string.IsNullOrWhiteSpace(req.StudySessionId))
studySession = await studySessions.RecordReviewAsync(
req.StudySessionId, userId, result.Attempt, delayed, awards);
if (studySession?.Summary is not null)
awards.AddRange(studySession.Summary.CoinAwards.Where(value =>
value.ReasonCode == CreditReasonCodes.SessionGoalComplete
&& awards.All(existing => existing.ReasonCode != value.ReasonCode)));
var missionResult = await dailyMissions.RecordReviewAsync(userId, result.Attempt);
if (missionResult is not null) awards.AddRange(missionResult.CreditAwards);
var progressionResult = await progression.RefreshAsync(userId);
awards.AddRange(progressionResult.Awards);
return Ok(new ReviewQueueAttemptResponse
{
QuestionExternalId = question.ExternalId,
IsCorrect = result.Attempt.IsCorrect,
QueueState = result.Item?.State ?? ReviewQueueStates.Pending,
PendingCount = checked((int)await queue.CountPendingAsync(userId))
PendingCount = checked((int)await queue.CountPendingAsync(userId)),
Credits = await credits.GetStatusAsync(userId),
CreditAwards = awards,
StudySessionProgress = studySession?.Progress,
SessionSummary = studySession?.Summary,
DailyMissions = missionResult?.State,
Progression = progressionResult.State
});
}
@@ -0,0 +1,52 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Models;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/study-sessions")]
[Authorize(Roles = "USER,SUPER")]
public sealed class StudySessionsController(StudySessionStore sessions) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create(CreateStudySessionRequest request)
{
try { return Ok(sessions.Envelope(await sessions.CreateAsync(UserId(), request))); }
catch (ArgumentException exception) { return BadRequest(exception.Message); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
[HttpGet("active")]
public async Task<IActionResult> Active()
{
var session = await sessions.GetActiveAsync(UserId());
return session is null ? NoContent() : Ok(sessions.Envelope(session));
}
[HttpPost("{id}/finish")]
public async Task<IActionResult> Finish(string id)
{
var result = await sessions.FinishEarlyAsync(id, UserId());
return result is null ? NotFound() : Ok(result);
}
[HttpGet("{id}/summary")]
public async Task<IActionResult> Summary(string id)
{
var session = await sessions.GetOwnedAsync(id, UserId());
return session is null ? NotFound() : Ok(sessions.Envelope(session));
}
[HttpPost("{id}/check")]
public async Task<IActionResult> Check(string id)
{
var result = await sessions.CheckAsync(id, UserId());
return result is null ? NotFound() : Ok(result);
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? throw new UnauthorizedAccessException();
}
@@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/weekly-goals")]
[Authorize(Roles = "USER,SUPER")]
public sealed class WeeklyGoalsController(
WeeklyGoalStore goals, DashboardStore dashboard, QuestionStore questions) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get()
{
var userId = UserId();
var now = DateTime.UtcNow;
return Ok(await goals.GetAsync(userId, await dashboard.GetAsync(userId, now),
await questions.GetPublishedAsync()));
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? throw new UnauthorizedAccessException();
}
+2
View File
@@ -9,6 +9,7 @@ RUN dotnet restore "api/QuestionsApi/QuestionsApi.csproj"
COPY api/QuestionsApi/ api/QuestionsApi/
COPY content/dynamic_questions/ content/dynamic_questions/
COPY content/ccs/ content/ccs/
WORKDIR /src/api/QuestionsApi
RUN dotnet publish "QuestionsApi.csproj" -c Release -o /app/publish /p:UseAppHost=false /p:SourceRevisionId=${BUILD_REVISION}
@@ -17,6 +18,7 @@ ARG BUILD_REVISION
WORKDIR /app
COPY --from=build /app/publish .
COPY --from=build /src/content/dynamic_questions ./content/dynamic_questions
COPY --from=build /src/content/ccs ./content/ccs
ENV ASPNETCORE_URLS=http://+:5008 \
ASPNETCORE_ENVIRONMENT=Production \
+96
View File
@@ -64,7 +64,10 @@ public class CcsCaseRevision
public int RealTimeMinutes { get; set; } = 20;
public int Difficulty { get; set; } = 2;
public List<string> Tags { get; set; } = [];
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
public CcsInitialChart InitialChart { get; set; } = new();
public CcsInitialInterview InitialInterview { get; set; } = new();
public List<string> AllowedLocations { get; set; } = [];
public List<CcsExamDefinition> Exams { get; set; } = [];
public List<CcsCaseOrderDefinition> OrderResponses { get; set; } = [];
@@ -74,6 +77,62 @@ public class CcsCaseRevision
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}
public class CcsInitialInterview
{
public string StartNodeId { get; set; } = "";
public List<CcsDialogueNode> Nodes { get; set; } = [];
public string OpeningPatientText { get; set; } = "";
public List<CcsInterviewTopic> Topics { get; set; } = [];
public string FinishChoiceText { get; set; } = "Finish the interview";
public string ClosingPatientText { get; set; } = "Thank you, doctor.";
public CcsInterviewDebrief Debrief { get; set; } = new();
}
public class CcsInterviewTopic
{
public string Id { get; set; } = "";
public string ClinicianText { get; set; } = "";
public string PatientText { get; set; } = "";
public bool Required { get; set; }
public List<string> PrerequisiteTopicIds { get; set; } = [];
}
public class CcsInterviewDebrief
{
public string LikelyDiagnosis { get; set; } = "";
public string Summary { get; set; } = "";
public List<string> TeachingPoints { get; set; } = [];
}
public class CcsGraphicalPresentation
{
public string SceneKey { get; set; } = "";
public string PatientAssetKey { get; set; } = "";
public int InterviewRevision { get; set; }
}
public static class CcsExperienceModes
{
public const string Management = "management";
public const string GraphicalInterview = "graphical_interview";
}
public class CcsDialogueNode
{
public string Id { get; set; } = "";
public string PatientText { get; set; } = "";
public bool SuppressPatientText { get; set; }
public bool IsTerminal { get; set; }
public List<CcsDialogueChoice> Choices { get; set; } = [];
}
public class CcsDialogueChoice
{
public string Id { get; set; } = "";
public string Text { get; set; } = "";
public string NextNodeId { get; set; } = "";
}
public class CcsInitialChart
{
public string PatientSummary { get; set; } = "";
@@ -105,6 +164,8 @@ public class CcsCaseOrderDefinition
{
public string OrderCode { get; set; } = "";
public int? TurnaroundMinutes { get; set; }
public string PerformanceLocation { get; set; } = "";
public bool RequiresPatientVisit { get; set; }
public string Result { get; set; } = "";
public string ResultSection { get; set; } = "";
public Dictionary<string, string> VitalPatches { get; set; } = [];
@@ -148,6 +209,8 @@ public class CcsAttempt
public int Revision { get; set; }
public int SimulatedMinute { get; set; }
[BsonIgnoreIfNull] public bool? OpeningCompleted { get; set; }
public string InterviewNodeId { get; set; } = "";
public List<string> InterviewChoiceIds { get; set; } = [];
public string Location { get; set; } = "";
public Dictionary<string, string> CurrentVitals { get; set; } = [];
public List<CcsAttemptAction> Actions { get; set; } = [];
@@ -182,6 +245,8 @@ public class CcsPendingResult
public string Category { get; set; } = "";
public string Title { get; set; } = "";
public int DueMinute { get; set; }
public string PerformanceLocation { get; set; } = "";
public bool RequiresPatientVisit { get; set; }
public string Result { get; set; } = "";
public Dictionary<string, string> VitalPatches { get; set; } = [];
}
@@ -210,10 +275,22 @@ public class CcsActiveOrder
public string Frequency { get; set; } = "";
public int OrderedMinute { get; set; }
public int? ReportMinute { get; set; }
public int? DueMinute { get; set; }
public string PerformanceLocation { get; set; } = "";
public bool RequiresPatientVisit { get; set; }
public string Status { get; set; } = "active";
public bool IsPending { get; set; }
public bool HasResult { get; set; }
}
public class CcsOrderOption
{
public string Code { get; set; } = "";
public int TurnaroundMinutes { get; set; }
public string PerformanceLocation { get; set; } = "";
public bool RequiresPatientVisit { get; set; }
}
public class CcsCaseScore
{
public double Percent { get; set; }
@@ -246,6 +323,7 @@ public class CcsImportResponse
{
public int OrdersUpserted { get; set; }
public int CasesCreated { get; set; }
public int CasesSkipped { get; set; }
public List<string> Errors { get; set; } = [];
}
@@ -287,6 +365,10 @@ public class CcsCaseSummary
public int RealTimeMinutes { get; set; }
public int Difficulty { get; set; }
public List<string> Tags { get; set; } = [];
public bool InteractiveInterviewAvailable { get; set; }
public bool GraphicalPresentationAvailable { get; set; }
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
}
public class CcsAttemptResponse
@@ -307,24 +389,38 @@ public class CcsAttemptResponse
public DateTime CreatedUtc { get; set; }
public int RealTimeMinutes { get; set; }
public int ElapsedSeconds { get; set; }
public string ExperienceMode { get; set; } = CcsExperienceModes.Management;
public CcsGraphicalPresentation GraphicalPresentation { get; set; } = new();
public string PatientSummary { get; set; } = "";
public string ReasonForEncounter { get; set; } = "";
public string Appearance { get; set; } = "";
public string History { get; set; } = "";
public List<CcsNarrativeSection> HistorySections { get; set; } = [];
public string MedicalHistory { 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<CcsOrderOption> OrderOptions { 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; } = [];
public CcsInterviewStateResponse? Interview { get; set; }
public CcsInterviewDebrief? InterviewDebrief { get; set; }
public CcsCaseScore? Score { get; set; }
public CcsRatingChange? Rating { get; set; }
public List<QuestionReference> References { get; set; } = [];
}
public class CcsInterviewStateResponse
{
public string CurrentNodeId { get; set; } = "";
public CcsDialogueNode CurrentNode { get; set; } = new();
public List<string> VisitedChoiceIds { get; set; } = [];
public bool Completed { get; set; }
}
public class CcsHistoryItem
{
public string Id { get; set; } = "";
+225 -1
View File
@@ -5,13 +5,175 @@ namespace QuestionsApi.Models;
public static class CreditEconomy
{
public const int RollingEarningLimit = 20;
public const int RollingEarningLimit = 30;
public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24);
public const string ExamTokenSku = "exam_token_1";
public const string QBankResetTokenSku = "qbank_reset_1";
public const int ExamTokenPrice = 100;
public const int QBankResetTokenPrice = 300;
public static readonly IReadOnlyList<RzStoreProduct> Products =
[
new()
{
Sku = ExamTokenSku,
Name = "Exam Simulation Token",
Description = "Starts one new full exam simulation.",
CoinPrice = ExamTokenPrice,
Grant = RzStoreGrant.ExamToken
},
new()
{
Sku = QBankResetTokenSku,
Name = "QBank Reset Token",
Description = "Makes every QBank question eligible again without erasing progress.",
CoinPrice = QBankResetTokenPrice,
Grant = RzStoreGrant.QBankResetToken
}
];
public static RzStoreProduct? FindProduct(string sku) =>
Products.FirstOrDefault(value => value.Sku.Equals(sku, StringComparison.Ordinal));
public static int FirstCorrectAmount(int difficulty) => difficulty >= 5 ? 3 : difficulty >= 4 ? 2 : 1;
public static int ReviewAmount(bool delayedRecovery) => delayedRecovery ? 3 : 1;
}
public static class SlotMachineEconomy
{
public const int SpinCost = 0; // TODO: restore to 50 once testing is done
public const int RollingSpinLimit = 200;
public static readonly TimeSpan SpinWindow = TimeSpan.FromHours(24);
public static readonly IReadOnlyList<SpinReward> Rewards =
[
new() { Key = "jackpot", Label = "10,000 RZ Coins", Description = "Jackpot!", Grant = RzStoreGrant.Coins, CoinAmount = 10000, Probability = 0.005 },
new() { Key = "epic", Label = "2,000 RZ Coins", Description = "Epic Reward", Grant = RzStoreGrant.Coins, CoinAmount = 2000, Probability = 0.02 },
new() { Key = "great", Label = "500 RZ Coins", Description = "Great Reward", Grant = RzStoreGrant.Coins, CoinAmount = 500, Probability = 0.10 },
new() { Key = "exam_token", Label = "Exam Simulation Token", Description = "Starts one new full exam simulation.", Grant = RzStoreGrant.ExamToken, Probability = 0.15 },
new() { Key = "qbank_reset", Label = "QBank Reset Token", Description = "Makes every QBank question eligible again.", Grant = RzStoreGrant.QBankResetToken, Probability = 0.20 },
new() { Key = "nice", Label = "100 RZ Coins", Description = "Nice!", Grant = RzStoreGrant.Coins, CoinAmount = 100, Probability = 0.30 },
new() { Key = "good_luck", Label = "50 RZ Coins", Description = "Good luck!", Grant = RzStoreGrant.Coins, CoinAmount = 50, Probability = 0.225 },
];
public static SpinReward RollReward(double roll)
{
var cumulative = 0.0;
foreach (var reward in Rewards)
{
cumulative += reward.Probability;
if (roll < cumulative) return reward;
}
return Rewards[^1];
}
public static SpinReward? FindReward(string key) =>
Rewards.FirstOrDefault(value => value.Key.Equals(key, StringComparison.Ordinal));
}
public enum RzStoreGrant { ExamToken, QBankResetToken, Coins }
public sealed class SpinReward
{
public string Key { get; set; } = "";
public string Label { get; set; } = "";
public string Description { get; set; } = "";
public RzStoreGrant Grant { get; set; }
public int CoinAmount { get; set; }
public double Probability { get; set; }
}
public sealed class RzStoreProduct
{
public string Sku { get; set; } = "";
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public int CoinPrice { get; set; }
public RzStoreGrant Grant { get; set; }
}
public sealed class CreditEvent
{
public string OperationId { get; set; } = "";
public int CoinAmount { get; set; } = 1;
public DateTime CreatedUtc { get; set; }
}
public static class CreditReasonCodes
{
public const string FirstCorrect = "qbank_first_correct";
public const string DifficultCorrect = "qbank_difficult_correct";
public const string ScheduledReview = "scheduled_review_correct";
public const string DelayedRecovery = "delayed_recovery";
public const string MasteryPracticing = "mastery_practicing";
public const string MasteryStrong = "mastery_strong";
public const string MasteryMastered = "mastery_mastered";
public const string ExamSimulationComplete = "exam_simulation_complete";
public const string SessionGoalComplete = "session_goal_complete";
public const string DailyMissionComplete = "daily_mission_complete";
public const string WeeklyGoalComplete = "weekly_goal_complete";
public const string RankPromotion = "rank_promotion";
public const string Purchase = "purchase";
}
public sealed class CreditLedgerEntry
{
[BsonId]
public string Id { get; set; } = "";
public string UserId { get; set; } = "";
public string OperationId { get; set; } = "";
public int RequestedAmount { get; set; }
public int Amount { get; set; }
public string ReasonCode { get; set; } = "";
public string Description { get; set; } = "";
public string SourceType { get; set; } = "";
public string SourceId { get; set; } = "";
public bool SubjectToRollingCap { get; set; }
public string State { get; set; } = "pending";
public DateTime CreatedUtc { get; set; }
public DateTime? AppliedUtc { get; set; }
}
public sealed class CreditAward
{
public int Amount { get; set; }
public string ReasonCode { get; set; } = "";
public string Description { get; set; } = "";
}
public sealed class CreditAwardResult
{
public CreditAward? Award { get; set; }
public CreditStatusResponse Status { get; set; } = new();
}
public sealed class CreditLedgerPage
{
public List<CreditLedgerEntryResponse> Items { get; set; } = [];
public DateTime? NextBefore { get; set; }
}
public sealed class CreditLedgerEntryResponse
{
public int Amount { get; set; }
public string ReasonCode { get; set; } = "";
public string Description { get; set; } = "";
public DateTime CreatedUtc { get; set; }
}
public sealed class CreditPurchaseEvent
{
public string OperationId { get; set; } = "";
public string Sku { get; set; } = "";
public int CoinCost { get; set; }
public DateTime CreatedUtc { get; set; }
}
public sealed class SpinEvent
{
public string OperationId { get; set; } = "";
public string RewardKey { get; set; } = "";
public int CoinsWon { get; set; }
public DateTime CreatedUtc { get; set; }
}
@@ -22,7 +184,16 @@ public sealed class UserCreditAccount
public string? Id { get; set; }
public string UserId { get; set; } = "";
public int Credits { get; set; }
public int ExamTokens { get; set; }
public int QBankResetTokens { get; set; }
public int BankCycle { get; set; }
public bool InitialExamGrantApplied { get; set; }
public bool MasteryAwardBaselineApplied { get; set; }
public List<CreditEvent> RecentEarnings { get; set; } = [];
public List<CreditPurchaseEvent> Purchases { get; set; } = [];
public List<SpinEvent> Spins { get; set; } = [];
public List<CreditEvent> RecentSpins { get; set; } = [];
public List<string> AppliedOperations { get; set; } = [];
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
@@ -32,4 +203,57 @@ public sealed class CreditStatusResponse
public int EarnedInRollingWindow { get; set; }
public int RemainingEarningCapacity { get; set; }
public int RollingWindowHours { get; set; } = 24;
public int ExamTokens { get; set; }
public int QBankResetTokens { get; set; }
public int BankCycle { get; set; }
public IReadOnlyList<RzStoreProduct> Products { get; set; } = CreditEconomy.Products;
}
public sealed record CreditAwardRequest(
string OperationId,
int Amount,
string ReasonCode,
string Description,
string SourceType,
string SourceId,
bool SubjectToRollingCap = true);
public sealed class CreditPurchaseRequest
{
public string Sku { get; set; } = "";
public string OperationId { get; set; } = "";
}
public sealed class QBankResetRequest
{
public string Confirmation { get; set; } = "";
public string OperationId { get; set; } = "";
}
public sealed class SpinRequest
{
public string OperationId { get; set; } = "";
}
public enum CreditOperationResult { Succeeded, InsufficientCredits, InsufficientTokens, OperationConflict, RateLimited }
public sealed class CreditOperationResponse
{
public CreditOperationResult Result { get; set; }
public CreditStatusResponse Status { get; set; } = new();
}
public sealed class SpinConfigResponse
{
public int SpinCost { get; set; } = SlotMachineEconomy.SpinCost;
public IReadOnlyList<SpinReward> Rewards { get; set; } = SlotMachineEconomy.Rewards;
}
public sealed class SpinResponse
{
public CreditOperationResult Result { get; set; }
public string RewardKey { get; set; } = "";
public string RewardLabel { get; set; } = "";
public int CoinsWon { get; set; }
public CreditStatusResponse Status { get; set; } = new();
}
@@ -0,0 +1,64 @@
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class DailyMissionTypes
{
public const string DueReviews = "due_reviews";
public const string WeakSystemCorrect = "weak_system_correct";
public const string DifficultyFourCorrect = "difficulty_four_correct";
}
public sealed class DailyMission
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Type { get; set; } = "";
public string Title { get; set; } = "";
public string Step3System { get; set; } = "";
public int DifficultyMin { get; set; }
public int Target { get; set; }
public int Progress { get; set; }
public DateTime StartsUtc { get; set; }
public DateTime? CompletedUtc { get; set; }
public List<string> AppliedEventIds { get; set; } = [];
}
public sealed class DailyMissionSet
{
[BsonId]
public string Id { get; set; } = "";
public string UserId { get; set; } = "";
public string DateUtc { get; set; } = "";
public List<DailyMission> Missions { get; set; } = [];
public List<DailyMission> ReplacementCandidates { get; set; } = [];
public bool ReplacementUsed { get; set; }
public bool ExpirationRecorded { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime ExpiresUtc { get; set; }
}
public sealed class DailyMissionResponse
{
public string Id { get; set; } = "";
public string Type { get; set; } = "";
public string Title { get; set; } = "";
public string Step3System { get; set; } = "";
public int DifficultyMin { get; set; }
public int Target { get; set; }
public int Progress { get; set; }
public bool Completed { get; set; }
}
public sealed class DailyMissionSetResponse
{
public string DateUtc { get; set; } = "";
public DateTime ExpiresUtc { get; set; }
public bool ReplacementAvailable { get; set; }
public List<DailyMissionResponse> Missions { get; set; } = [];
}
public sealed class DailyMissionEnvelope
{
public DailyMissionSetResponse State { get; set; } = new();
public List<CreditAward> CreditAwards { get; set; } = [];
}
@@ -31,6 +31,7 @@ public sealed class DashboardResponse
public List<DashboardSystemPerformance> Systems { get; set; } = [];
public List<DashboardActivityDay> WeeklyActivity { get; set; } = [];
public List<DashboardRecentActivity> RecentActivity { get; set; } = [];
public ProgressionResponse? Progression { get; set; }
}
public sealed class DashboardTrendPoint
@@ -49,6 +50,32 @@ public sealed class DashboardSystemPerformance
public int Correct { get; set; }
public int Total { get; set; }
public int Accuracy { get; set; }
public int MasteryScore { get; set; }
public string MasteryState { get; set; } = MasteryStates.Unseen;
public int DistinctQuestions { get; set; }
public int Difficulty4PlusSuccessful { get; set; }
public int PendingReviews { get; set; }
public DateTime? LastEvidenceUtc { get; set; }
public bool RefreshDue { get; set; }
public DashboardMasteryAction RecommendedAction { get; set; } = new();
}
public static class MasteryStates
{
public const string Unseen = "unseen";
public const string Learning = "learning";
public const string Practicing = "practicing";
public const string Strong = "strong";
public const string Mastered = "mastered";
}
public sealed class DashboardMasteryAction
{
public string Type { get; set; } = "practice";
public string Label { get; set; } = "Start learning";
public int QuestionCount { get; set; } = 10;
public int? DifficultyMin { get; set; }
public int? DifficultyMax { get; set; }
}
public sealed class DashboardActivityDay
+11 -1
View File
@@ -41,6 +41,7 @@ public class ExamSimulationItem
public int Position { get; set; }
public List<string> PresentedChoiceIds { get; set; } = [];
public int QuestionElo { get; set; } = 1000;
public int QuestionDifficulty { get; set; }
public bool WasSeen { get; set; }
public string BlueprintCategory { get; set; } = "Other";
}
@@ -52,6 +53,7 @@ public class ExamSimulationResponse
public List<string> EliminatedChoiceIds { get; set; } = [];
public bool Flagged { get; set; }
public int ElapsedSeconds { get; set; }
public bool? IsSubmitted { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
}
@@ -64,7 +66,9 @@ public class ExamSimulationScore
public int Correct { get; set; }
public int Total { get; set; }
public int SeenCount { get; set; }
public double AbilityElo { get; set; }
public double RawAccuracy { get; set; }
public double DifficultyAdjustedAccuracy { get; set; }
public double? AbilityElo { get; set; }
public Dictionary<string, ExamSimulationCategoryScore> Categories { get; set; } = [];
}
@@ -81,6 +85,7 @@ public class SaveSimulationResponseRequest
public List<string> EliminatedChoiceIds { get; set; } = [];
public bool Flagged { get; set; }
public int ElapsedSeconds { get; set; }
public bool IsSubmitted { get; set; }
}
public class ExamSimulationQuestionResponse
@@ -112,6 +117,11 @@ public class ExamSimulationStateResponse
public Dictionary<string, int> BlueprintTargets { get; set; } = [];
public Dictionary<string, int> BlueprintActual { get; set; } = [];
public List<QuestionResponse> ReviewQuestions { get; set; } = [];
public CreditStatusResponse? Credits { get; set; }
public List<CreditAward> CreditAwards { get; set; } = [];
public StudySessionProgress? StudySessionProgress { get; set; }
public StudySessionSummary? SessionSummary { get; set; }
public ProgressionResponse? Progression { get; set; }
}
public class ExamSimulationReadinessResponse
@@ -0,0 +1,98 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public sealed class UserProgression
{
[BsonId, BsonRepresentation(BsonType.ObjectId)] public string? Id { get; set; }
public string UserId { get; set; } = "";
public int HighestRankOrdinal { get; set; }
public DateTime TrackingStartedUtc { get; set; }
public bool BaselineApplied { get; set; }
public Dictionary<string, DateTime> EarnedRanksUtc { get; set; } = [];
public int CurrentHardStreak { get; set; }
public int BestHardStreak { get; set; }
public DateTime? BestHardStreakUtc { get; set; }
public PersonalRecordValue BestTimedBlock { get; set; } = new();
public PersonalRecordValue SystemsAdvancedWeek { get; set; } = new();
public PersonalRecordValue ReviewQueueReduction { get; set; } = new();
public PersonalRecordValue WeakSystemImprovement { get; set; } = new();
public DateTime UpdatedUtc { get; set; }
}
public sealed class PersonalRecordValue
{
public int Value { get; set; }
public int SampleSize { get; set; }
public string Detail { get; set; } = "";
public DateTime? AchievedUtc { get; set; }
}
public sealed class ProgressionWeeklySnapshot
{
[BsonId] public string Id { get; set; } = "";
public string UserId { get; set; } = "";
public DateTime WeekStartUtc { get; set; }
public List<ProgressionSystemSnapshot> OpeningSystems { get; set; } = [];
public List<ProgressionSystemSnapshot> LatestSystems { get; set; } = [];
public int OpeningPendingReviews { get; set; }
public int LatestPendingReviews { get; set; }
public DateTime UpdatedUtc { get; set; }
}
public sealed class ProgressionSystemSnapshot
{
public string System { get; set; } = "";
public int StateOrdinal { get; set; }
public int MasteryScore { get; set; }
public int DistinctQuestions { get; set; }
}
public sealed class ProgressionRequirementResponse
{
public string Key { get; set; } = "";
public string Label { get; set; } = "";
public int Current { get; set; }
public int Required { get; set; }
public bool Complete => Current >= Required;
}
public sealed class ProgressionRecordResponse
{
public string Key { get; set; } = "";
public string Label { get; set; } = "";
public string Definition { get; set; } = "";
public int Value { get; set; }
public int SampleSize { get; set; }
public string Detail { get; set; } = "";
public DateTime? AchievedUtc { get; set; }
public bool TrackingFromLaunch { get; set; }
}
public sealed class ProgressionResponse
{
public int RankOrdinal { get; set; }
public int QualifiedRankOrdinal { get; set; }
public string RankName { get; set; } = "Case Scout";
public string Division { get; set; } = "III";
public string DisplayName => $"{RankName} {Division}";
public DateTime TrackingStartedUtc { get; set; }
public List<string> EarnedBadges { get; set; } = [];
public Dictionary<string, DateTime> EarnedRanksUtc { get; set; } = [];
public List<ProgressionRankResponse> Ladder { get; set; } = [];
public string? NextRankName { get; set; }
public string? NextDivision { get; set; }
public List<ProgressionRequirementResponse> NextRequirements { get; set; } = [];
public List<ProgressionRecordResponse> Records { get; set; } = [];
}
public sealed class ProgressionRankResponse
{
public int Ordinal { get; set; }
public string RankName { get; set; } = "";
public string Division { get; set; } = "";
public bool Earned { get; set; }
public DateTime? EarnedUtc { get; set; }
public string RequirementSummary { get; set; } = "";
}
+1
View File
@@ -23,6 +23,7 @@ public class Question
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public string Discipline { get; set; } = "";
public string System { get; set; } = "";
public string Step3System { get; set; } = "";
public string PerformanceCategory { get; set; } = "";
public int Difficulty { get; set; } = 1;
public List<QuestionLab> Labs { get; set; } = [];
@@ -11,6 +11,7 @@ public class QuestionAttempt
public string QuestionExternalId { get; set; } = "";
public string UserId { get; set; } = "";
public int BankCycle { get; set; }
public string ChoiceId { get; set; } = "";
public List<string> PresentedChoiceIds { get; set; } = [];
public bool TracksPresentation { get; set; }
@@ -3,6 +3,7 @@ namespace QuestionsApi.Models;
public class QuestionSearchRequest
{
public string? System { get; set; }
public string? Step3System { get; set; }
public string? Discipline { get; set; }
public List<string> Tags { get; set; } = [];
public int? DifficultyMin { get; set; }
@@ -50,4 +51,5 @@ public class FirstQuestionAttemptRequest
public List<string> PresentedChoiceIds { get; set; } = [];
public int DurationSeconds { get; set; }
public bool MetTimeLimit { get; set; } = true;
public string? StudySessionId { get; set; }
}
@@ -1,3 +1,5 @@
using QuestionsApi.Services;
namespace QuestionsApi.Models;
public class QuestionResponse
@@ -18,6 +20,7 @@ public class QuestionResponse
public string ChoiceSelectionRateBasis { get; set; } = "presented";
public string Discipline { get; set; } = "";
public string System { get; set; } = "";
public string Step3System { get; set; } = "";
public string PerformanceCategory { get; set; } = "";
public int Difficulty { get; set; }
public int QuestionElo { get; set; } = 1000;
@@ -59,6 +62,7 @@ public class QuestionResponse
.ToDictionary(),
Discipline = question.Discipline,
System = question.System,
Step3System = Step3SystemCatalog.Normalize(question) ?? "",
PerformanceCategory = RatingCategory.Normalize(question.PerformanceCategory, question.System, question.Discipline),
Difficulty = question.Difficulty,
QuestionElo = questionElo,
@@ -142,6 +146,11 @@ public class FirstQuestionAttemptResponse
public int QuestionElo { get; set; } = 1000;
public RatingChangeResponse? Rating { get; set; }
public CreditStatusResponse? Credits { get; set; }
public List<CreditAward> CreditAwards { get; set; } = [];
public StudySessionProgress? StudySessionProgress { get; set; }
public StudySessionSummary? SessionSummary { get; set; }
public DailyMissionSetResponse? DailyMissions { get; set; }
public ProgressionResponse? Progression { get; set; }
}
public class SkipQuestionResponse
@@ -151,6 +160,7 @@ public class SkipQuestionResponse
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public int QuestionElo { get; set; } = 1000;
public RatingChangeResponse? Rating { get; set; }
public ProgressionResponse? Progression { get; set; }
}
public static class RatingCategory
@@ -55,6 +55,7 @@ public sealed class ReviewQueueNextRequest
{
public List<string> ExcludeExternalIds { get; set; } = [];
public bool SupportsChoicePools { get; set; } = true;
public string? Step3System { get; set; }
}
public sealed class ReviewQueueNextResponse
@@ -72,6 +73,7 @@ public sealed class ReviewQueueAttemptRequest
public string ChoiceId { get; set; } = "";
public List<string> PresentedChoiceIds { get; set; } = [];
public int DurationSeconds { get; set; }
public string? StudySessionId { get; set; }
}
public sealed class ReviewQueueAttemptResponse
@@ -80,6 +82,12 @@ public sealed class ReviewQueueAttemptResponse
public bool IsCorrect { get; set; }
public string QueueState { get; set; } = ReviewQueueStates.Pending;
public int PendingCount { get; set; }
public CreditStatusResponse? Credits { get; set; }
public List<CreditAward> CreditAwards { get; set; } = [];
public StudySessionProgress? StudySessionProgress { get; set; }
public StudySessionSummary? SessionSummary { get; set; }
public DailyMissionSetResponse? DailyMissions { get; set; }
public ProgressionResponse? Progression { get; set; }
}
public sealed class ReviewQueueRecordResult
@@ -0,0 +1,106 @@
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class StudyGoalTypes
{
public const string QuestionCount = "question_count";
public const string Duration = "duration";
public const string WeakSystem = "weak_system";
public const string DueReviews = "due_reviews";
public const string SimulationBlock = "simulation_block";
public static readonly HashSet<string> All = [QuestionCount, Duration, WeakSystem, DueReviews, SimulationBlock];
}
public static class StudySessionStates
{
public const string Active = "active";
public const string Completed = "completed";
public const string EndedEarly = "ended_early";
public const string Interrupted = "interrupted";
}
public sealed class StudySession
{
[BsonId]
[BsonRepresentation(BsonType.ObjectId)]
public string? Id { get; set; }
public string UserId { get; set; } = "";
public string ClientSessionId { get; set; } = "";
public string GoalType { get; set; } = StudyGoalTypes.QuestionCount;
public string Mode { get; set; } = "Tutor";
public string Step3System { get; set; } = "";
public int TargetValue { get; set; } = 10;
public string State { get; set; } = StudySessionStates.Active;
public int QuestionsCompleted { get; set; }
public int Correct { get; set; }
public int ReviewsCompleted { get; set; }
public int Recovered { get; set; }
public int CoinsEarned { get; set; }
public List<CreditAward> CoinAwards { get; set; } = [];
public List<string> AppliedEventIds { get; set; } = [];
public int? InitialMasteryScore { get; set; }
public string InitialMasteryState { get; set; } = "";
public int? FinalMasteryScore { get; set; }
public string FinalMasteryState { get; set; } = "";
public int? InitialReadiness { get; set; }
public int? FinalReadiness { get; set; }
public string SimulationId { get; set; } = "";
public int? SimulationBlock { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }
public DateTime? TargetEndUtc { get; set; }
public DateTime? CompletedUtc { get; set; }
}
public sealed class CreateStudySessionRequest
{
public string ClientSessionId { get; set; } = "";
public string GoalType { get; set; } = StudyGoalTypes.QuestionCount;
public int TargetValue { get; set; } = 10;
public string Mode { get; set; } = "Tutor";
public string Step3System { get; set; } = "";
}
public sealed class StudySessionProgress
{
public string Id { get; set; } = "";
public string State { get; set; } = "";
public string GoalType { get; set; } = "";
public int TargetValue { get; set; }
public int CompletedValue { get; set; }
public int QuestionsCompleted { get; set; }
public int SecondsRemaining { get; set; }
public bool GoalReached { get; set; }
}
public sealed class StudySessionSummary
{
public string Id { get; set; } = "";
public string State { get; set; } = "";
public string GoalType { get; set; } = "";
public int TargetValue { get; set; }
public int CompletedValue { get; set; }
public int QuestionsCompleted { get; set; }
public int Correct { get; set; }
public int Accuracy { get; set; }
public int ReviewsCompleted { get; set; }
public int Recovered { get; set; }
public int ElapsedSeconds { get; set; }
public int CoinsEarned { get; set; }
public List<CreditAward> CoinAwards { get; set; } = [];
public string Step3System { get; set; } = "";
public int? MasteryScoreChange { get; set; }
public string InitialMasteryState { get; set; } = "";
public string FinalMasteryState { get; set; } = "";
public int? ReadinessChange { get; set; }
public string ReadinessExplanation { get; set; } = "A completed exam simulation is required to update readiness.";
public bool GoalBonusAwarded { get; set; }
}
public sealed class StudySessionEnvelope
{
public StudySessionProgress Progress { get; set; } = new();
public StudySessionSummary? Summary { get; set; }
}
@@ -0,0 +1,54 @@
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class WeeklyRemediationTypes
{
public const string Reviews = "reviews";
public const string HardCorrect = "hard_correct";
}
public sealed class WeeklyGoal
{
[BsonId] public string Id { get; set; } = "";
public string UserId { get; set; } = "";
public DateTime WeekStartUtc { get; set; }
public DateTime ExpiresUtc { get; set; }
public string RemediationType { get; set; } = WeeklyRemediationTypes.Reviews;
public int RemediationTarget { get; set; }
public List<string> EligibleSystems { get; set; } = [];
public DateTime CreatedUtc { get; set; }
public DateTime? CompletedUtc { get; set; }
public bool RewardIssued { get; set; }
public int RewardAmount { get; set; }
public bool ExpirationRecorded { get; set; }
}
public sealed class WeeklyGoalComponentResponse
{
public string Type { get; set; } = "";
public string Label { get; set; } = "";
public int Progress { get; set; }
public int Target { get; set; }
public bool Completed { get; set; }
}
public sealed class WeeklyGoalResponse
{
public DateTime WeekStartUtc { get; set; }
public DateTime ExpiresUtc { get; set; }
public bool Completed { get; set; }
public string RemediationType { get; set; } = "";
public List<string> EligibleSystems { get; set; } = [];
public string SuggestedSystem { get; set; } = "";
public int RewardAmount { get; set; }
public int RewardMaximum { get; set; } = 10;
public List<WeeklyGoalComponentResponse> Components { get; set; } = [];
}
public sealed class WeeklyGoalEnvelope
{
public WeeklyGoalResponse State { get; set; } = new();
public CreditStatusResponse? Credits { get; set; }
public List<CreditAward> CreditAwards { get; set; } = [];
}
+35
View File
@@ -29,6 +29,12 @@ if (args.FirstOrDefault() == "promote-dynamic-questions")
return;
}
if (args.FirstOrDefault() == "promote-ccs-cases")
{
Environment.ExitCode = await CcsPromotionCommand.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);
@@ -55,6 +61,28 @@ if (args.FirstOrDefault() == "flag-media-questions")
return;
}
if (args.FirstOrDefault() == "migrate-question-attempt-cycles")
{
var migrationConfiguration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
Environment.ExitCode = await QuestionAttemptCycleMigrationCommand.RunAsync(
migrationConfiguration, args.Skip(1).ToArray(), Console.Out);
return;
}
if (args.FirstOrDefault() == "backfill-step3-systems")
{
var migrationConfiguration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables()
.Build();
Environment.ExitCode = await Step3SystemBackfillCommand.RunAsync(
migrationConfiguration, args.Skip(1).ToArray(), Console.Out);
return;
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.Configure<ForwardedHeadersOptions>(options =>
@@ -68,6 +96,8 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
builder.Services.AddSingleton<MongoDatabaseProvider>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<CreditStore>();
builder.Services.AddSingleton<MasteryAwardService>();
builder.Services.AddHostedService<CreditLedgerReconciliationService>();
builder.Services.AddSingleton<QuestionStore>();
builder.Services.AddSingleton<QuestionAttemptStore>();
builder.Services.AddSingleton<AnswerStatisticsStore>();
@@ -87,6 +117,10 @@ builder.Services.AddSingleton<UserNotepadStore>();
builder.Services.AddSingleton<QuestionBookmarkStore>();
builder.Services.AddSingleton<ReviewQueueStore>();
builder.Services.AddSingleton<DashboardStore>();
builder.Services.AddSingleton<StudySessionStore>();
builder.Services.AddSingleton<DailyMissionStore>();
builder.Services.AddSingleton<WeeklyGoalStore>();
builder.Services.AddSingleton<ProgressionStore>();
builder.Services.AddSingleton<UserProfileStore>();
builder.Services.AddSingleton<IFlashcardScheduler, FsrsFlashcardScheduler>();
builder.Services.AddSingleton<FlashcardDeckStore>();
@@ -114,6 +148,7 @@ builder.Services.AddSingleton<DonationStore>();
builder.Services.AddSingleton<IStripeDonationGateway, StripeDonationGateway>();
builder.Services.AddSingleton<ServiceMetrics>();
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
builder.Services.AddHostedService<ExamSimulationScoringMigrationService>();
builder.Services.AddHttpClient();
builder.Services.AddRateLimiter(options =>
{
+8
View File
@@ -57,6 +57,14 @@ least one embedded authoritative verification reference. Legacy published
questions without references remain readable but must add references before a
future authoring update.
Wrong-answer follow-ups are distractor-specific remediation, not generic review
questions. Each distractor must have a different three-choice quiz about the
mechanism, indication, contraindication, timing, or other property that makes
that option inappropriate in the vignette. Its teaching point must explain why
that distractor fails without revealing the main correct answer. Publication
validation rejects duplicated prompts, correct follow-up answers, choice sets,
and teaching points within a question.
- `GET /readyz` verifies MongoDB connectivity for readiness checks.
- `GET /metrics` exposes Prometheus-format service and reconciliation metrics.
+108 -2
View File
@@ -4,7 +4,7 @@ namespace QuestionsApi.Services;
public static class CcsCaseValidator
{
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "end_case", "exit_case"];
private static readonly HashSet<string> CommandTypes = ["exam", "order", "cancel_order", "location", "advance", "interval_history", "acknowledge_opening", "dialogue_choice", "end_case", "exit_case"];
public static List<string> ValidateImport(CcsImportRequest request, IReadOnlySet<string> existingOrderCodes)
{
@@ -34,10 +34,17 @@ public static class CcsCaseValidator
if (string.IsNullOrWhiteSpace(item.PresentingLocation)) errors.Add(prefix + "presentingLocation required");
if (item.RealTimeMinutes is not (10 or 20)) errors.Add(prefix + "realTimeMinutes must be 10 or 20");
if (item.Difficulty is < 1 or > 5) errors.Add(prefix + "difficulty must be between 1 and 5");
if (item.ExperienceMode is not (CcsExperienceModes.Management or CcsExperienceModes.GraphicalInterview))
errors.Add(prefix + "experienceMode is invalid");
if (item.ExperienceMode == CcsExperienceModes.GraphicalInterview
&& (string.IsNullOrWhiteSpace(item.GraphicalPresentation.SceneKey)
|| string.IsNullOrWhiteSpace(item.GraphicalPresentation.PatientAssetKey)))
errors.Add(prefix + "graphical interviews require sceneKey and patientAssetKey");
if (string.IsNullOrWhiteSpace(item.InitialChart.PatientSummary) || string.IsNullOrWhiteSpace(item.InitialChart.ReasonForEncounter))
errors.Add(prefix + "initial patient summary and reason are required");
if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase))
errors.Add(prefix + "allowedLocations must include presentingLocation");
ValidateInterview(item.InitialInterview, prefix, errors);
if (item.IsPublished)
{
var referenceError = QuestionValidator.ValidateReferences(item.References, true);
@@ -53,6 +60,9 @@ public static class CcsCaseValidator
foreach (var response in item.OrderResponses)
{
if (!orderCodes.Contains(response.OrderCode)) errors.Add(prefix + $"unknown order code {response.OrderCode}");
if (response.TurnaroundMinutes < 0) errors.Add(prefix + $"order response {response.OrderCode} turnaroundMinutes cannot be negative");
if (response.RequiresPatientVisit && string.IsNullOrWhiteSpace(response.PerformanceLocation))
errors.Add(prefix + $"order response {response.OrderCode} requires a performanceLocation");
if (!string.IsNullOrWhiteSpace(response.ResultSection) && !CcsChartCategories.ResultSections.Contains(response.ResultSection))
errors.Add(prefix + $"order response {response.OrderCode} resultSection is invalid");
}
@@ -72,6 +82,10 @@ public static class CcsCaseValidator
if (criterion.ActionType == "order" && !orderCodes.Contains(target)) errors.Add(prefix + $"criterion {criterion.Id} references unknown order {target}");
if (criterion.ActionType == "exam" && !examCodes.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown exam {target}");
if (criterion.ActionType == "location" && !item.AllowedLocations.Contains(target, StringComparer.OrdinalIgnoreCase)) errors.Add(prefix + $"criterion {criterion.Id} references unknown location {target}");
if (criterion.ActionType == "dialogue_choice"
&& !item.InitialInterview.Topics.Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase))
&& !item.InitialInterview.Nodes.SelectMany(value => value.Choices).Any(value => value.Id.Equals(target, StringComparison.OrdinalIgnoreCase)))
errors.Add(prefix + $"criterion {criterion.Id} references unknown dialogue choice {target}");
}
}
}
@@ -90,7 +104,7 @@ public static class CcsCaseValidator
if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required";
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
if (!CommandTypes.Contains(request.Type)) return "unsupported command type";
if ((request.Type is "exam" or "order" or "cancel_order" or "location") && string.IsNullOrWhiteSpace(request.Code)) return "code required";
if ((request.Type is "exam" or "order" or "cancel_order" or "location" or "dialogue_choice") && string.IsNullOrWhiteSpace(request.Code)) return "code required";
if (request.Type == "advance" && request.Minutes is < 1 or > 525600) return "advance minutes must be between 1 and 525600";
return null;
}
@@ -119,4 +133,96 @@ public static class CcsCaseValidator
return "history and examinations cannot be mixed with order changes in one batch";
return null;
}
private static void ValidateInterview(CcsInitialInterview interview, string prefix, List<string> errors)
{
if (interview.Topics.Count > 0)
{
if (interview.Nodes.Count > 0 || !string.IsNullOrWhiteSpace(interview.StartNodeId))
errors.Add(prefix + "initialInterview cannot combine topics with nodes");
if (string.IsNullOrWhiteSpace(interview.OpeningPatientText))
errors.Add(prefix + "topic interview requires openingPatientText");
if (string.IsNullOrWhiteSpace(interview.FinishChoiceText) || string.IsNullOrWhiteSpace(interview.ClosingPatientText))
errors.Add(prefix + "topic interview requires finishChoiceText and closingPatientText");
var topicIds = interview.Topics.Select(value => value.Id).ToList();
if (topicIds.Any(string.IsNullOrWhiteSpace)
|| topicIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != topicIds.Count)
errors.Add(prefix + "interview topic ids must be present and unique");
if (topicIds.Contains("finish_interview", StringComparer.OrdinalIgnoreCase))
errors.Add(prefix + "finish_interview is a reserved interview topic id");
if (!interview.Topics.Any(value => value.Required))
errors.Add(prefix + "topic interview requires at least one required topic");
foreach (var topic in interview.Topics)
{
if (string.IsNullOrWhiteSpace(topic.ClinicianText))
errors.Add(prefix + $"interview topic {topic.Id} requires clinicianText");
if (string.IsNullOrWhiteSpace(topic.PatientText))
errors.Add(prefix + $"interview topic {topic.Id} requires patientText");
foreach (var prerequisite in topic.PrerequisiteTopicIds)
if (!topicIds.Contains(prerequisite, StringComparer.OrdinalIgnoreCase)
|| prerequisite.Equals(topic.Id, StringComparison.OrdinalIgnoreCase))
errors.Add(prefix + $"interview topic {topic.Id} has an invalid prerequisite {prerequisite}");
}
var topicMap = interview.Topics.ToDictionary(value => value.Id, StringComparer.OrdinalIgnoreCase);
var resolved = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var resolving = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
bool ResolveTopic(string topicId)
{
if (resolving.Contains(topicId))
{
errors.Add(prefix + "interview topic prerequisites cannot contain cycles");
return false;
}
if (resolved.Contains(topicId)) return true;
resolving.Add(topicId);
if (topicMap.TryGetValue(topicId, out var topic))
foreach (var prerequisite in topic.PrerequisiteTopicIds.Where(topicMap.ContainsKey))
ResolveTopic(prerequisite);
resolving.Remove(topicId);
resolved.Add(topicId);
return true;
}
foreach (var topic in interview.Topics) ResolveTopic(topic.Id);
return;
}
if (string.IsNullOrWhiteSpace(interview.StartNodeId) && interview.Nodes.Count == 0) return;
if (string.IsNullOrWhiteSpace(interview.StartNodeId)) errors.Add(prefix + "initialInterview.startNodeId required");
if (interview.Nodes.Count == 0) { errors.Add(prefix + "initialInterview.nodes required"); return; }
var nodeIds = interview.Nodes.Select(value => value.Id).ToList();
if (nodeIds.Any(string.IsNullOrWhiteSpace) || nodeIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != nodeIds.Count)
errors.Add(prefix + "interview node ids must be present and unique");
var nodeMap = interview.Nodes.Where(value => !string.IsNullOrWhiteSpace(value.Id))
.GroupBy(value => value.Id, StringComparer.OrdinalIgnoreCase).ToDictionary(value => value.Key, value => value.First(), StringComparer.OrdinalIgnoreCase);
if (!nodeMap.ContainsKey(interview.StartNodeId)) errors.Add(prefix + "interview start node does not exist");
var choiceIds = interview.Nodes.SelectMany(value => value.Choices).Select(value => value.Id).ToList();
if (choiceIds.Any(string.IsNullOrWhiteSpace) || choiceIds.Distinct(StringComparer.OrdinalIgnoreCase).Count() != choiceIds.Count)
errors.Add(prefix + "interview choice ids must be present and globally unique");
foreach (var node in interview.Nodes)
{
if (string.IsNullOrWhiteSpace(node.PatientText)) errors.Add(prefix + $"interview node {node.Id} requires patientText");
if (node.IsTerminal && node.Choices.Count > 0) errors.Add(prefix + $"terminal interview node {node.Id} cannot have choices");
if (!node.IsTerminal && node.Choices.Count == 0) errors.Add(prefix + $"nonterminal interview node {node.Id} requires choices");
foreach (var choice in node.Choices)
{
if (string.IsNullOrWhiteSpace(choice.Text)) errors.Add(prefix + $"interview choice {choice.Id} requires text");
if (string.IsNullOrWhiteSpace(choice.NextNodeId) || !nodeMap.ContainsKey(choice.NextNodeId))
errors.Add(prefix + $"interview choice {choice.Id} references an unknown next node");
}
}
if (!nodeMap.ContainsKey(interview.StartNodeId)) return;
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var visiting = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
bool Walk(string nodeId)
{
if (visiting.Contains(nodeId)) { errors.Add(prefix + "interview graph cannot contain cycles"); return false; }
if (visited.Contains(nodeId)) return true;
visiting.Add(nodeId);
if (nodeMap.TryGetValue(nodeId, out var node))
foreach (var choice in node.Choices.Where(value => nodeMap.ContainsKey(value.NextNodeId))) Walk(choice.NextNodeId);
visiting.Remove(nodeId); visited.Add(nodeId); return true;
}
Walk(interview.StartNodeId);
if (visited.Count != nodeMap.Count) errors.Add(prefix + "every interview node must be reachable from startNodeId");
if (!interview.Nodes.Any(value => value.IsTerminal)) errors.Add(prefix + "interview requires a terminal node");
}
}
@@ -0,0 +1,92 @@
using Microsoft.Extensions.Configuration;
using MongoDB.Driver;
using QuestionsApi.Models;
using System.Text.Json;
namespace QuestionsApi.Services;
public static class CcsPromotionCommand
{
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", "ccs");
var files = File.Exists(source)
? new[] { source }
: Directory.Exists(source)
? Directory.EnumerateFiles(source, "*.json", SearchOption.AllDirectories).ToArray()
: [];
if (files.Length == 0)
{
await output.WriteLineAsync($"CCS content not found: {source}");
return 2;
}
var json = new JsonSerializerOptions(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true };
var request = new CcsImportRequest();
foreach (var file in files)
{
var parsed = JsonSerializer.Deserialize<CcsImportRequest>(await File.ReadAllTextAsync(file), json)
?? throw new InvalidDataException($"Could not parse CCS content: {file}");
request.Orders.AddRange(parsed.Orders);
request.Cases.AddRange(parsed.Cases);
}
var duplicateOrders = request.Orders.GroupBy(value => value.Code, StringComparer.OrdinalIgnoreCase)
.Where(value => value.Count() > 1).Select(value => value.Key).ToList();
if (duplicateOrders.Count > 0)
throw new InvalidDataException($"Duplicate CCS order codes: {string.Join(", ", duplicateOrders)}");
var configuration = new ConfigurationBuilder().AddJsonFile("appsettings.json", optional: true)
.AddEnvironmentVariables().Build();
var mongo = new MongoDatabaseProvider(configuration);
var orders = mongo.Database.GetCollection<CcsOrderDefinition>("CcsOrderCatalog");
var cases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
var existingCodes = (await orders.Find(Builders<CcsOrderDefinition>.Filter.Empty)
.Project(value => value.Code).ToListAsync()).ToHashSet(StringComparer.OrdinalIgnoreCase);
var errors = CcsCaseValidator.ValidateImport(request, existingCodes);
if (errors.Count > 0)
{
foreach (var error in errors) await output.WriteLineAsync(error);
return 1;
}
await output.WriteLineAsync($"Validated {request.Cases.Count} CCS case(s) and {request.Orders.Count} order(s).");
if (!apply)
{
await output.WriteLineAsync("Dry run only. Pass --apply to publish.");
return 0;
}
foreach (var order in request.Orders)
{
order.Code = order.Code.Trim().ToLowerInvariant();
order.UpdatedUtc = DateTime.UtcNow;
await orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
}
var created = 0;
var unchanged = 0;
foreach (var incoming in request.Cases)
{
var current = await cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent)
.SortByDescending(value => value.Version).FirstOrDefaultAsync();
if (current is not null && CcsStore.CaseContentDocument(current).Equals(CcsStore.CaseContentDocument(incoming)))
{
unchanged++;
continue;
}
if (current is not null)
await cases.UpdateOneAsync(value => value.Id == current.Id,
Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
incoming.Id = null;
incoming.Version = (current?.Version ?? 0) + 1;
incoming.IsCurrent = true;
incoming.CreatedUtc = DateTime.UtcNow;
await cases.InsertOneAsync(incoming);
created++;
}
await output.WriteLineAsync($"Promotion complete: {created} created, {unchanged} unchanged.");
return 0;
}
}
+265 -14
View File
@@ -50,10 +50,16 @@ public sealed class CcsStore
await _orders.ReplaceOneAsync(value => value.Code == order.Code, order, new ReplaceOptions { IsUpsert = true });
}
var created = 0;
var skipped = 0;
foreach (var incoming in request.Cases)
{
var current = await _cases.Find(value => value.ExternalId == incoming.ExternalId && value.IsCurrent)
.SortByDescending(value => value.Version).FirstOrDefaultAsync();
if (current is not null && CaseContentDocument(current).Equals(CaseContentDocument(incoming)))
{
skipped++;
continue;
}
if (current is not null)
await _cases.UpdateOneAsync(value => value.Id == current.Id, Builders<CcsCaseRevision>.Update.Set(value => value.IsCurrent, false));
incoming.Id = null;
@@ -63,7 +69,17 @@ public sealed class CcsStore
await _cases.InsertOneAsync(incoming);
created++;
}
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created };
return new CcsImportResponse { OrdersUpserted = request.Orders.Count, CasesCreated = created, CasesSkipped = skipped };
}
internal static BsonDocument CaseContentDocument(CcsCaseRevision value)
{
var document = value.ToBsonDocument();
document.Remove("_id");
document.Remove(nameof(CcsCaseRevision.Version));
document.Remove(nameof(CcsCaseRevision.IsCurrent));
document.Remove(nameof(CcsCaseRevision.CreatedUtc));
return document;
}
public async Task<List<CcsCaseSummary>> ListCasesAsync() =>
@@ -108,6 +124,8 @@ public sealed class CcsStore
}
if (definition is null) throw new KeyNotFoundException("CCS case not found.");
var now = DateTime.UtcNow;
var interview = EffectiveInterview(definition);
var interviewStartNodeId = definition.InitialInterview.Topics.Count > 0 ? "opening" : interview.StartNodeId;
var attempt = new CcsAttempt
{
UserId = userId,
@@ -117,6 +135,7 @@ public sealed class CcsStore
CaseVersion = definition.Version,
Mode = CcsAttemptModes.Timed,
OpeningCompleted = false,
InterviewNodeId = interviewStartNodeId,
Location = definition.PresentingLocation,
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
VitalHistory =
@@ -124,9 +143,11 @@ public sealed class CcsStore
new CcsVitalSnapshot { SimulatedMinute = 0, Values = new Dictionary<string, string>(definition.InitialChart.Vitals) }
],
ActiveOrderCodes = definition.InitialChart.InitialOrderCodes.Distinct(StringComparer.OrdinalIgnoreCase).ToList(),
DeadlineUtc = now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
HardDeadlineUtc = now.AddMinutes(definition.RealTimeMinutes),
RatingEligible = true,
DeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? null : now.AddMinutes(ActivePhaseMinutes(definition.RealTimeMinutes)),
HardDeadlineUtc = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? null : now.AddMinutes(definition.RealTimeMinutes),
RatingEligible = definition.ExperienceMode != CcsExperienceModes.GraphicalInterview,
Chart =
[
new CcsChartEntry { Category = CcsChartCategories.Progress, Code = "reason_for_encounter", Title = "Reason for encounter", Text = definition.InitialChart.ReasonForEncounter },
@@ -227,6 +248,7 @@ public sealed class CcsStore
case "acknowledge_opening":
attempt.OpeningCompleted = true;
break;
case "dialogue_choice": ApplyDialogueChoice(attempt, definition, command.Code, now); break;
case "end_case": EnterFinalOrders(attempt); break;
case "exit_case": await CompleteAsync(attempt); break;
}
@@ -389,6 +411,23 @@ public sealed class CcsStore
{
var definition = await DefinitionAsync(attempt);
var completed = attempt.State == CcsAttemptStates.Completed;
var optionCodes = definition.OrderResponses.Select(response => response.OrderCode).ToList();
List<CcsOrderDefinition> optionCatalog = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? await _orders.Find(value => optionCodes.Contains(value.Code)).ToListAsync()
: [];
var orderOptions = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? definition.OrderResponses.Select(response =>
{
var catalog = optionCatalog.FirstOrDefault(value => value.Code.Equals(response.OrderCode, StringComparison.OrdinalIgnoreCase));
return new CcsOrderOption
{
Code = response.OrderCode,
TurnaroundMinutes = response.TurnaroundMinutes ?? catalog?.DefaultTurnaroundMinutes ?? 0,
PerformanceLocation = PerformanceLocation(response, catalog, attempt.Location),
RequiresPatientVisit = response.RequiresPatientVisit
};
}).ToList()
: [];
List<CcsOrderDefinition> activeCatalog = attempt.ActiveOrderCodes.Count == 0
? []
: await _orders.Find(value => attempt.ActiveOrderCodes.Contains(value.Code)).ToListAsync();
@@ -401,44 +440,105 @@ public sealed class CcsStore
var reported = attempt.Chart.LastOrDefault(value =>
value.Code.Equals(order.Code, StringComparison.OrdinalIgnoreCase)
&& value.Category is not CcsChartCategories.Orders);
var response = definition.OrderResponses.FirstOrDefault(value =>
value.OrderCode.Equals(order.Code, StringComparison.OrdinalIgnoreCase));
var performanceLocation = pending?.PerformanceLocation ?? PerformanceLocation(response, order, attempt.Location);
var requiresPatientVisit = pending?.RequiresPatientVisit ?? response?.RequiresPatientVisit == true;
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,
DueMinute = pending?.DueMinute, PerformanceLocation = performanceLocation,
RequiresPatientVisit = requiresPatientVisit,
Status = pending is not null ? (requiresPatientVisit ? "scheduled" : "pending")
: reported is not null ? "completed" : "active",
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) }];
CcsInterviewStateResponse? interview = null;
var currentInterviewNode = CurrentInterviewNode(attempt, definition);
var openingCompleted = attempt.OpeningCompleted ?? true;
if (definition.InitialInterview.Topics.Count > 0 && currentInterviewNode?.IsTerminal != true)
openingCompleted = false;
if (currentInterviewNode is not null)
{
interview = new CcsInterviewStateResponse
{
CurrentNodeId = currentInterviewNode.Id,
CurrentNode = currentInterviewNode,
VisitedChoiceIds = attempt.InterviewChoiceIds,
Completed = openingCompleted
};
}
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, SimulatedStartUtc = definition.InitialChart.SimulatedStartUtc,
OpeningCompleted = attempt.OpeningCompleted ?? true, Location = attempt.Location,
OpeningCompleted = openingCompleted, 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)),
ExperienceMode = definition.ExperienceMode, GraphicalPresentation = definition.GraphicalPresentation,
PatientSummary = definition.InitialChart.PatientSummary, ReasonForEncounter = definition.InitialChart.ReasonForEncounter,
Appearance = definition.InitialChart.Appearance, History = definition.InitialChart.History,
HistorySections = definition.InitialChart.HistorySections,
Appearance = definition.InitialChart.Appearance,
History = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
? "" : definition.InitialChart.History,
HistorySections = definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && !completed
? [] : definition.InitialChart.HistorySections,
MedicalHistory = MedicalHistory(definition),
Vitals = attempt.CurrentVitals, Chart = attempt.Chart, ActiveOrderCodes = attempt.ActiveOrderCodes,
ActiveOrders = activeOrders, VitalHistory = vitalHistory,
ActiveOrders = activeOrders, OrderOptions = orderOptions, VitalHistory = vitalHistory,
PendingResults = attempt.PendingResults.Select(value => new CcsPendingResult
{
OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title
OrderCode = value.OrderCode, DueMinute = value.DueMinute, Category = value.Category, Title = value.Title,
PerformanceLocation = value.PerformanceLocation, RequiresPatientVisit = value.RequiresPatientVisit
}).ToList(),
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
Interview = interview,
InterviewDebrief = completed && definition.ExperienceMode == CcsExperienceModes.GraphicalInterview
? definition.InitialInterview.Debrief : null,
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
References = completed ? definition.References : []
};
}
public static void ApplyDialogueChoice(CcsAttempt attempt, CcsCaseRevision definition, string choiceId, DateTime now)
{
if (definition.InitialInterview.Topics.Count > 0)
{
if (attempt.OpeningCompleted == true
&& attempt.InterviewNodeId.Equals("complete", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("The initial interview is already complete.");
// Attempts that passed through the legacy classic opening can have
// this flag set before any graphical topic was completed.
attempt.OpeningCompleted = false;
ApplyTopicChoice(attempt, definition.InitialInterview, choiceId, now);
return;
}
if (attempt.OpeningCompleted == true) throw new InvalidOperationException("The initial interview is already complete.");
var interview = EffectiveInterview(definition);
var node = interview.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidOperationException("The interactive interview is unavailable.");
var choice = node.Choices.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase))
?? throw new ArgumentException("The dialogue choice is not available at the current interview node.");
var next = interview.Nodes.FirstOrDefault(value =>
value.Id.Equals(choice.NextNodeId, StringComparison.OrdinalIgnoreCase))
?? throw new InvalidOperationException("The dialogue choice target is unavailable.");
attempt.Actions.Add(Action("dialogue_choice", choice.Id, attempt.SimulatedMinute, now));
attempt.InterviewChoiceIds.Add(choice.Id);
attempt.InterviewNodeId = next.Id;
if (next.IsTerminal) attempt.OpeningCompleted = true;
}
public async Task AbandonAsync(CcsAttempt attempt)
{
attempt.State = CcsAttemptStates.Abandoned;
@@ -451,23 +551,31 @@ public sealed class CcsStore
{
code = code.Trim().ToLowerInvariant();
var catalog = await _orders.Find(value => value.Code == code).FirstOrDefaultAsync() ?? throw new ArgumentException("Unknown order code.");
if (catalog.AvailableLocations.Count > 0 && !catalog.AvailableLocations.Contains(attempt.Location, StringComparer.OrdinalIgnoreCase))
var response = definition.OrderResponses.FirstOrDefault(value => value.OrderCode.Equals(code, StringComparison.OrdinalIgnoreCase));
if (definition.ExperienceMode == CcsExperienceModes.GraphicalInterview && response is null)
throw new InvalidOperationException("That order is not configured for this case.");
if (response is null && catalog.AvailableLocations.Count > 0 && !catalog.AvailableLocations.Contains(attempt.Location, StringComparer.OrdinalIgnoreCase))
throw new InvalidOperationException("That order is unavailable at the current location.");
attempt.Actions.Add(Action("order", code, attempt.SimulatedMinute, now));
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 performanceLocation = PerformanceLocation(response, catalog, attempt.Location);
var requiresPatientVisit = response?.RequiresPatientVisit == true;
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, Category = resultCategory, Title = catalog.CanonicalName,
DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches
DueMinute = attempt.SimulatedMinute + delay, Result = response.Result, VitalPatches = response.VitalPatches,
PerformanceLocation = performanceLocation, RequiresPatientVisit = requiresPatientVisit
});
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Orders, Code = code, Title = catalog.CanonicalName,
SimulatedMinute = attempt.SimulatedMinute, Text = $"Ordered: {catalog.CanonicalName}"
SimulatedMinute = attempt.SimulatedMinute,
Text = requiresPatientVisit
? $"Scheduled: {catalog.CanonicalName} at {Humanize(performanceLocation)}; expected in {delay} minutes."
: $"Ordered: {catalog.CanonicalName}"
});
if (delay == 0 && processImmediately) ProcessDue(attempt);
}
@@ -532,6 +640,13 @@ public sealed class CcsStore
var vitalsChanged = false;
foreach (var result in due)
{
if (result.RequiresPatientVisit)
attempt.Chart.Add(new CcsChartEntry
{
Category = CcsChartCategories.Progress, Code = $"{result.OrderCode}_completed", Title = "Patient returned",
SimulatedMinute = attempt.SimulatedMinute,
Text = $"The patient completed {result.Title} at {Humanize(result.PerformanceLocation)} and returned."
});
if (!string.IsNullOrWhiteSpace(result.Result))
attempt.Chart.Add(new CcsChartEntry
{
@@ -545,6 +660,13 @@ public sealed class CcsStore
if (vitalsChanged) SnapshotVitals(attempt);
}
private static string PerformanceLocation(CcsCaseOrderDefinition? response, CcsOrderDefinition? catalog, string currentLocation)
{
if (!string.IsNullOrWhiteSpace(response?.PerformanceLocation)) return response.PerformanceLocation;
if (catalog?.AvailableLocations.Contains(currentLocation, StringComparer.OrdinalIgnoreCase) == true) return currentLocation;
return catalog?.AvailableLocations.FirstOrDefault() ?? currentLocation;
}
private static bool ProcessEvents(CcsAttempt attempt, CcsCaseRevision definition)
{
var activeOrders = attempt.Actions.Where(value => value.Type == "order").Select(value => value.Code).ToHashSet(StringComparer.OrdinalIgnoreCase);
@@ -693,8 +815,137 @@ public sealed class CcsStore
{
ExternalId = value.ExternalId, Title = value.Title, System = value.System,
PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes,
Difficulty = value.Difficulty, Tags = value.Tags
Difficulty = value.Difficulty, Tags = value.Tags,
InteractiveInterviewAvailable = value.InitialInterview.Topics.Count > 0 || EffectiveInterview(value).Nodes.Count > 0,
GraphicalPresentationAvailable = !string.IsNullOrWhiteSpace(value.GraphicalPresentation.SceneKey)
&& !string.IsNullOrWhiteSpace(value.GraphicalPresentation.PatientAssetKey),
ExperienceMode = value.ExperienceMode, GraphicalPresentation = value.GraphicalPresentation
};
internal static CcsDialogueNode? CurrentInterviewNode(CcsAttempt attempt, CcsCaseRevision definition)
{
var topicInterview = definition.InitialInterview;
if (topicInterview.Topics.Count > 0)
{
if (attempt.InterviewNodeId.Equals("complete", StringComparison.OrdinalIgnoreCase))
return new CcsDialogueNode
{
Id = "complete", PatientText = topicInterview.ClosingPatientText, IsTerminal = true
};
var patientText = attempt.InterviewNodeId.Equals("opening", StringComparison.OrdinalIgnoreCase)
? topicInterview.OpeningPatientText
: topicInterview.Topics.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase))?.PatientText;
if (string.IsNullOrWhiteSpace(patientText)) return null;
var visited = attempt.InterviewChoiceIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
var choices = topicInterview.Topics.Where(value => !visited.Contains(value.Id)
&& value.PrerequisiteTopicIds.All(visited.Contains))
.Select(value => new CcsDialogueChoice
{
Id = value.Id, Text = value.ClinicianText, NextNodeId = value.Id
}).ToList();
if (topicInterview.Topics.Where(value => value.Required).All(value => visited.Contains(value.Id)))
choices.Add(new CcsDialogueChoice
{
Id = "finish_interview", Text = topicInterview.FinishChoiceText, NextNodeId = "complete"
});
return new CcsDialogueNode
{
Id = attempt.InterviewNodeId, PatientText = patientText,
SuppressPatientText = attempt.InterviewNodeId.Equals("opening", StringComparison.OrdinalIgnoreCase),
Choices = choices
};
}
var interview = EffectiveInterview(definition);
return interview.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
}
private static void ApplyTopicChoice(CcsAttempt attempt, CcsInitialInterview interview, string choiceId, DateTime now)
{
if (choiceId.Equals("finish_interview", StringComparison.OrdinalIgnoreCase))
{
var visited = attempt.InterviewChoiceIds.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (!interview.Topics.Where(value => value.Required).All(value => visited.Contains(value.Id)))
throw new ArgumentException("Complete the core interview topics before finishing.");
attempt.Actions.Add(Action("dialogue_choice", "finish_interview", attempt.SimulatedMinute, now));
attempt.InterviewChoiceIds.Add("finish_interview");
attempt.InterviewNodeId = "complete";
attempt.OpeningCompleted = true;
return;
}
var topic = interview.Topics.FirstOrDefault(value => value.Id.Equals(choiceId, StringComparison.OrdinalIgnoreCase))
?? throw new ArgumentException("The dialogue choice is not available at the current interview node.");
if (attempt.InterviewChoiceIds.Contains(topic.Id, StringComparer.OrdinalIgnoreCase))
throw new ArgumentException("That interview topic has already been covered.");
if (!topic.PrerequisiteTopicIds.All(value => attempt.InterviewChoiceIds.Contains(value, StringComparer.OrdinalIgnoreCase)))
throw new ArgumentException("That interview topic is not available yet.");
attempt.Actions.Add(Action("dialogue_choice", topic.Id, attempt.SimulatedMinute, now));
attempt.InterviewChoiceIds.Add(topic.Id);
attempt.InterviewNodeId = topic.Id;
}
internal static CcsInitialInterview EffectiveInterview(CcsCaseRevision definition)
{
if (!string.IsNullOrWhiteSpace(definition.InitialInterview.StartNodeId)
&& definition.InitialInterview.Nodes.Count > 0)
return definition.InitialInterview;
var narrative = definition.InitialChart.HistorySections
.Where(value => !string.IsNullOrWhiteSpace(value.Text))
.Select((value, index) => new
{
Id = $"history_{index + 1}",
Prompt = string.IsNullOrWhiteSpace(value.Title)
? "Can you tell me more about your history?"
: $"Can you tell me about your {value.Title.Trim().ToLowerInvariant()}?",
value.Text
}).ToList();
if (narrative.Count == 0 && !string.IsNullOrWhiteSpace(definition.InitialChart.History))
narrative.Add(new { Id = "history_1", Prompt = "Can you tell me more about your history?", Text = definition.InitialChart.History });
var nodes = new List<CcsDialogueNode>
{
new()
{
Id = "opening", PatientText = "Hello, doctor.", SuppressPatientText = true,
Choices = [new() { Id = "ask_reason", Text = "What brings you in today?", NextNodeId = "reason" }]
},
new()
{
Id = "reason", PatientText = definition.InitialChart.ReasonForEncounter,
Choices = [new()
{
Id = narrative.Count > 0 ? "ask_history_1" : "finish_interview",
Text = narrative.Count > 0 ? narrative[0].Prompt : "Is there anything else you would like me to know?",
NextNodeId = narrative.Count > 0 ? narrative[0].Id : "complete"
}]
}
};
for (var index = 0; index < narrative.Count; index++)
{
var next = index + 1 < narrative.Count ? narrative[index + 1] : null;
nodes.Add(new CcsDialogueNode
{
Id = narrative[index].Id, PatientText = narrative[index].Text,
Choices = [new()
{
Id = next is null ? "finish_interview" : $"ask_history_{index + 2}",
Text = next?.Prompt ?? "Is there anything else you would like me to know?",
NextNodeId = next?.Id ?? "complete"
}]
});
}
nodes.Add(new CcsDialogueNode
{
Id = "complete", PatientText = "That is everything I can think of, doctor.", IsTerminal = true
});
return new CcsInitialInterview { StartNodeId = "opening", Nodes = nodes };
}
internal static string MedicalHistory(CcsCaseRevision definition) =>
definition.InitialChart.HistorySections.FirstOrDefault(value =>
value.Code.Equals("past_medical_history", StringComparison.OrdinalIgnoreCase))?.Text ?? "";
internal static int Rank(CcsOrderDefinition order, string query)
{
var values = new[] { order.CanonicalName }.Concat(order.Aliases).Select(value => value.ToLowerInvariant());
@@ -0,0 +1,17 @@
namespace QuestionsApi.Services;
public sealed class CreditLedgerReconciliationService(
CreditStore credits,
ILogger<CreditLedgerReconciliationService> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try { await credits.ReconcilePendingAsync(stoppingToken); }
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { return; }
catch (Exception exception) { logger.LogError(exception, "Credit ledger reconciliation failed."); }
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
}
+435 -27
View File
@@ -7,43 +7,167 @@ namespace QuestionsApi.Services;
public sealed class CreditStore
{
private readonly IMongoCollection<UserCreditAccount> _accounts;
private readonly IMongoCollection<CreditLedgerEntry> _ledger;
private readonly TimeProvider _clock;
private readonly ServiceMetrics _metrics;
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock)
public CreditStore(MongoDatabaseProvider mongo, TimeProvider clock, ServiceMetrics metrics)
{
_accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
_ledger = mongo.Database.GetCollection<CreditLedgerEntry>("CreditLedgerEntries");
_clock = clock;
_metrics = metrics;
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" }));
_ledger.Indexes.CreateOne(new CreateIndexModel<CreditLedgerEntry>(
Builders<CreditLedgerEntry>.IndexKeys.Ascending(value => value.UserId)
.Descending(value => value.CreatedUtc).Descending(value => value.Id),
new CreateIndexOptions { Name = "credit_ledger_user_time" }));
}
public async Task<bool> AwardForFirstAnswerAsync(string userId, string attemptId)
public async Task<CreditAwardResult> AwardAsync(string userId, CreditAwardRequest request)
{
if (string.IsNullOrWhiteSpace(attemptId)) return false;
if (string.IsNullOrWhiteSpace(request.OperationId) || request.Amount <= 0)
return new CreditAwardResult { Status = await GetStatusAsync(userId) };
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
var entryId = LedgerId(userId, request.OperationId);
var entry = await _ledger.Find(value => value.Id == entryId).FirstOrDefaultAsync();
if (entry is not null && (entry.ReasonCode != request.ReasonCode || entry.SourceId != request.SourceId))
throw new InvalidOperationException("The credit operation ID was already used for another event.");
if (entry is null)
{
var alreadyApplied = await _accounts.Find(value => value.UserId == userId
&& value.AppliedOperations.Contains(request.OperationId)).AnyAsync();
if (alreadyApplied) return new CreditAwardResult { Status = await GetStatusAsync(userId) };
entry = new CreditLedgerEntry
{
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,
Id = entryId, UserId = userId, OperationId = request.OperationId,
RequestedAmount = request.Amount, Amount = request.SubjectToRollingCap ? 0 : request.Amount,
ReasonCode = request.ReasonCode,
Description = request.Description, SourceType = request.SourceType,
SourceId = request.SourceId, SubjectToRollingCap = request.SubjectToRollingCap,
CreatedUtc = now
})
.Set(value => value.UpdatedUtc, now);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
};
try { await _ledger.InsertOneAsync(entry); }
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
{ entry = await _ledger.Find(value => value.Id == entryId).FirstAsync(); }
}
if (entry.State == "applied")
{
_metrics.CreditDuplicate(request.ReasonCode);
return new CreditAwardResult { Status = await GetStatusAsync(userId) };
}
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
if (account.AppliedOperations.Contains(request.OperationId))
{
var appliedAmount = AppliedAmount(account, entry);
entry.Amount = appliedAmount;
await MarkLedgerAppliedAsync(entry, appliedAmount, now);
return new CreditAwardResult { Status = await GetStatusAsync(userId) };
}
var amount = request.Amount;
var appliedNow = false;
while (true)
{
var earned = account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount));
amount = request.SubjectToRollingCap
? Math.Min(request.Amount, Math.Max(0, CreditEconomy.RollingEarningLimit - earned))
: request.Amount;
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId)
& Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [request.OperationId]);
if (request.SubjectToRollingCap && amount > 0)
filter &= RollingEarnedAtMost(CreditEconomy.RollingEarningLimit - amount);
var update = Builders<UserCreditAccount>.Update
.AddToSet(value => value.AppliedOperations, request.OperationId)
.Set(value => value.UpdatedUtc, now);
if (amount > 0)
{
update = update.Inc(value => value.Credits, amount);
if (request.SubjectToRollingCap)
update = update.Push(value => value.RecentEarnings,
new CreditEvent { OperationId = request.OperationId, CoinAmount = amount, CreatedUtc = now });
}
if ((await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1)
{
appliedNow = true;
break;
}
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
if (account.AppliedOperations.Contains(request.OperationId))
{
amount = AppliedAmount(account, entry);
break;
}
}
entry.Amount = amount;
await MarkLedgerAppliedAsync(entry, amount, now);
if (appliedNow && amount > 0) _metrics.CreditAward(request.ReasonCode, amount);
if (appliedNow && request.SubjectToRollingCap && amount < request.Amount)
_metrics.CreditCapped(request.ReasonCode);
var status = await GetStatusAsync(userId);
return appliedNow ? Result(entry, status) : new CreditAwardResult { Status = status };
}
public async Task InitializeMasteryBaselineAsync(string userId, IEnumerable<string> operationIds)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
var update = Builders<UserCreditAccount>.Update
.Set(value => value.MasteryAwardBaselineApplied, true);
foreach (var operationId in operationIds.Distinct(StringComparer.Ordinal))
update = update.AddToSet(value => value.AppliedOperations, operationId);
await _accounts.UpdateOneAsync(
value => value.UserId == userId && !value.MasteryAwardBaselineApplied, update);
}
public async Task<bool> IsMasteryBaselineAppliedAsync(string userId)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).MasteryAwardBaselineApplied;
}
public async Task<HashSet<string>> GetAppliedOperationsAsync(string userId)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
return account.AppliedOperations.ToHashSet(StringComparer.Ordinal);
}
public async Task<CreditLedgerPage> GetLedgerAsync(string userId, int limit, DateTime? before)
{
limit = Math.Clamp(limit, 1, 50);
var filter = Builders<CreditLedgerEntry>.Filter.Eq(value => value.UserId, userId)
& Builders<CreditLedgerEntry>.Filter.Eq(value => value.State, "applied")
& Builders<CreditLedgerEntry>.Filter.Ne(value => value.Amount, 0);
if (before.HasValue) filter &= Builders<CreditLedgerEntry>.Filter.Lt(value => value.CreatedUtc, before.Value);
var entries = await _ledger.Find(filter).SortByDescending(value => value.CreatedUtc)
.ThenByDescending(value => value.Id).Limit(limit + 1).ToListAsync();
var hasMore = entries.Count > limit;
if (hasMore) entries.RemoveAt(entries.Count - 1);
return new CreditLedgerPage
{
Items = entries.Select(value => new CreditLedgerEntryResponse
{
Amount = value.Amount, ReasonCode = value.ReasonCode,
Description = value.Description, CreatedUtc = value.CreatedUtc
}).ToList(),
NextBefore = hasMore && entries.Count > 0 ? entries[^1].CreatedUtc : null
};
}
public async Task ReconcilePendingAsync(CancellationToken cancellationToken = default)
{
var pending = await _ledger.Find(value => value.State == "pending")
.SortBy(value => value.CreatedUtc).Limit(100).ToListAsync(cancellationToken);
foreach (var entry in pending)
{
cancellationToken.ThrowIfCancellationRequested();
await AwardAsync(entry.UserId, new CreditAwardRequest(
entry.OperationId, entry.RequestedAmount, entry.ReasonCode, entry.Description,
entry.SourceType, entry.SourceId, entry.SubjectToRollingCap));
}
}
public async Task<CreditStatusResponse> GetStatusAsync(string userId)
@@ -51,15 +175,187 @@ public sealed class CreditStore
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
return new CreditStatusResponse
return ToStatus(account);
}
public async Task<CreditAward?> GetAppliedAwardAsync(string userId, string operationId)
{
var entry = await _ledger.Find(value => value.Id == LedgerId(userId, operationId)
&& value.State == "applied").FirstOrDefaultAsync();
return entry is null || entry.Amount <= 0 ? null : new CreditAward
{
Credits = account.Credits,
EarnedInRollingWindow = account.RecentEarnings.Count,
RemainingEarningCapacity = Math.Max(
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count)
Amount = entry.Amount, ReasonCode = entry.ReasonCode, Description = entry.Description
};
}
public async Task<int> GetBankCycleAsync(string userId)
{
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
return (await _accounts.Find(value => value.UserId == userId).FirstAsync()).BankCycle;
}
public async Task<CreditOperationResponse> PurchaseAsync(
string userId, RzStoreProduct product, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync();
var prior = existing.Purchases.FirstOrDefault(value => value.OperationId == operationId);
if (prior is not null)
{
if (prior.Sku != product.Sku)
return Response(CreditOperationResult.OperationConflict, existing);
await RecordPurchaseLedgerAsync(userId, product, operationId, prior.CreatedUtc);
return Response(CreditOperationResult.Succeeded, existing);
}
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, product.CoinPrice) &
Builders<UserCreditAccount>.Filter.Not(
Builders<UserCreditAccount>.Filter.ElemMatch(
value => value.Purchases, purchase => purchase.OperationId == operationId));
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.Credits, -product.CoinPrice)
.Push(value => value.Purchases, new CreditPurchaseEvent
{
OperationId = operationId,
Sku = product.Sku,
CoinCost = product.CoinPrice,
CreatedUtc = now
})
.Set(value => value.UpdatedUtc, now);
update = product.Grant == RzStoreGrant.ExamToken
? update.Inc(value => value.ExamTokens, 1)
: update.Inc(value => value.QBankResetTokens, 1);
var account = await _accounts.FindOneAndUpdateAsync(
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
{
ReturnDocument = ReturnDocument.After
});
if (account is not null)
{
await RecordPurchaseLedgerAsync(userId, product, operationId, now);
return Response(CreditOperationResult.Succeeded, account);
}
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
prior = account.Purchases.FirstOrDefault(value => value.OperationId == operationId);
if (prior is not null)
{
if (prior.Sku != product.Sku)
return Response(CreditOperationResult.OperationConflict, account);
await RecordPurchaseLedgerAsync(userId, product, operationId, prior.CreatedUtc);
return Response(CreditOperationResult.Succeeded, account);
}
return Response(CreditOperationResult.InsufficientCredits, account);
}
public async Task<CreditOperationResponse> ApplyQBankResetAsync(string userId, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gt(value => value.QBankResetTokens, 0) &
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.QBankResetTokens, -1)
.Inc(value => value.BankCycle, 1)
.AddToSet(value => value.AppliedOperations, operationId)
.Set(value => value.UpdatedUtc, now);
var account = await _accounts.FindOneAndUpdateAsync(
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
{
ReturnDocument = ReturnDocument.After
});
if (account is not null) return Response(CreditOperationResult.Succeeded, account);
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
if (account.AppliedOperations.Contains(operationId))
return Response(CreditOperationResult.Succeeded, account);
return Response(CreditOperationResult.InsufficientTokens, account);
}
public async Task<SpinResponse> SpinAsync(string userId, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var existing = await _accounts.Find(value => value.UserId == userId).FirstAsync();
var priorSpin = existing.Spins.FirstOrDefault(value => value.OperationId == operationId);
if (priorSpin is not null)
return SpinResult(CreditOperationResult.Succeeded, existing, priorSpin);
var reward = SlotMachineEconomy.RollReward(Random.Shared.NextDouble());
var netCreditDelta = reward.Grant == RzStoreGrant.Coins
? reward.CoinAmount - SlotMachineEconomy.SpinCost
: -SlotMachineEconomy.SpinCost;
var spinEvent = new SpinEvent
{
OperationId = operationId,
RewardKey = reward.Key,
CoinsWon = reward.Grant == RzStoreGrant.Coins ? reward.CoinAmount : 0,
CreatedUtc = now
};
var belowSpinCap = new BsonDocumentFilterDefinition<UserCreditAccount>(
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray
{
new BsonDocument("$size", new BsonDocument("$ifNull", new BsonArray { "$RecentSpins", new BsonArray() })),
SlotMachineEconomy.RollingSpinLimit
})));
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gte(value => value.Credits, SlotMachineEconomy.SpinCost) &
Builders<UserCreditAccount>.Filter.Not(
Builders<UserCreditAccount>.Filter.ElemMatch(
value => value.Spins, spin => spin.OperationId == operationId)) &
belowSpinCap;
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.Credits, netCreditDelta)
.Push(value => value.Spins, spinEvent)
.Push(value => value.RecentSpins, new CreditEvent { OperationId = operationId, CreatedUtc = now })
.Set(value => value.UpdatedUtc, now);
update = reward.Grant switch
{
RzStoreGrant.ExamToken => update.Inc(value => value.ExamTokens, 1),
RzStoreGrant.QBankResetToken => update.Inc(value => value.QBankResetTokens, 1),
_ => update
};
var account = await _accounts.FindOneAndUpdateAsync(
filter, update, new FindOneAndUpdateOptions<UserCreditAccount>
{
ReturnDocument = ReturnDocument.After
});
if (account is not null) return SpinResult(CreditOperationResult.Succeeded, account, spinEvent);
account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
priorSpin = account.Spins.FirstOrDefault(value => value.OperationId == operationId);
if (priorSpin is not null) return SpinResult(CreditOperationResult.Succeeded, account, priorSpin);
if (account.Credits < SlotMachineEconomy.SpinCost)
return SpinResult(CreditOperationResult.InsufficientCredits, account, null);
return SpinResult(CreditOperationResult.RateLimited, account, null);
}
public async Task<bool> TryConsumeExamTokenAsync(string userId, string operationId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now);
var filter = Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Gt(value => value.ExamTokens, 0) &
Builders<UserCreditAccount>.Filter.AnyNin(value => value.AppliedOperations, [operationId]);
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.ExamTokens, -1)
.AddToSet(value => value.AppliedOperations, operationId)
.Set(value => value.UpdatedUtc, now);
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1;
}
public Task RefundExamTokenAsync(string userId, string operationId) =>
_accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.AnyEq(value => value.AppliedOperations, operationId),
Builders<UserCreditAccount>.Update
.Inc(value => value.ExamTokens, 1)
.Pull(value => value.AppliedOperations, operationId)
.Set(value => value.UpdatedUtc, _clock.GetUtcNow().UtcDateTime));
private async Task EnsureAndCleanAsync(string userId, DateTime now)
{
await _accounts.UpdateOneAsync(
@@ -67,18 +363,130 @@ public sealed class CreditStore
Builders<UserCreditAccount>.Update
.SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.Credits, 0)
.SetOnInsert(value => value.ExamTokens, 1)
.SetOnInsert(value => value.QBankResetTokens, 0)
.SetOnInsert(value => value.BankCycle, 0)
.SetOnInsert(value => value.InitialExamGrantApplied, true)
.SetOnInsert(value => value.MasteryAwardBaselineApplied, false)
.SetOnInsert(value => value.RecentEarnings, [])
.SetOnInsert(value => value.Purchases, [])
.SetOnInsert(value => value.Spins, [])
.SetOnInsert(value => value.RecentSpins, [])
.SetOnInsert(value => value.AppliedOperations, [])
.SetOnInsert(value => value.UpdatedUtc, now),
new UpdateOptions { IsUpsert = true });
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Ne(value => value.InitialExamGrantApplied, true),
Builders<UserCreditAccount>.Update
.Inc(value => value.ExamTokens, 1)
.Set(value => value.InitialExamGrantApplied, 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, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Purchases), false),
Builders<UserCreditAccount>.Update.Set(value => value.Purchases, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.Spins), false),
Builders<UserCreditAccount>.Update.Set(value => value.Spins, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentSpins), false),
Builders<UserCreditAccount>.Update.Set(value => value.RecentSpins, []));
await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.AppliedOperations), false),
Builders<UserCreditAccount>.Update.Set(value => value.AppliedOperations, []));
var cutoff = now - CreditEconomy.EarningWindow;
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update.PullFilter(
value => value.RecentEarnings,
earning => earning.CreatedUtc <= cutoff));
var spinCutoff = now - SlotMachineEconomy.SpinWindow;
await _accounts.UpdateOneAsync(
value => value.UserId == userId,
Builders<UserCreditAccount>.Update.PullFilter(
value => value.RecentSpins,
spin => spin.CreatedUtc <= spinCutoff));
}
private static CreditStatusResponse ToStatus(UserCreditAccount account) => new()
{
Credits = account.Credits,
EarnedInRollingWindow = account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount)),
RemainingEarningCapacity = Math.Max(
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Sum(value => Math.Max(1, value.CoinAmount))),
ExamTokens = account.ExamTokens,
QBankResetTokens = account.QBankResetTokens,
BankCycle = account.BankCycle
};
private static CreditOperationResponse Response(
CreditOperationResult result, UserCreditAccount account) => new()
{
Result = result,
Status = ToStatus(account)
};
private static SpinResponse SpinResult(
CreditOperationResult result, UserCreditAccount account, SpinEvent? spinEvent) => new()
{
Result = result,
RewardKey = spinEvent?.RewardKey ?? "",
RewardLabel = spinEvent is null ? "" : SlotMachineEconomy.FindReward(spinEvent.RewardKey)?.Label ?? "",
CoinsWon = spinEvent?.CoinsWon ?? 0,
Status = ToStatus(account)
};
private static string LedgerId(string userId, string operationId) => $"{userId}:{operationId}";
private static int AppliedAmount(UserCreditAccount account, CreditLedgerEntry entry) =>
entry.SubjectToRollingCap
? account.RecentEarnings.FirstOrDefault(value => value.OperationId == entry.OperationId)?.CoinAmount ?? 0
: entry.RequestedAmount;
private static FilterDefinition<UserCreditAccount> RollingEarnedAtMost(int maximum) =>
new BsonDocumentFilterDefinition<UserCreditAccount>(new BsonDocument("$expr",
new BsonDocument("$lte", new BsonArray
{
new BsonDocument("$sum", new BsonDocument("$map", new BsonDocument
{
{ "input", new BsonDocument("$ifNull", new BsonArray { "$RecentEarnings", new BsonArray() }) },
{ "as", "earning" },
{ "in", new BsonDocument("$ifNull", new BsonArray { "$$earning.CoinAmount", 1 }) }
})),
maximum
})));
private Task MarkLedgerAppliedAsync(CreditLedgerEntry entry, int amount, DateTime now) =>
_ledger.UpdateOneAsync(value => value.Id == entry.Id,
Builders<CreditLedgerEntry>.Update.Set(value => value.Amount, amount)
.Set(value => value.State, "applied").Set(value => value.AppliedUtc, now));
private Task RecordPurchaseLedgerAsync(string userId, RzStoreProduct product, string operationId, DateTime createdUtc) =>
_ledger.ReplaceOneAsync(
value => value.Id == LedgerId(userId, $"purchase:{operationId}"),
new CreditLedgerEntry
{
Id = LedgerId(userId, $"purchase:{operationId}"), UserId = userId,
OperationId = $"purchase:{operationId}", RequestedAmount = -product.CoinPrice,
Amount = -product.CoinPrice, ReasonCode = CreditReasonCodes.Purchase,
Description = $"Purchased {product.Name}", SourceType = "store_product",
SourceId = product.Sku, State = "applied", CreatedUtc = createdUtc, AppliedUtc = createdUtc
},
new ReplaceOptions { IsUpsert = true });
private static CreditAwardResult Result(CreditLedgerEntry entry, CreditStatusResponse status) => new()
{
Award = entry.Amount <= 0 ? null : new CreditAward
{
Amount = entry.Amount, ReasonCode = entry.ReasonCode, Description = entry.Description
},
Status = status
};
}
@@ -0,0 +1,203 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class DailyMissionStore
{
private readonly IMongoCollection<DailyMissionSet> _sets;
private readonly CreditStore _credits;
private readonly TimeProvider _clock;
private readonly ServiceMetrics _metrics;
public DailyMissionStore(MongoDatabaseProvider mongo, CreditStore credits, TimeProvider clock, ServiceMetrics metrics)
{
_sets = mongo.Database.GetCollection<DailyMissionSet>("DailyMissionSets");
_credits = credits;
_clock = clock;
_metrics = metrics;
_sets.Indexes.CreateOne(new CreateIndexModel<DailyMissionSet>(
Builders<DailyMissionSet>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.DateUtc),
new CreateIndexOptions { Unique = true, Name = "uq_daily_mission_user_date" }));
}
public async Task<DailyMissionEnvelope> GetOrCreateAsync(
string userId, DashboardResponse dashboard, IReadOnlyCollection<Question> published)
{
var now = _clock.GetUtcNow().UtcDateTime;
await RecordExpiredAsync(userId, now);
var key = DateKey(now);
var existing = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
if (existing is not null) return Envelope(existing);
var candidates = BuildCandidates(dashboard, published, now);
if (candidates.Count < 3) throw new InvalidOperationException("Not enough eligible question-bank coverage to create daily missions.");
var set = new DailyMissionSet
{
Id = $"{userId}|{key}", UserId = userId, DateUtc = key,
Missions = candidates.Take(3).ToList(), ReplacementCandidates = candidates.Skip(3).ToList(),
CreatedUtc = now, ExpiresUtc = now.Date.AddDays(1)
};
try
{
await _sets.InsertOneAsync(set);
foreach (var mission in set.Missions) _metrics.DailyMissionAssigned(mission.Type);
}
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstAsync();
}
return Envelope(set);
}
public async Task<DailyMissionEnvelope?> ReplaceAsync(string userId, string missionId)
{
var key = DateKey(_clock.GetUtcNow().UtcDateTime);
var set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
if (set is null) return null;
if (set.ReplacementUsed) throw new InvalidOperationException("Today's mission replacement has already been used.");
var index = set.Missions.FindIndex(value => value.Id == missionId);
if (index < 0) throw new ArgumentException("Mission not found.");
if (set.Missions[index].CompletedUtc.HasValue) throw new InvalidOperationException("Completed missions cannot be replaced.");
if (set.ReplacementCandidates.Count == 0) throw new InvalidOperationException("No replacement mission is available.");
var replacement = set.ReplacementCandidates[0];
replacement.StartsUtc = _clock.GetUtcNow().UtcDateTime;
set.Missions[index] = replacement;
set.ReplacementCandidates.RemoveAt(0);
set.ReplacementUsed = true;
var changed = await _sets.ReplaceOneAsync(value => value.Id == set.Id && !value.ReplacementUsed, set);
if (changed.ModifiedCount != 1) throw new InvalidOperationException("Today's mission replacement has already been used.");
_metrics.DailyMissionReplaced();
return Envelope(set);
}
public Task<DailyMissionEnvelope?> RecordQuestionAsync(string userId, QuestionAttempt attempt, Question question) =>
!attempt.IsCorrect || attempt.IsSkip ? Task.FromResult<DailyMissionEnvelope?>(null) :
RecordAsync(userId, $"question:{attempt.Id}", attempt.CreatedUtc,
mission => QualifiesQuestion(mission, attempt, question));
public Task<DailyMissionEnvelope?> RecordReviewAsync(string userId, QuestionReviewAttempt attempt) =>
!attempt.IsCorrect ? Task.FromResult<DailyMissionEnvelope?>(null) :
RecordAsync(userId, $"review:{attempt.Id}", attempt.CreatedUtc,
mission => QualifiesReview(mission, attempt));
public static bool QualifiesQuestion(DailyMission mission, QuestionAttempt attempt, Question question)
{
if (!attempt.IsCorrect || attempt.IsSkip || attempt.CreatedUtc < mission.StartsUtc) return false;
var system = Step3SystemCatalog.Normalize(question);
return mission.Type == DailyMissionTypes.WeakSystemCorrect && mission.Step3System == system
|| mission.Type == DailyMissionTypes.DifficultyFourCorrect
&& mission.Step3System == system && question.Difficulty >= 4;
}
public static bool QualifiesReview(DailyMission mission, QuestionReviewAttempt attempt) =>
attempt.IsCorrect && attempt.CreatedUtc >= mission.StartsUtc && mission.Type == DailyMissionTypes.DueReviews;
private async Task<DailyMissionEnvelope?> RecordAsync(
string userId, string eventId, DateTime eventUtc, Func<DailyMission, bool> qualifies)
{
var key = DateKey(_clock.GetUtcNow().UtcDateTime);
var set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
if (set is null) return null;
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null
&& value.StartsUtc <= eventUtc && qualifies(value)))
{
var missionFilter = Builders<DailyMission>.Filter.Eq(value => value.Id, mission.Id)
& Builders<DailyMission>.Filter.Eq(value => value.CompletedUtc, null)
& Builders<DailyMission>.Filter.AnyNin(value => value.AppliedEventIds, [eventId]);
var filter = Builders<DailyMissionSet>.Filter.Eq(value => value.Id, set.Id)
& Builders<DailyMissionSet>.Filter.ElemMatch(value => value.Missions, missionFilter);
await _sets.UpdateOneAsync(filter, Builders<DailyMissionSet>.Update
.Inc("Missions.$.Progress", 1).AddToSet("Missions.$.AppliedEventIds", eventId));
}
set = await _sets.Find(value => value.Id == set.Id).FirstAsync();
var awards = new List<CreditAward>();
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null && value.Progress >= value.Target))
{
var now = _clock.GetUtcNow().UtcDateTime;
var filter = Builders<DailyMissionSet>.Filter.Eq(value => value.Id, set.Id)
& Builders<DailyMissionSet>.Filter.ElemMatch(value => value.Missions,
item => item.Id == mission.Id && item.CompletedUtc == null && item.Progress >= item.Target);
var completed = await _sets.UpdateOneAsync(filter,
Builders<DailyMissionSet>.Update.Set("Missions.$.CompletedUtc", now));
if (completed.ModifiedCount != 1) continue;
var award = await _credits.AwardAsync(userId, new CreditAwardRequest(
$"mission:{mission.Id}:complete", 3, CreditReasonCodes.DailyMissionComplete,
$"Completed daily mission: {mission.Title}", "daily_mission", mission.Id, true));
if (award.Award is not null) awards.Add(award.Award);
_metrics.DailyMissionCompleted(mission.Type);
}
set = await _sets.Find(value => value.Id == set.Id).FirstAsync();
return new DailyMissionEnvelope { State = Response(set), CreditAwards = awards };
}
public static List<DailyMission> BuildCandidates(
DashboardResponse dashboard, IReadOnlyCollection<Question> published, DateTime now)
{
var availability = published.GroupBy(Step3SystemCatalog.Normalize)
.Where(group => !string.IsNullOrWhiteSpace(group.Key))
.ToDictionary(group => group.Key!, group => (All: group.Count(), Hard: group.Count(q => q.Difficulty >= 4)));
var systems = dashboard.Systems.Where(value => availability.ContainsKey(value.System))
.OrderBy(value => MasteryRank(value.MasteryState)).ThenBy(value => value.MasteryScore)
.ThenBy(value => value.DistinctQuestions).ThenBy(value => value.System).ToList();
var result = new List<DailyMission>();
if (dashboard.MissedQuestionCount > 0)
result.Add(New(DailyMissionTypes.DueReviews,
$"Complete {Math.Min(10, dashboard.MissedQuestionCount)} scheduled reviews", "", 0,
Math.Min(10, dashboard.MissedQuestionCount), now));
var weak = systems.Where(value => availability[value.System].All >= 3).Select(system =>
New(DailyMissionTypes.WeakSystemCorrect,
$"Get 3 first-attempt answers correct in {system.System}", system.System, 0, 3, now));
var hard = systems.Where(value => availability[value.System].Hard >= 3)
.OrderBy(value => value.Difficulty4PlusSuccessful).ThenBy(value => value.MasteryScore)
.Select(system => New(DailyMissionTypes.DifficultyFourCorrect,
$"Get 3 difficulty 4+ answers correct in {system.System}", system.System, 4, 3, now));
var weakList = weak.ToList();
var hardList = hard.ToList();
if (weakList.Count > 0) result.Add(weakList[0]);
var firstHard = hardList.FirstOrDefault(value => value.Step3System != weakList.FirstOrDefault()?.Step3System)
?? hardList.FirstOrDefault();
if (firstHard is not null) result.Add(firstHard);
result.AddRange(weakList.Skip(1));
result.AddRange(hardList.Where(value => value.Id != firstHard?.Id));
return result.GroupBy(value => $"{value.Type}|{value.Step3System}").Select(group => group.First()).ToList();
}
private static DailyMission New(string type, string title, string system, int difficulty, int target, DateTime now) =>
new() { Type = type, Title = title, Step3System = system, DifficultyMin = difficulty,
Target = target, StartsUtc = now };
private static int MasteryRank(string state) => state switch
{
MasteryStates.Unseen => 0, MasteryStates.Learning => 1, MasteryStates.Practicing => 2,
MasteryStates.Strong => 3, _ => 4
};
private static string DateKey(DateTime now) => now.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
private async Task RecordExpiredAsync(string userId, DateTime now)
{
var expired = await _sets.Find(value => value.UserId == userId && value.ExpiresUtc <= now
&& !value.ExpirationRecorded).ToListAsync();
foreach (var set in expired)
{
var changed = await _sets.UpdateOneAsync(value => value.Id == set.Id && !value.ExpirationRecorded,
Builders<DailyMissionSet>.Update.Set(value => value.ExpirationRecorded, true));
if (changed.ModifiedCount != 1) continue;
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null))
_metrics.DailyMissionExpired(mission.Type);
}
}
private static DailyMissionEnvelope Envelope(DailyMissionSet set) => new() { State = Response(set) };
private static DailyMissionSetResponse Response(DailyMissionSet set) => new()
{
DateUtc = set.DateUtc, ExpiresUtc = set.ExpiresUtc,
ReplacementAvailable = !set.ReplacementUsed && set.ReplacementCandidates.Count > 0
&& set.Missions.Any(value => value.CompletedUtc is null),
Missions = set.Missions.Select(value => new DailyMissionResponse
{
Id = value.Id, Type = value.Type, Title = value.Title, Step3System = value.Step3System,
DifficultyMin = value.DifficultyMin, Target = value.Target,
Progress = Math.Min(value.Progress, value.Target), Completed = value.CompletedUtc.HasValue
}).ToList()
};
}
+12 -72
View File
@@ -22,23 +22,7 @@ public sealed class DashboardStore
private readonly CcsRatingStore _ccsRatingStore;
private readonly IMongoCollection<UserRating> _ratings;
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 static readonly string[] Step3Systems = Step3SystemCatalog.All;
public DashboardStore(
MongoDatabaseProvider mongo,
@@ -69,6 +53,7 @@ public sealed class DashboardStore
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
{
await _reviewQueue.EnsureUserQueueAsync(userId);
var today = nowUtc.Date;
var trendStart = today.AddDays(-29);
var weekStart = today.AddDays(-6);
@@ -86,22 +71,25 @@ public sealed class DashboardStore
var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
var allReviewAttemptsTask = _reviewQueue.GetUserReviewsAsync(userId);
var reviewItemsTask = _reviewQueue.GetUserItemsAsync(userId);
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);
flashcardReviewsTask, dynamicReviewsTask, allReviewAttemptsTask, reviewItemsTask);
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 questionMap = await _questions.GetByExternalIdsAsync(attempts.Select(value => value.QuestionExternalId)
.Concat(reviewItemsTask.Result.Select(value => value.QuestionExternalId))
.Concat(allReviewAttemptsTask.Result.Select(value => value.QuestionExternalId)));
var ccsAttempts = ccsAttemptsTask.Result;
var simulations = simulationsTask.Result;
var completedCcs = ccsAttempts.Where(value => value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue).ToList();
await _reviewQueue.EnsureUserQueueAsync(userId);
var pendingReviewCount = await _reviewQueue.CountPendingAsync(userId);
var qbankElo = ratingTask.Result.OverallElo;
@@ -148,7 +136,8 @@ public sealed class DashboardStore
};
response.PerformanceTrend = performanceTrend;
response.Systems = BuildSystemPerformance(answeredAttempts, questionMap);
response.Systems = MasteryCalculator.Calculate(
attempts, allReviewAttemptsTask.Result, reviewItemsTask.Result, questionMap, nowUtc);
response.WeeklyActivity = Enumerable.Range(0, 7).Select(offset =>
{
@@ -303,59 +292,10 @@ public sealed class DashboardStore
}
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;
}
=> Step3SystemCatalog.Normalize(question);
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);
=> Step3SystemCatalog.Normalize(value);
private async Task<List<DashboardRecentActivity>> BuildRecentActivityAsync(
List<QuestionAttempt> attempts,
@@ -283,7 +283,8 @@ public sealed class DynamicQuestionGenerator
IReadOnlyDictionary<string, string> values)
{
var result = new Dictionary<string, WrongAnswerFollowup>();
foreach (var distractor in distractors)
var distractorList = distractors.ToList();
foreach (var distractor in distractorList)
{
if (templates.TryGetValue(distractor.Id, out var template))
{
@@ -297,16 +298,18 @@ public sealed class DynamicQuestionGenerator
};
continue;
}
var alternatives = distractorList.Where(value => value.Id != distractor.Id).Take(2).ToArray();
result[distractor.Id] = new WrongAnswerFollowup
{
Prompt = "Which option best addresses the decisive finding in this case?",
Prompt = $"Which limitation of option {distractor.Id} should have ruled it out in this case?",
Choices =
[
new QuestionChoice { Id = correct.Id, Text = Render(correct.TextTemplate, values) },
new QuestionChoice { Id = distractor.Id, Text = Render(distractor.TextTemplate, values) }
new QuestionChoice { Id = "A", Text = Render(distractor.ExplanationTemplate, values) },
new QuestionChoice { Id = "B", Text = Render(alternatives[0].ExplanationTemplate, values) },
new QuestionChoice { Id = "C", Text = Render(alternatives[1].ExplanationTemplate, values) }
],
CorrectChoiceId = correct.Id,
TeachingPoint = $"{Render(distractor.ExplanationTemplate, values)} {Render(correct.ExplanationTemplate, values)}"
CorrectChoiceId = "A",
TeachingPoint = Render(distractor.ExplanationTemplate, values)
};
}
return result;
@@ -4,33 +4,35 @@ namespace QuestionsApi.Services;
public static class ExamSimulationScoring
{
public const string ModelVersion = "provisional-1";
private const double PriorMean = 1000.0;
private const double PriorSd = 400.0;
public const string ModelVersion = "provisional-2";
private const double PassingAccuracy = 0.60;
private const double ScoreIntercept = 50.0;
private const double ScoreScale = 250.0;
public static ExamSimulationScore Calculate(
IReadOnlyList<ExamSimulationItem> items,
IReadOnlyDictionary<string, ExamSimulationResponse> responses,
IReadOnlyDictionary<string, Question> questions)
{
var ability = EstimateAbility(items, responses, questions);
var predicted = Math.Clamp((int)Math.Round(227.0 + (ability - 1000.0) * 15.0 / 200.0), 1, 300);
var information = 1.0 / (PriorSd * PriorSd);
var correct = 0;
var seen = 0;
var weightedCorrect = 0.0;
var weightSum = 0.0;
var squaredWeightSum = 0.0;
var categories = new Dictionary<string, ExamSimulationCategoryScore>();
foreach (var item in items)
{
if (!questions.TryGetValue(item.QuestionExternalId, out var question))
continue;
questions.TryGetValue(item.QuestionExternalId, out var question);
responses.TryGetValue(item.QuestionExternalId, out var response);
var isCorrect = response?.ChoiceId == question.CorrectChoiceId;
var isCorrect = question is not null && response?.ChoiceId == question.CorrectChoiceId;
if (isCorrect) correct++;
if (item.WasSeen) seen++;
var weight = item.WasSeen ? 0.5 : 1.0;
var probability = Expected(ability, item.QuestionElo);
information += weight * probability * (1.0 - probability) * Math.Pow(Math.Log(10.0) / 400.0, 2);
var difficulty = ResolveDifficulty(item, question);
var weight = DifficultyWeight(difficulty);
weightSum += weight;
squaredWeightSum += weight * weight;
if (isCorrect) weightedCorrect += weight;
if (!categories.TryGetValue(item.BlueprintCategory, out var category))
{
category = new ExamSimulationCategoryScore();
@@ -40,51 +42,53 @@ public static class ExamSimulationScoring
if (isCorrect) category.Correct++;
}
var abilitySe = Math.Sqrt(1.0 / information);
var scoreSe = abilitySe * 15.0 / 200.0;
var combinedSe = Math.Sqrt(7.0 * 7.0 + 15.0 * 15.0 + scoreSe * scoreSe);
var range = (int)Math.Ceiling(combinedSe);
var passProbability = NormalCdf((predicted - 200.0) / combinedSe);
var rawAccuracy = items.Count == 0 ? 0.0 : (double)correct / items.Count;
var adjustedAccuracy = weightSum == 0.0 ? 0.0 : weightedCorrect / weightSum;
var predicted = ToScore(adjustedAccuracy);
var effectiveSampleSize = squaredWeightSum == 0.0 ? 0.0 : weightSum * weightSum / squaredWeightSum;
var accuracySe = effectiveSampleSize == 0.0
? 0.0
: Math.Sqrt(adjustedAccuracy * (1.0 - adjustedAccuracy) / effectiveSampleSize);
var accuracyMargin = 1.96 * accuracySe;
var rangeLow = ToScore(Math.Clamp(adjustedAccuracy - accuracyMargin, 0.0, 1.0));
var rangeHigh = ToScore(Math.Clamp(adjustedAccuracy + accuracyMargin, 0.0, 1.0));
var passProbability = accuracySe == 0.0
? adjustedAccuracy >= PassingAccuracy ? 1.0 : 0.0
: NormalCdf((adjustedAccuracy - PassingAccuracy) / accuracySe);
return new ExamSimulationScore
{
PredictedScore = predicted,
RangeLow = Math.Clamp(predicted - range, 1, 300),
RangeHigh = Math.Clamp(predicted + range, 1, 300),
RangeLow = rangeLow,
RangeHigh = rangeHigh,
PassProbability = Math.Round(passProbability, 4),
Correct = correct,
Total = items.Count,
SeenCount = seen,
AbilityElo = Math.Round(ability, 1),
RawAccuracy = Math.Round(rawAccuracy, 4),
DifficultyAdjustedAccuracy = Math.Round(adjustedAccuracy, 4),
AbilityElo = null,
Categories = categories
};
}
private static double EstimateAbility(
IReadOnlyList<ExamSimulationItem> items,
IReadOnlyDictionary<string, ExamSimulationResponse> responses,
IReadOnlyDictionary<string, Question> questions)
public static int ResolveDifficulty(ExamSimulationItem item, Question? question = null)
{
var low = 100.0;
var high = 1900.0;
for (var iteration = 0; iteration < 80; iteration++)
{
var ability = (low + high) / 2.0;
var derivative = -(ability - PriorMean) / (PriorSd * PriorSd);
foreach (var item in items)
{
if (!questions.TryGetValue(item.QuestionExternalId, out var question)) continue;
responses.TryGetValue(item.QuestionExternalId, out var response);
var actual = response?.ChoiceId == question.CorrectChoiceId ? 1.0 : 0.0;
var weight = item.WasSeen ? 0.5 : 1.0;
derivative += weight * (actual - Expected(ability, item.QuestionElo)) * Math.Log(10.0) / 400.0;
}
if (derivative > 0) low = ability; else high = ability;
}
return (low + high) / 2.0;
if (item.QuestionDifficulty is >= 1 and <= 5) return item.QuestionDifficulty;
return question?.Difficulty is >= 1 and <= 5 ? question.Difficulty : 3;
}
private static double Expected(double ability, int difficulty) =>
1.0 / (1.0 + Math.Pow(10.0, (difficulty - ability) / 400.0));
private static double DifficultyWeight(int difficulty) => difficulty switch
{
1 => 0.8,
2 => 0.9,
3 => 1.0,
4 => 1.1,
5 => 1.2,
_ => 1.0
};
private static int ToScore(double accuracy) =>
Math.Clamp((int)Math.Round(ScoreIntercept + ScoreScale * accuracy), 1, 300);
private static double NormalCdf(double value) => 0.5 * (1.0 + Erf(value / Math.Sqrt(2.0)));
@@ -0,0 +1,37 @@
namespace QuestionsApi.Services;
public sealed class ExamSimulationScoringMigrationService : BackgroundService
{
private readonly ExamSimulationStore _simulations;
private readonly ILogger<ExamSimulationScoringMigrationService> _logger;
public ExamSimulationScoringMigrationService(
ExamSimulationStore simulations,
ILogger<ExamSimulationScoringMigrationService> logger)
{
_simulations = simulations;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
try
{
var result = await _simulations.BackfillScoringModelAsync(stoppingToken);
_logger.LogInformation(
"Exam simulation scoring migration completed; recalculated {UpdatedCount} simulation(s)",
result.UpdatedCount);
foreach (var reference in result.MissingQuestionIds)
_logger.LogWarning(
"Exam simulation scoring migration used neutral difficulty for missing question {Reference}",
reference);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
catch (Exception exception)
{
_logger.LogError(exception, "Exam simulation scoring migration failed");
}
}
}
@@ -13,6 +13,7 @@ public class ExamSimulationStore
private readonly IMongoCollection<Step3Outcome> _outcomes;
private readonly QuestionStore _questions;
private readonly QuestionAttemptStore _attempts;
private readonly CreditStore _credits;
private readonly AnswerStatisticsStore _statistics;
private readonly RatingStore _ratings;
private readonly ServiceMetrics _metrics;
@@ -22,6 +23,7 @@ public class ExamSimulationStore
MongoDatabaseProvider mongo,
QuestionStore questions,
QuestionAttemptStore attempts,
CreditStore credits,
AnswerStatisticsStore statistics,
RatingStore ratings,
ServiceMetrics metrics,
@@ -31,6 +33,7 @@ public class ExamSimulationStore
_outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes");
_questions = questions;
_attempts = attempts;
_credits = credits;
_statistics = statistics;
_ratings = ratings;
_metrics = metrics;
@@ -68,7 +71,8 @@ public class ExamSimulationStore
var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList();
if (bank.Count < RequiredQuestions)
throw new InvalidOperationException($"At least {RequiredQuestions} published questions are required.");
var seen = (await _attempts.GetUserQuestionIdsAsync(userId)).ToHashSet(StringComparer.Ordinal);
var bankCycle = await _credits.GetBankCycleAsync(userId);
var seen = (await _attempts.GetUserQuestionIdsAsync(userId, bankCycle)).ToHashSet(StringComparer.Ordinal);
var elos = await _ratings.GetQuestionElosAsync(bank.Select(question => question.ExternalId));
var targets = BuildTargets();
var selected = SelectForm(bank, seen, targets);
@@ -87,6 +91,7 @@ public class ExamSimulationStore
Position = offset,
PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)),
QuestionElo = elos.GetValueOrDefault(question.ExternalId, 1000),
QuestionDifficulty = question.Difficulty,
WasSeen = seen.Contains(question.ExternalId),
BlueprintCategory = BlueprintCategory(question)
});
@@ -124,6 +129,7 @@ public class ExamSimulationStore
Position = PositionWithinBlock(index),
PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)),
QuestionElo = 1000,
QuestionDifficulty = question.Difficulty,
WasSeen = false,
BlueprintCategory = BlueprintCategory(question)
}).ToList();
@@ -169,15 +175,22 @@ public class ExamSimulationStore
(item.State == ExamSimulationStates.BetweenBlocks || item.State == ExamSimulationStates.BlockActive))
.SortByDescending(item => item.UpdatedUtc).FirstOrDefaultAsync();
public async Task<ExamSimulationScore?> GetLatestCompletedScoreAsync(string userId) =>
(await _simulations.Find(item => item.UserId == userId && item.State == ExamSimulationStates.Completed && item.Score != null)
.SortByDescending(item => item.CompletedUtc).FirstOrDefaultAsync())?.Score;
public async Task<ExamSimulationScore?> GetLatestCompletedScoreAsync(string userId)
{
var simulation = await _simulations
.Find(item => item.UserId == userId && item.State == ExamSimulationStates.Completed && item.Score != null)
.SortByDescending(item => item.CompletedUtc)
.FirstOrDefaultAsync();
return simulation is null ? null : (await UpgradeScoringModelAsync(simulation)).Score;
}
public async Task<ExamSimulation?> GetOwnedAsync(string id, string userId)
{
var simulation = await _simulations.Find(item => item.Id == id && item.UserId == userId).FirstOrDefaultAsync();
if (simulation is not null && simulation.State == ExamSimulationStates.BlockActive && simulation.BlockDeadlineUtc <= DateTime.UtcNow)
simulation = await SubmitBlockAsync(simulation, true);
if (simulation is not null)
simulation = await UpgradeScoringModelAsync(simulation);
return simulation;
}
@@ -191,6 +204,7 @@ public class ExamSimulationStore
var simulations = await _simulations.Find(filter).SortByDescending(item => item.CompletedUtc).Limit(limit + 1).ToListAsync();
var hasMore = simulations.Count > limit;
if (hasMore) simulations.RemoveAt(simulations.Count - 1);
await UpgradeScoringModelsAsync(simulations);
return new CompletedExamSimulationPage
{
Items = simulations.Select(item => new CompletedExamSimulationSummary
@@ -257,6 +271,7 @@ public class ExamSimulationStore
var simulations = await _simulations.Find(filter).Sort(sortDefinition).Limit(limit + 1).ToListAsync();
var hasMore = simulations.Count > limit;
if (hasMore) simulations.RemoveAt(simulations.Count - 1);
await UpgradeScoringModelsAsync(simulations.Where(item => item.State == ExamSimulationStates.Completed));
var last = simulations.LastOrDefault();
return new ExamSimulationHistoryPage
{
@@ -333,23 +348,53 @@ public class ExamSimulationStore
if (!item.PresentedChoiceIds.Contains(request.ChoiceId, StringComparer.Ordinal) && request.ChoiceId != "")
throw new ArgumentException("Choice is not presented for this question.");
var response = simulation.Responses.FirstOrDefault(candidate => candidate.QuestionExternalId == request.QuestionExternalId);
if (response is not null && IsResponseLocked(response))
{
if (ResponseContentMatches(response, request, item.PresentedChoiceIds))
return simulation;
throw new InvalidOperationException("A submitted simulation answer cannot be changed.");
}
if (response is null)
{
response = new ExamSimulationResponse { QuestionExternalId = request.QuestionExternalId };
response = new ExamSimulationResponse
{
QuestionExternalId = request.QuestionExternalId,
IsSubmitted = false
};
simulation.Responses.Add(response);
}
response.ChoiceId = request.ChoiceId;
response.EliminatedChoiceIds = request.EliminatedChoiceIds.Where(item.PresentedChoiceIds.Contains).Distinct().ToList();
response.Flagged = request.Flagged;
response.ElapsedSeconds = Math.Clamp(request.ElapsedSeconds, 0, 1800);
response.IsSubmitted = request.IsSubmitted;
response.UpdatedUtc = DateTime.UtcNow;
simulation.UpdatedUtc = DateTime.UtcNow;
await ReplaceAsync(simulation);
return simulation;
}
public static bool IsResponseLocked(ExamSimulationResponse response) =>
response.IsSubmitted == true ||
(response.IsSubmitted is null && !string.IsNullOrEmpty(response.ChoiceId));
public static bool ResponseContentMatches(
ExamSimulationResponse response,
SaveSimulationResponseRequest request,
IReadOnlyCollection<string> presentedChoiceIds)
{
var eliminated = request.EliminatedChoiceIds
.Where(presentedChoiceIds.Contains)
.ToHashSet(StringComparer.Ordinal);
return response.ChoiceId == request.ChoiceId &&
response.Flagged == request.Flagged &&
response.EliminatedChoiceIds.ToHashSet(StringComparer.Ordinal).SetEquals(eliminated);
}
public async Task<ExamSimulation> SubmitBlockAsync(ExamSimulation simulation, bool expired = false)
{
if (IsAlreadySubmittedState(simulation.State))
return simulation;
if (simulation.State != ExamSimulationStates.BlockActive)
throw new InvalidOperationException("There is no active block to submit.");
if (!expired && simulation.BlockDeadlineUtc is not null)
@@ -366,6 +411,9 @@ public class ExamSimulationStore
return simulation;
}
public static bool IsAlreadySubmittedState(string state) =>
state is ExamSimulationStates.BetweenBlocks or ExamSimulationStates.Completed;
public async Task AbandonAsync(ExamSimulation simulation)
{
simulation.State = ExamSimulationStates.Abandoned;
@@ -433,7 +481,9 @@ public class ExamSimulationStore
{
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
SnapshotDifficulties(simulation, questionMap);
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
simulation.State = ExamSimulationStates.Completed;
simulation.CompletedUtc = DateTime.UtcNow;
simulation.UpdatedUtc = DateTime.UtcNow;
@@ -443,7 +493,10 @@ public class ExamSimulationStore
if (!questionMap.TryGetValue(item.QuestionExternalId, out var question)) continue;
responses.TryGetValue(item.QuestionExternalId, out var response);
var choiceId = response?.ChoiceId ?? "";
var record = await _attempts.RecordFirstAttemptAsync(item.QuestionExternalId, simulation.UserId, choiceId, choiceId == question.CorrectChoiceId, choiceId == "", item.PresentedChoiceIds);
var record = await _attempts.RecordFirstAttemptAsync(
item.QuestionExternalId, simulation.UserId, choiceId,
choiceId == question.CorrectChoiceId, choiceId == "",
item.PresentedChoiceIds, bankCycle: await _credits.GetBankCycleAsync(simulation.UserId));
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
if (!record.Inserted) continue;
_metrics.AttemptCreated();
@@ -468,6 +521,71 @@ public class ExamSimulationStore
private Task ReplaceAsync(ExamSimulation simulation) =>
_simulations.ReplaceOneAsync(item => item.Id == simulation.Id && item.UserId == simulation.UserId, simulation);
public async Task<ExamSimulationBackfillResult> BackfillScoringModelAsync(CancellationToken cancellationToken = default)
{
var filter = Builders<ExamSimulation>.Filter.Eq(item => item.State, ExamSimulationStates.Completed) &
Builders<ExamSimulation>.Filter.Ne(item => item.ModelVersion, ExamSimulationScoring.ModelVersion);
var simulations = await _simulations.Find(filter).ToListAsync(cancellationToken);
var missingQuestionIds = new List<string>();
var updated = 0;
foreach (var simulation in simulations)
{
cancellationToken.ThrowIfCancellationRequested();
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
missingQuestionIds.AddRange(simulation.Items
.Where(item => !questionMap.ContainsKey(item.QuestionExternalId))
.Select(item => $"{simulation.Id}:{item.QuestionExternalId}"));
SnapshotDifficulties(simulation, questionMap);
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
simulation.UpdatedUtc = DateTime.UtcNow;
await ReplaceAsync(simulation);
updated++;
}
return new ExamSimulationBackfillResult(updated, missingQuestionIds);
}
private async Task UpgradeScoringModelsAsync(IEnumerable<ExamSimulation> simulations)
{
foreach (var simulation in simulations)
await UpgradeScoringModelAsync(simulation);
}
private async Task<ExamSimulation> UpgradeScoringModelAsync(ExamSimulation simulation)
{
var needsDifficultySnapshot = simulation.Items.Any(item => item.QuestionDifficulty is < 1 or > 5);
var needsScoreUpgrade = simulation.State == ExamSimulationStates.Completed &&
simulation.ModelVersion != ExamSimulationScoring.ModelVersion;
if (!needsDifficultySnapshot && !needsScoreUpgrade)
return simulation;
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
SnapshotDifficulties(simulation, questionMap);
if (simulation.State == ExamSimulationStates.Completed)
{
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
}
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
simulation.UpdatedUtc = DateTime.UtcNow;
await ReplaceAsync(simulation);
return simulation;
}
private static void SnapshotDifficulties(
ExamSimulation simulation,
IReadOnlyDictionary<string, Question> questions)
{
foreach (var item in simulation.Items)
{
questions.TryGetValue(item.QuestionExternalId, out var question);
item.QuestionDifficulty = ExamSimulationScoring.ResolveDifficulty(item, question);
}
}
public sealed record ExamSimulationBackfillResult(int UpdatedCount, IReadOnlyList<string> MissingQuestionIds);
private static int BlockIndexForPosition(int position)
{
var consumed = 0;
@@ -0,0 +1,86 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class MasteryAwardService
{
private readonly IMongoCollection<QuestionAttempt> _attempts;
private readonly IMongoCollection<QuestionReviewAttempt> _reviews;
private readonly IMongoCollection<ReviewQueueItem> _queue;
private readonly QuestionStore _questions;
private readonly CreditStore _credits;
private readonly TimeProvider _clock;
public MasteryAwardService(MongoDatabaseProvider mongo, QuestionStore questions, CreditStore credits, TimeProvider clock)
{
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
_reviews = mongo.Database.GetCollection<QuestionReviewAttempt>("QuestionReviewAttempts");
_queue = mongo.Database.GetCollection<ReviewQueueItem>("ReviewQueueItems");
_questions = questions;
_credits = credits;
_clock = clock;
}
public async Task PrepareAsync(string userId)
{
if (await _credits.IsMasteryBaselineAppliedAsync(userId)) return;
var states = await CurrentStatesAsync(userId);
await _credits.InitializeMasteryBaselineAsync(userId, states.SelectMany(BaselineOperations));
}
public async Task<List<CreditAward>> AwardPromotionsAsync(string userId)
{
var awards = new List<CreditAward>();
var applied = await _credits.GetAppliedOperationsAsync(userId);
foreach (var system in await CurrentStatesAsync(userId))
{
foreach (var state in EarnedStates(system.MasteryState))
{
var spec = AwardSpec(state);
if (applied.Contains(Operation(system.System, state))) continue;
var result = await _credits.AwardAsync(userId, new CreditAwardRequest(
Operation(system.System, state), spec.Amount, spec.Reason, spec.Description,
"step3_system", system.System, false));
if (result.Award is not null) awards.Add(result.Award);
applied.Add(Operation(system.System, state));
}
}
return awards;
}
private async Task<List<DashboardSystemPerformance>> CurrentStatesAsync(string userId)
{
var attempts = await _attempts.Find(value => value.UserId == userId).ToListAsync();
var reviews = await _reviews.Find(value => value.UserId == userId).ToListAsync();
var queue = await _queue.Find(value => value.UserId == userId).ToListAsync();
var ids = attempts.Select(value => value.QuestionExternalId)
.Concat(reviews.Select(value => value.QuestionExternalId))
.Concat(queue.Select(value => value.QuestionExternalId));
var questions = await _questions.GetByExternalIdsAsync(ids);
return MasteryCalculator.Calculate(attempts, reviews, queue, questions, _clock.GetUtcNow().UtcDateTime);
}
private static IEnumerable<string> BaselineOperations(DashboardSystemPerformance system) =>
EarnedStates(system.MasteryState).Select(state => Operation(system.System, state));
private static IEnumerable<string> EarnedStates(string state)
{
if (state is MasteryStates.Practicing or MasteryStates.Strong or MasteryStates.Mastered)
yield return MasteryStates.Practicing;
if (state is MasteryStates.Strong or MasteryStates.Mastered)
yield return MasteryStates.Strong;
if (state is MasteryStates.Mastered)
yield return MasteryStates.Mastered;
}
private static string Operation(string system, string state) =>
$"mastery:{system.ToLowerInvariant().Replace(' ', '_')}:{state}";
private static (int Amount, string Reason, string Description) AwardSpec(string state) => state switch
{
MasteryStates.Practicing => (3, CreditReasonCodes.MasteryPracticing, "System advanced to Practicing"),
MasteryStates.Strong => (5, CreditReasonCodes.MasteryStrong, "System advanced to Strong"),
_ => (10, CreditReasonCodes.MasteryMastered, "System advanced to Mastered")
};
}
@@ -0,0 +1,109 @@
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public static class MasteryCalculator
{
private static readonly IReadOnlyDictionary<int, double> DifficultyWeights = new Dictionary<int, double>
{
[1] = 0.6, [2] = 0.8, [3] = 1.0, [4] = 1.2, [5] = 1.4
};
public static List<DashboardSystemPerformance> Calculate(
IEnumerable<QuestionAttempt> attempts,
IEnumerable<QuestionReviewAttempt> reviews,
IEnumerable<ReviewQueueItem> queueItems,
IReadOnlyDictionary<string, Question> questions,
DateTime nowUtc)
{
var mappedAttempts = attempts.Where(attempt => questions.ContainsKey(attempt.QuestionExternalId)).ToList();
var answered = mappedAttempts.Where(attempt => !attempt.IsSkip).ToList();
var maxCycle = mappedAttempts.Count == 0 ? 0 : mappedAttempts.Max(attempt => attempt.BankCycle);
var reviewList = reviews.OrderBy(review => review.CreatedUtc).ToList();
var queueList = queueItems.ToList();
var results = new List<DashboardSystemPerformance>();
foreach (var system in Step3SystemCatalog.All)
{
var systemAttempts = answered.Where(attempt =>
Step3SystemCatalog.Normalize(questions[attempt.QuestionExternalId]) == system).ToList();
var ids = systemAttempts.Select(attempt => attempt.QuestionExternalId).Distinct(StringComparer.Ordinal).ToHashSet(StringComparer.Ordinal);
var systemReviews = reviewList.Where(review => ids.Contains(review.QuestionExternalId)).ToList();
var pending = queueList.Count(item => item.State == ReviewQueueStates.Pending
&& questions.TryGetValue(item.QuestionExternalId, out var question)
&& Step3SystemCatalog.Normalize(question) == system);
double earned = 0;
double possible = 0;
var advancedSuccesses = new HashSet<string>(StringComparer.Ordinal);
foreach (var attempt in systemAttempts)
{
var question = questions[attempt.QuestionExternalId];
var weight = DifficultyWeights.GetValueOrDefault(Math.Clamp(question.Difficulty, 1, 5), 1.0)
* Math.Pow(0.5, Math.Max(0, maxCycle - attempt.BankCycle));
var outcome = attempt.IsCorrect ? 1.0 : RecoveryOutcome(attempt, systemAttempts, systemReviews);
earned += weight * outcome;
possible += weight;
if (question.Difficulty >= 4 && outcome >= 0.75) advancedSuccesses.Add(question.ExternalId);
}
var score = possible == 0 ? 0 : (int)Math.Round(earned / possible * 100, MidpointRounding.AwayFromZero);
var state = State(ids.Count, score, advancedSuccesses.Count, pending);
var lastEvidence = systemAttempts.Select(value => (DateTime?)value.CreatedUtc)
.Concat(systemReviews.Select(value => (DateTime?)value.CreatedUtc)).Max();
var refreshDue = state is MasteryStates.Strong or MasteryStates.Mastered
&& lastEvidence.HasValue && lastEvidence.Value < nowUtc.AddDays(-30);
results.Add(new DashboardSystemPerformance
{
System = system,
Correct = systemAttempts.Count(value => value.IsCorrect),
Total = systemAttempts.Count,
Accuracy = DashboardStore.Percentage(systemAttempts.Count(value => value.IsCorrect), systemAttempts.Count),
MasteryScore = score,
MasteryState = state,
DistinctQuestions = ids.Count,
Difficulty4PlusSuccessful = advancedSuccesses.Count,
PendingReviews = pending,
LastEvidenceUtc = lastEvidence,
RefreshDue = refreshDue,
RecommendedAction = Action(state, pending, refreshDue)
});
}
return results;
}
private static double RecoveryOutcome(
QuestionAttempt attempt,
IReadOnlyCollection<QuestionAttempt> attempts,
IReadOnlyCollection<QuestionReviewAttempt> reviews)
{
var nextAttempt = attempts.Where(value => value.QuestionExternalId == attempt.QuestionExternalId
&& value.CreatedUtc > attempt.CreatedUtc)
.OrderBy(value => value.CreatedUtc).FirstOrDefault();
var recovery = reviews.Where(value => value.QuestionExternalId == attempt.QuestionExternalId
&& value.IsCorrect && value.CreatedUtc > attempt.CreatedUtc
&& (nextAttempt is null || value.CreatedUtc < nextAttempt.CreatedUtc))
.OrderBy(value => value.CreatedUtc).FirstOrDefault();
if (recovery is null) return 0;
return recovery.CreatedUtc - attempt.CreatedUtc >= TimeSpan.FromHours(24) ? 0.75 : 0.5;
}
private static string State(int distinct, int score, int advanced, int pending)
{
if (distinct == 0) return MasteryStates.Unseen;
if (distinct < 5) return MasteryStates.Learning;
if (distinct >= 25 && score >= 85 && advanced >= 5 && pending == 0) return MasteryStates.Mastered;
if (distinct >= 15 && score >= 75) return MasteryStates.Strong;
return MasteryStates.Practicing;
}
private static DashboardMasteryAction Action(string state, int pending, bool refreshDue)
{
if (pending > 0) return new() { Type = "review", Label = "Review missed concepts", QuestionCount = pending };
return state switch
{
MasteryStates.Unseen or MasteryStates.Learning => new() { Type = "practice", Label = "Start learning", QuestionCount = 10 },
MasteryStates.Practicing => new() { Type = "practice", Label = "Build mastery", QuestionCount = 10, DifficultyMin = 3, DifficultyMax = 4 },
MasteryStates.Strong => new() { Type = "practice", Label = "Challenge this system", QuestionCount = 10, DifficultyMin = 4, DifficultyMax = 5 },
MasteryStates.Mastered when refreshDue => new() { Type = "practice", Label = "Refresh mastery", QuestionCount = 5, DifficultyMin = 4, DifficultyMax = 5 },
_ => new() { Type = "practice", Label = "Maintain mastery", QuestionCount = 5, DifficultyMin = 4, DifficultyMax = 5 }
};
}
}
@@ -0,0 +1,42 @@
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public static class ProgressionPolicy
{
public sealed record Threshold(int L, int P, int S, int M, int H, int R);
public static readonly Threshold[] Thresholds =
[
new(0,0,0,0,0,0), new(2,0,0,0,1,0), new(4,1,0,0,3,1),
new(6,2,0,0,6,1), new(8,4,0,0,10,2), new(10,6,0,0,15,3),
new(0,8,1,0,20,4), new(0,9,2,0,28,5), new(0,10,4,0,36,7),
new(0,11,5,1,45,9), new(0,12,7,2,55,11), new(0,13,9,4,65,13),
new(0,14,10,6,75,15), new(0,14,12,9,90,18), new(0,14,14,12,105,21)
];
public static readonly string[] Names = ["Case Scout", "Pattern Hunter", "Diagnostic Tactician", "Management Strategist", "Clinical Vanguard"];
public static readonly string[] Divisions = ["III", "II", "I"];
public static int Qualified(IReadOnlyCollection<DashboardSystemPerformance> systems, int retention)
{
var l = systems.Count(x => State(x.MasteryState) >= 1);
var p = systems.Count(x => State(x.MasteryState) >= 2);
var s = systems.Count(x => State(x.MasteryState) >= 3);
var m = systems.Count(x => State(x.MasteryState) >= 4);
var h = systems.Sum(x => x.Difficulty4PlusSuccessful);
for (var i = Thresholds.Length - 1; i >= 0; i--)
{
var t = Thresholds[i];
if (l >= t.L && p >= t.P && s >= t.S && m >= t.M && h >= t.H && retention >= t.R) return i;
}
return 0;
}
public static int State(string state) => state switch
{
MasteryStates.Learning => 1, MasteryStates.Practicing => 2,
MasteryStates.Strong => 3, MasteryStates.Mastered => 4, _ => 0
};
public static string Name(int ordinal) => Names[Math.Clamp(ordinal / 3, 0, 4)];
public static string Division(int ordinal) => Divisions[Math.Clamp(ordinal % 3, 0, 2)];
public static int TierStart(int ordinal) => ordinal / 3 * 3;
}
@@ -0,0 +1,253 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class ProgressionStore
{
private readonly IMongoCollection<UserProgression> _progressions;
private readonly IMongoCollection<ProgressionWeeklySnapshot> _snapshots;
private readonly IMongoCollection<QuestionAttempt> _attempts;
private readonly IMongoCollection<QuestionReviewAttempt> _reviews;
private readonly IMongoCollection<ReviewQueueItem> _queue;
private readonly IMongoCollection<StudySession> _sessions;
private readonly QuestionStore _questions;
private readonly CreditStore _credits;
private readonly TimeProvider _clock;
public ProgressionStore(MongoDatabaseProvider mongo, QuestionStore questions, CreditStore credits, TimeProvider clock)
{
var db = mongo.Database;
_progressions = db.GetCollection<UserProgression>("UserProgressions");
_snapshots = db.GetCollection<ProgressionWeeklySnapshot>("ProgressionWeeklySnapshots");
_attempts = db.GetCollection<QuestionAttempt>("QuestionAttempts");
_reviews = db.GetCollection<QuestionReviewAttempt>("QuestionReviewAttempts");
_queue = db.GetCollection<ReviewQueueItem>("ReviewQueueItems");
_sessions = db.GetCollection<StudySession>("StudySessions");
_questions = questions;
_credits = credits;
_clock = clock;
_progressions.Indexes.CreateOne(new CreateIndexModel<UserProgression>(
Builders<UserProgression>.IndexKeys.Ascending(x => x.UserId), new CreateIndexOptions { Unique = true }));
_snapshots.Indexes.CreateOne(new CreateIndexModel<ProgressionWeeklySnapshot>(
Builders<ProgressionWeeklySnapshot>.IndexKeys.Ascending(x => x.UserId).Descending(x => x.WeekStartUtc)));
}
public async Task PrepareAsync(string userId)
{
if (await _progressions.Find(x => x.UserId == userId).AnyAsync())
{
await EnsureWeeklyOpeningAsync(userId);
return;
}
var evidence = await EvidenceAsync(userId);
var qualified = ProgressionPolicy.Qualified(evidence.Systems, evidence.Retention);
var now = _clock.GetUtcNow().UtcDateTime;
var progression = new UserProgression
{
UserId = userId, HighestRankOrdinal = qualified, BaselineApplied = true,
TrackingStartedUtc = now, UpdatedUtc = now,
EarnedRanksUtc = Enumerable.Range(0, qualified + 1).ToDictionary(x => x.ToString(), _ => now)
};
ApplyHistoricalRecords(progression, evidence);
try { await _progressions.InsertOneAsync(progression); }
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { }
await EnsureWeeklyOpeningAsync(userId, evidence);
}
public async Task<(ProgressionResponse State, List<CreditAward> Awards)> RefreshAsync(string userId)
{
await PrepareAsync(userId);
var evidence = await EvidenceAsync(userId);
var progression = await _progressions.Find(x => x.UserId == userId).FirstAsync();
var oldHighest = progression.HighestRankOrdinal;
var qualified = ProgressionPolicy.Qualified(evidence.Systems, evidence.Retention);
var now = _clock.GetUtcNow().UtcDateTime;
ApplyHistoricalRecords(progression, evidence);
if (qualified > progression.HighestRankOrdinal)
{
progression.HighestRankOrdinal = qualified;
for (var i = oldHighest + 1; i <= qualified; i++) progression.EarnedRanksUtc.TryAdd(i.ToString(), now);
}
await UpdateWeeklyAsync(progression, evidence, now);
progression.UpdatedUtc = now;
var update = Builders<UserProgression>.Update.Max(x => x.HighestRankOrdinal, qualified)
.Set(x => x.CurrentHardStreak, progression.CurrentHardStreak)
.Set(x => x.BestHardStreak, progression.BestHardStreak)
.Set(x => x.BestHardStreakUtc, progression.BestHardStreakUtc)
.Set(x => x.BestTimedBlock, progression.BestTimedBlock)
.Set(x => x.SystemsAdvancedWeek, progression.SystemsAdvancedWeek)
.Set(x => x.ReviewQueueReduction, progression.ReviewQueueReduction)
.Set(x => x.WeakSystemImprovement, progression.WeakSystemImprovement)
.Set(x => x.UpdatedUtc, now);
for (var i = oldHighest + 1; i <= qualified; i++) update = update.Set($"EarnedRanksUtc.{i}", now);
await _progressions.UpdateOneAsync(x => x.UserId == userId, update);
progression = await _progressions.Find(x => x.UserId == userId).FirstAsync();
var awards = new List<CreditAward>();
foreach (var tier in new[] { 3, 6, 9, 12 }.Where(x => oldHighest < x && qualified >= x))
{
var amount = tier switch { 3 => 5, 6 => 10, 9 => 15, _ => 25 };
var result = await _credits.AwardAsync(userId, new CreditAwardRequest(
$"progression:rank:{tier}", amount, CreditReasonCodes.RankPromotion,
$"Advanced to {ProgressionPolicy.Name(tier)}", "progression_rank", tier.ToString(), false));
if (result.Award is not null) awards.Add(result.Award);
}
return (Response(progression, qualified, evidence), awards);
}
public async Task<ProgressionResponse> GetAsync(string userId) => (await RefreshAsync(userId)).State;
private async Task EnsureWeeklyOpeningAsync(string userId, Evidence? supplied = null)
{
var now = _clock.GetUtcNow().UtcDateTime;
var week = WeekStart(now);
var id = $"{userId}:{week:yyyyMMdd}";
if (await _snapshots.Find(x => x.Id == id).AnyAsync()) return;
var evidence = supplied ?? await EvidenceAsync(userId);
var values = Snapshots(evidence.Systems);
var snapshot = new ProgressionWeeklySnapshot
{
Id = id, UserId = userId, WeekStartUtc = week,
OpeningSystems = values, LatestSystems = values,
OpeningPendingReviews = evidence.Pending, LatestPendingReviews = evidence.Pending, UpdatedUtc = now
};
try { await _snapshots.InsertOneAsync(snapshot); }
catch (MongoWriteException exception) when (exception.WriteError?.Category == ServerErrorCategory.DuplicateKey) { }
}
private async Task UpdateWeeklyAsync(UserProgression progression, Evidence evidence, DateTime now)
{
await EnsureWeeklyOpeningAsync(progression.UserId, evidence);
var week = WeekStart(now);
var id = $"{progression.UserId}:{week:yyyyMMdd}";
await _snapshots.UpdateOneAsync(x => x.Id == id, Builders<ProgressionWeeklySnapshot>.Update
.Set(x => x.LatestSystems, Snapshots(evidence.Systems)).Set(x => x.LatestPendingReviews, evidence.Pending)
.Set(x => x.UpdatedUtc, now));
var all = await _snapshots.Find(x => x.UserId == progression.UserId).ToListAsync();
foreach (var snapshot in all)
{
var opening = snapshot.OpeningSystems.ToDictionary(x => x.System);
var advanced = snapshot.LatestSystems.Count(x => opening.TryGetValue(x.System, out var before) && x.StateOrdinal > before.StateOrdinal);
SetRecordIfBetter(progression.SystemsAdvancedWeek, advanced, 0, $"Week of {snapshot.WeekStartUtc:yyyy-MM-dd}", snapshot.UpdatedUtc);
var reduced = Math.Max(0, snapshot.OpeningPendingReviews - snapshot.LatestPendingReviews);
SetRecordIfBetter(progression.ReviewQueueReduction, reduced, snapshot.OpeningPendingReviews,
$"Week of {snapshot.WeekStartUtc:yyyy-MM-dd}", snapshot.UpdatedUtc);
foreach (var after in snapshot.LatestSystems)
{
if (!opening.TryGetValue(after.System, out var before) || before.MasteryScore >= 75 || before.DistinctQuestions < 5) continue;
SetRecordIfBetter(progression.WeakSystemImprovement, Math.Max(0, after.MasteryScore - before.MasteryScore),
after.DistinctQuestions, after.System, snapshot.UpdatedUtc);
}
}
}
private async Task<Evidence> EvidenceAsync(string userId)
{
var attempts = await _attempts.Find(x => x.UserId == userId).SortBy(x => x.CreatedUtc).ToListAsync();
var reviews = await _reviews.Find(x => x.UserId == userId).SortBy(x => x.CreatedUtc).ToListAsync();
var queue = await _queue.Find(x => x.UserId == userId).ToListAsync();
var ids = attempts.Select(x => x.QuestionExternalId).Concat(reviews.Select(x => x.QuestionExternalId)).Concat(queue.Select(x => x.QuestionExternalId));
var questions = await _questions.GetByExternalIdsAsync(ids);
var systems = MasteryCalculator.Calculate(attempts, reviews, queue, questions, _clock.GetUtcNow().UtcDateTime);
var delayed = reviews.Where(x => x.IsCorrect).Select(x => new
{
Review = x, Item = queue.FirstOrDefault(q => q.QuestionExternalId == x.QuestionExternalId)
}).Where(x => x.Item is not null && x.Review.CreatedUtc - x.Item.CreatedUtc >= TimeSpan.FromHours(24))
.Select(x => x.Review.QuestionExternalId).Distinct().Count();
var directHard = attempts.Where(x => x.IsCorrect && !x.IsSkip && questions.TryGetValue(x.QuestionExternalId, out var q) && q.Difficulty >= 4)
.Select(x => x.QuestionExternalId).Distinct().Count();
var sessions = await _sessions.Find(x => x.UserId == userId && x.State == StudySessionStates.Completed
&& x.Mode == "Timed" && x.GoalType != StudyGoalTypes.SimulationBlock).ToListAsync();
return new Evidence(attempts, reviews, queue, sessions, questions, systems, delayed + directHard / 5,
queue.Count(x => x.State == ReviewQueueStates.Pending));
}
private void ApplyHistoricalRecords(UserProgression progression, Evidence evidence)
{
var streak = 0;
foreach (var attempt in evidence.Attempts.Where(x => evidence.Questions.TryGetValue(x.QuestionExternalId, out var q) && q.Difficulty >= 4))
{
streak = attempt.IsCorrect && !attempt.IsSkip ? streak + 1 : 0;
if (streak > progression.BestHardStreak)
{
progression.BestHardStreak = streak;
progression.BestHardStreakUtc = attempt.CreatedUtc;
}
}
progression.CurrentHardStreak = streak;
var attemptsById = evidence.Attempts.Where(x => x.Id is not null).ToDictionary(x => x.Id!);
foreach (var session in evidence.Sessions)
{
var sessionAttempts = session.AppliedEventIds.Where(attemptsById.ContainsKey).Select(x => attemptsById[x]).ToList();
if (sessionAttempts.Count < 20 || sessionAttempts.Count != session.AppliedEventIds.Count) continue;
var hard = sessionAttempts.Count(x => evidence.Questions.TryGetValue(x.QuestionExternalId, out var q) && q.Difficulty >= 4);
if (hard * 2 < sessionAttempts.Count) continue;
var accuracy = (int)Math.Round(sessionAttempts.Count(x => x.IsCorrect && !x.IsSkip) * 100d / sessionAttempts.Count);
if (accuracy > progression.BestTimedBlock.Value
|| accuracy == progression.BestTimedBlock.Value && sessionAttempts.Count > progression.BestTimedBlock.SampleSize)
progression.BestTimedBlock = new PersonalRecordValue { Value = accuracy, SampleSize = sessionAttempts.Count,
Detail = $"{hard} difficulty 4–5 questions", AchievedUtc = session.CompletedUtc ?? session.UpdatedUtc };
}
}
private static void SetRecordIfBetter(PersonalRecordValue record, int value, int sample, string detail, DateTime date)
{
if (value <= record.Value) return;
record.Value = value; record.SampleSize = sample; record.Detail = detail; record.AchievedUtc = date;
}
private static ProgressionResponse Response(UserProgression p, int qualified, Evidence evidence)
{
var earnedTierNames = Enumerable.Range(0, p.HighestRankOrdinal / 3 + 1).Select(x => ProgressionPolicy.Names[x]).ToList();
var response = new ProgressionResponse
{
RankOrdinal = p.HighestRankOrdinal, QualifiedRankOrdinal = qualified,
RankName = ProgressionPolicy.Name(p.HighestRankOrdinal), Division = ProgressionPolicy.Division(p.HighestRankOrdinal),
TrackingStartedUtc = p.TrackingStartedUtc, EarnedBadges = earnedTierNames, EarnedRanksUtc = p.EarnedRanksUtc,
Records = Records(p), Ladder = Enumerable.Range(0, ProgressionPolicy.Thresholds.Length).Select(i =>
{
var t = ProgressionPolicy.Thresholds[i];
var parts = new List<string>();
if (t.L > 0) parts.Add($"Learning+ {t.L}"); if (t.P > 0) parts.Add($"Practicing+ {t.P}");
if (t.S > 0) parts.Add($"Strong+ {t.S}"); if (t.M > 0) parts.Add($"Mastered {t.M}");
if (t.H > 0) parts.Add($"Hard successes {t.H}"); if (t.R > 0) parts.Add($"Retention {t.R}");
return new ProgressionRankResponse { Ordinal=i, RankName=ProgressionPolicy.Name(i), Division=ProgressionPolicy.Division(i),
Earned=i <= p.HighestRankOrdinal, EarnedUtc=p.EarnedRanksUtc.GetValueOrDefault(i.ToString()),
RequirementSummary=parts.Count == 0 ? "Starting rank" : string.Join(" · ", parts) };
}).ToList()
};
if (p.HighestRankOrdinal >= ProgressionPolicy.Thresholds.Length - 1) return response;
var next = p.HighestRankOrdinal + 1;
response.NextRankName = ProgressionPolicy.Name(next); response.NextDivision = ProgressionPolicy.Division(next);
var threshold = ProgressionPolicy.Thresholds[next];
var values = new Dictionary<string, (string Label, int Current, int Required)>
{
["learning"] = ("Systems at Learning+", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 1), threshold.L),
["practicing"] = ("Systems at Practicing+", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 2), threshold.P),
["strong"] = ("Systems at Strong+", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 3), threshold.S),
["mastered"] = ("Systems Mastered", evidence.Systems.Count(x => ProgressionPolicy.State(x.MasteryState) >= 4), threshold.M),
["hard"] = ("Difficulty 4+ successes", evidence.Systems.Sum(x => x.Difficulty4PlusSuccessful), threshold.H),
["retention"] = ("Retention evidence", evidence.Retention, threshold.R)
};
response.NextRequirements = values.Where(x => x.Value.Required > 0).Select(x => new ProgressionRequirementResponse
{ Key = x.Key, Label = x.Value.Label, Current = x.Value.Current, Required = x.Value.Required }).ToList();
return response;
}
private static List<ProgressionRecordResponse> Records(UserProgression p) =>
[
new() { Key="timed_block", Label="Advanced Timed Block", Definition="Best accuracy in a completed 20+ question Timed block with at least 50% difficulty 4–5.", Value=p.BestTimedBlock.Value, SampleSize=p.BestTimedBlock.SampleSize, Detail=p.BestTimedBlock.Detail, AchievedUtc=p.BestTimedBlock.AchievedUtc },
new() { Key="hard_streak", Label="Hard-Question Streak", Definition="Consecutive correct first attempts at difficulty 4–5; easier questions are ignored.", Value=p.BestHardStreak, AchievedUtc=p.BestHardStreakUtc },
new() { Key="systems_week", Label="Systems Advanced in a Week", Definition="Unique systems whose mastery state increased in one UTC Monday–Sunday week.", Value=p.SystemsAdvancedWeek.Value, Detail=p.SystemsAdvancedWeek.Detail, AchievedUtc=p.SystemsAdvancedWeek.AchievedUtc, TrackingFromLaunch=true },
new() { Key="reviews_week", Label="Review Queue Reduction", Definition="Largest weekly decrease in pending reviews.", Value=p.ReviewQueueReduction.Value, SampleSize=p.ReviewQueueReduction.SampleSize, Detail=p.ReviewQueueReduction.Detail, AchievedUtc=p.ReviewQueueReduction.AchievedUtc, TrackingFromLaunch=true },
new() { Key="weak_gain", Label="Weak-System Improvement", Definition="Largest weekly mastery-score gain in a system that began below 75 with at least five questions of evidence.", Value=p.WeakSystemImprovement.Value, SampleSize=p.WeakSystemImprovement.SampleSize, Detail=p.WeakSystemImprovement.Detail, AchievedUtc=p.WeakSystemImprovement.AchievedUtc, TrackingFromLaunch=true }
];
private static List<ProgressionSystemSnapshot> Snapshots(IEnumerable<DashboardSystemPerformance> systems) => systems.Select(x => new ProgressionSystemSnapshot
{ System=x.System, StateOrdinal=ProgressionPolicy.State(x.MasteryState), MasteryScore=x.MasteryScore, DistinctQuestions=x.DistinctQuestions }).ToList();
private static DateTime WeekStart(DateTime value) => value.Date.AddDays(-((7 + (int)value.DayOfWeek - (int)DayOfWeek.Monday) % 7));
private sealed record Evidence(List<QuestionAttempt> Attempts, List<QuestionReviewAttempt> Reviews, List<ReviewQueueItem> Queue, List<StudySession> Sessions,
IReadOnlyDictionary<string, Question> Questions, List<DashboardSystemPerformance> Systems, int Retention, int Pending);
}
@@ -0,0 +1,66 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public static class QuestionAttemptCycleMigrationCommand
{
public const string Confirmation = "MIGRATE_ATTEMPT_CYCLES";
public const string LegacyIndexName = "QuestionExternalId_1_UserId_1";
public const string CycleIndexName = "uq_question_user_cycle";
public static async Task<int> RunAsync(
IConfiguration configuration, string[] args, TextWriter output)
{
var apply = args.Contains("--apply", StringComparer.Ordinal);
var confirmation = Option(args, "--confirm");
var mongo = new MongoDatabaseProvider(configuration);
var attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
var missingFilter = new BsonDocumentFilterDefinition<QuestionAttempt>(
new BsonDocument(nameof(QuestionAttempt.BankCycle),
new BsonDocument("$exists", false)));
var missing = await attempts.CountDocumentsAsync(missingFilter);
var indexes = (await attempts.Indexes.ListAsync()).ToList();
var names = indexes.Select(value => value["name"].AsString).ToHashSet(StringComparer.Ordinal);
await output.WriteLineAsync(
$"QuestionAttempts missing BankCycle: {missing}; legacy index: {names.Contains(LegacyIndexName)}; cycle index: {names.Contains(CycleIndexName)}.");
if (!apply)
{
await output.WriteLineAsync(
$"Dry run only. Re-run with --apply --confirm {Confirmation}.");
return 0;
}
if (confirmation != Confirmation)
{
await output.WriteLineAsync($"Refusing migration: --confirm {Confirmation} is required.");
return 2;
}
if (missing > 0)
await attempts.UpdateManyAsync(
missingFilter,
Builders<QuestionAttempt>.Update.Set(value => value.BankCycle, 0));
if (!names.Contains(CycleIndexName))
await attempts.Indexes.CreateOneAsync(new CreateIndexModel<QuestionAttempt>(
Builders<QuestionAttempt>.IndexKeys
.Ascending(value => value.QuestionExternalId)
.Ascending(value => value.UserId)
.Ascending(value => value.BankCycle),
new CreateIndexOptions { Unique = true, Name = CycleIndexName }));
if (names.Contains(LegacyIndexName))
await attempts.Indexes.DropOneAsync(LegacyIndexName);
var remaining = await attempts.CountDocumentsAsync(missingFilter);
await output.WriteLineAsync(
$"Migration complete. Backfilled {missing}; missing BankCycle: {remaining}; dropped legacy index: {names.Contains(LegacyIndexName)}.");
return remaining == 0 ? 0 : 1;
}
private static string Option(string[] args, string name)
{
var index = Array.IndexOf(args, name);
return index >= 0 && index + 1 < args.Length ? args[index + 1] : "";
}
}
@@ -17,10 +17,11 @@ public class QuestionAttemptStore
var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
.Ascending(a => a.QuestionExternalId)
.Ascending(a => a.UserId);
.Ascending(a => a.UserId)
.Ascending(a => a.BankCycle);
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
firstAttemptIndex,
new CreateIndexOptions { Unique = true }));
new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" }));
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
@@ -38,12 +39,14 @@ public class QuestionAttemptStore
bool isCorrect,
bool isSkip = false,
IReadOnlyCollection<string>? presentedChoiceIds = null,
int durationSeconds = 0)
int durationSeconds = 0,
int bankCycle = 0)
{
var attempt = new QuestionAttempt
{
QuestionExternalId = questionExternalId,
UserId = userId,
BankCycle = bankCycle,
ChoiceId = choiceId,
PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [],
TracksPresentation = presentedChoiceIds is { Count: > 0 },
@@ -63,7 +66,8 @@ public class QuestionAttemptStore
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
var existing = await _col
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId)
.Find(a => a.QuestionExternalId == questionExternalId &&
a.UserId == userId && a.BankCycle == bankCycle)
.FirstAsync();
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
}
@@ -75,11 +79,17 @@ public class QuestionAttemptStore
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
.ToListAsync();
public Task<List<string>> GetUserQuestionIdsAsync(string userId) =>
public Task<List<string>> GetUserQuestionIdsAsync(string userId, int? bankCycle = null)
{
var filter = Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId);
if (bankCycle.HasValue)
filter &= Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.BankCycle, bankCycle.Value);
return
_col.Distinct(
attempt => attempt.QuestionExternalId,
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId))
filter)
.ToListAsync();
}
public IMongoCollection<QuestionAttempt> Collection => _col;
+21 -4
View File
@@ -21,6 +21,10 @@ public class QuestionStore
_col.Indexes.CreateOne(new CreateIndexModel<Question>(systemIndex));
var tagsIndex = Builders<Question>.IndexKeys.Ascending(q => q.Tags);
_col.Indexes.CreateOne(new CreateIndexModel<Question>(tagsIndex));
var step3SystemIndex = Builders<Question>.IndexKeys.Ascending(q => q.Step3System)
.Ascending(q => q.IsPublished).Ascending(q => q.FlaggedForReviewUtc).Ascending(q => q.Difficulty);
_col.Indexes.CreateOne(new CreateIndexModel<Question>(step3SystemIndex,
new CreateIndexOptions { Name = "step3_system_visibility_difficulty" }));
}
public async Task<List<Question>> SearchAsync(QuestionSearchRequest req, bool includeUnpublished)
@@ -55,10 +59,18 @@ public class QuestionStore
var filter = filters.Count == 0 ? builder.Empty : builder.And(filters);
var limit = Math.Clamp(req.Limit, 1, 80);
return await _col.Aggregate()
.Match(filter)
.Sample(limit)
.ToListAsync();
if (string.IsNullOrWhiteSpace(req.Step3System))
return await _col.Aggregate().Match(filter).Sample(limit).ToListAsync();
var requested = Step3SystemCatalog.Normalize(req.Step3System)
?? throw new ArgumentException("step3System must be an official Step 3 system");
// Include legacy records until the idempotent Step3System backfill has run.
var systemFilter = builder.Eq(question => question.Step3System, requested)
| builder.Exists(nameof(Question.Step3System), false)
| builder.Eq(question => question.Step3System, "");
var candidates = await _col.Find(filter & systemFilter).ToListAsync();
return candidates.Where(question => Step3SystemCatalog.Normalize(question) == requested)
.OrderBy(_ => Random.Shared.Next()).Take(limit).ToList();
}
public async Task<Question?> GetByIdAsync(string id) =>
@@ -280,6 +292,11 @@ public class QuestionStore
ChoiceSelectionPercentages = req.ChoiceSelectionPercentages,
Discipline = req.Discipline.Trim(),
System = req.System.Trim(),
Step3System = Step3SystemCatalog.Derive(new Question
{
ExternalId = req.ExternalId.Trim(), System = req.System.Trim(),
PerformanceCategory = req.PerformanceCategory.Trim(), Discipline = req.Discipline.Trim()
}) ?? "",
PerformanceCategory = RatingCategory.Normalize(req.PerformanceCategory, req.System, req.Discipline),
Difficulty = req.Difficulty,
Labs = req.Labs,
@@ -49,6 +49,21 @@ public static class QuestionValidator
if (!entry.Value.Choices.Any(choice => choice.Id == entry.Value.CorrectChoiceId))
return "wrongAnswerFollowups correctChoiceId must match a follow-up choice id";
}
if (req.IsPublished)
{
if (req.WrongAnswerFollowups.Any(entry => entry.Value.Choices.Count != 3))
return "published wrongAnswerFollowups require exactly three choices per distractor";
if (!AllDistinct(req.WrongAnswerFollowups.Values.Select(value => value.Prompt)))
return "published wrongAnswerFollowups require a different prompt for every distractor";
if (!AllDistinct(req.WrongAnswerFollowups.Values.Select(value => value.TeachingPoint)))
return "published wrongAnswerFollowups require a different teachingPoint for every distractor";
if (!AllDistinct(req.WrongAnswerFollowups.Values.Select(value =>
value.Choices.Single(choice => choice.Id == value.CorrectChoiceId).Text)))
return "published wrongAnswerFollowups require a different correct follow-up answer for every distractor";
if (!AllDistinct(req.WrongAnswerFollowups.Values.Select(value => string.Join("|",
value.Choices.Select(choice => Normalize(choice.Text)).OrderBy(text => text, StringComparer.Ordinal)))))
return "published wrongAnswerFollowups require a different choice set for every distractor";
}
if (req.ReviewFollowups is null) return "reviewFollowups must be an array";
if (req.ReviewFollowups.Count > 10) return "questions support at most ten review follow-ups";
@@ -75,6 +90,16 @@ public static class QuestionValidator
return null;
}
private static bool AllDistinct(IEnumerable<string> values)
{
var normalized = values.Select(Normalize).ToList();
return normalized.All(value => value.Length > 0)
&& normalized.Distinct(StringComparer.Ordinal).Count() == normalized.Count;
}
private static string Normalize(string value) => string.Join(' ', value.Trim().ToLowerInvariant()
.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
public static string? ValidateLabs(IReadOnlyCollection<QuestionLab>? labs, bool requireComplete)
{
if (labs is null)
+66 -28
View File
@@ -46,15 +46,20 @@ public sealed class ReviewQueueStore
var update = Builders<ReviewQueueItem>.Update
.SetOnInsert(value => value.UserId, attempt.UserId)
.SetOnInsert(value => value.QuestionExternalId, attempt.QuestionExternalId)
.SetOnInsert(value => value.SourceAttemptId, attempt.Id ?? "")
.SetOnInsert(value => value.State, ReviewQueueStates.Pending)
.SetOnInsert(value => value.Cycle, 1)
.SetOnInsert(value => value.CreatedUtc, attempt.CreatedUtc)
.SetOnInsert(value => value.UpdatedUtc, now);
.Set(value => value.SourceAttemptId, attempt.Id ?? "")
.Set(value => value.State, ReviewQueueStates.Pending)
.Set(value => value.MasteredUtc, null)
.Set(value => value.UpdatedUtc, now);
var filter = Builders<ReviewQueueItem>.Filter.Eq(value => value.UserId, attempt.UserId)
& Builders<ReviewQueueItem>.Filter.Eq(value => value.QuestionExternalId, attempt.QuestionExternalId)
& (Builders<ReviewQueueItem>.Filter.Ne(value => value.State, ReviewQueueStates.Mastered)
| Builders<ReviewQueueItem>.Filter.Ne(value => value.SourceAttemptId, attempt.Id ?? ""));
try
{
await _items.UpdateOneAsync(
value => value.UserId == attempt.UserId && value.QuestionExternalId == attempt.QuestionExternalId,
filter,
update,
new UpdateOptions { IsUpsert = true });
}
@@ -66,26 +71,36 @@ public sealed class ReviewQueueStore
public async Task EnsureUserQueueAsync(string userId)
{
var checkpoint = await _backfills.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var scanThroughUtc = DateTime.UtcNow;
var filter = Builders<QuestionAttempt>.Filter.Where(value => value.UserId == userId && !value.IsCorrect && !value.IsSkip);
if (checkpoint is not null)
filter &= Builders<QuestionAttempt>.Filter.Gte(value => value.CreatedUtc, checkpoint.CompletedUtc);
filter &= Builders<QuestionAttempt>.Filter.Lte(value => value.CreatedUtc, scanThroughUtc);
var misses = await _firstAttempts.Find(filter).ToListAsync();
var allMisses = await _firstAttempts.Find(filter).ToListAsync();
var existingItems = (await _items.Find(value => value.UserId == userId).ToListAsync())
.ToDictionary(value => value.QuestionExternalId, StringComparer.Ordinal);
var misses = allMisses.GroupBy(value => value.QuestionExternalId, StringComparer.Ordinal)
.Select(group => group.OrderByDescending(value => value.CreatedUtc).First())
.Where(attempt => !existingItems.TryGetValue(attempt.QuestionExternalId, out var item)
|| (item.State == ReviewQueueStates.Mastered
&& item.SourceAttemptId != (attempt.Id ?? "")
&& (!item.MasteredUtc.HasValue || attempt.CreatedUtc > item.MasteredUtc.Value)))
.ToList();
if (misses.Count > 0)
{
var now = DateTime.UtcNow;
var writes = misses.Select(attempt => new UpdateOneModel<ReviewQueueItem>(
Builders<ReviewQueueItem>.Filter.Where(value => value.UserId == userId && value.QuestionExternalId == attempt.QuestionExternalId),
Builders<ReviewQueueItem>.Filter.Eq(value => value.UserId, userId)
& Builders<ReviewQueueItem>.Filter.Eq(value => value.QuestionExternalId, attempt.QuestionExternalId)
& (Builders<ReviewQueueItem>.Filter.Ne(value => value.State, ReviewQueueStates.Mastered)
| Builders<ReviewQueueItem>.Filter.Ne(value => value.SourceAttemptId, attempt.Id ?? "")),
Builders<ReviewQueueItem>.Update
.SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.QuestionExternalId, attempt.QuestionExternalId)
.SetOnInsert(value => value.SourceAttemptId, attempt.Id ?? "")
.SetOnInsert(value => value.State, ReviewQueueStates.Pending)
.SetOnInsert(value => value.Cycle, 1)
.SetOnInsert(value => value.CreatedUtc, attempt.CreatedUtc)
.SetOnInsert(value => value.UpdatedUtc, now))
.Set(value => value.SourceAttemptId, attempt.Id ?? "")
.Set(value => value.State, ReviewQueueStates.Pending)
.Set(value => value.MasteredUtc, null)
.Set(value => value.UpdatedUtc, now))
{ IsUpsert = true }).ToList();
try
{
@@ -115,27 +130,50 @@ public sealed class ReviewQueueStore
public Task<long> CountPendingAsync(string userId) =>
_items.CountDocumentsAsync(value => value.UserId == userId && value.State == ReviewQueueStates.Pending);
public async Task<ReviewQueueItem?> GetRandomPendingAsync(string userId, IEnumerable<string> excludedIds)
public async Task<long> CountPendingAsync(string userId, string? step3System)
{
if (string.IsNullOrWhiteSpace(step3System)) return await CountPendingAsync(userId);
var requested = Step3SystemCatalog.Normalize(step3System)
?? throw new ArgumentException("step3System must be an official Step 3 system");
var pending = await _items.Find(value => value.UserId == userId && value.State == ReviewQueueStates.Pending).ToListAsync();
var count = 0L;
foreach (var item in pending)
{
var question = await _questions.GetByExternalIdAsync(item.QuestionExternalId);
if (question is not null && Step3SystemCatalog.Normalize(question) == requested) count++;
}
return count;
}
public Task<List<ReviewQueueItem>> GetUserItemsAsync(string userId) =>
_items.Find(value => value.UserId == userId).ToListAsync();
public Task<List<QuestionReviewAttempt>> GetUserReviewsAsync(string userId) =>
_reviews.Find(value => value.UserId == userId).ToListAsync();
public async Task<ReviewQueueItem?> GetRandomPendingAsync(
string userId, IEnumerable<string> excludedIds, string? step3System = null)
{
var excluded = excludedIds.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.Ordinal).ToList();
for (var pass = 0; pass < 5; pass++)
var requestedSystem = string.IsNullOrWhiteSpace(step3System) ? null : Step3SystemCatalog.Normalize(step3System);
if (!string.IsNullOrWhiteSpace(step3System) && requestedSystem is null)
throw new ArgumentException("step3System must be an official Step 3 system");
var filter = Builders<ReviewQueueItem>.Filter.Eq(value => value.UserId, userId)
& Builders<ReviewQueueItem>.Filter.Eq(value => value.State, ReviewQueueStates.Pending);
if (excluded.Count > 0) filter &= Builders<ReviewQueueItem>.Filter.Nin(value => value.QuestionExternalId, excluded);
var candidates = await _items.Find(filter).ToListAsync();
foreach (var candidate in candidates.OrderBy(_ => Random.Shared.Next()))
{
var filter = Builders<ReviewQueueItem>.Filter.Eq(value => value.UserId, userId)
& Builders<ReviewQueueItem>.Filter.Eq(value => value.State, ReviewQueueStates.Pending);
if (excluded.Count > 0)
filter &= Builders<ReviewQueueItem>.Filter.Nin(value => value.QuestionExternalId, excluded);
var candidates = await _items.Aggregate().Match(filter).Sample(20).ToListAsync();
if (candidates.Count == 0) return null;
foreach (var candidate in candidates)
var question = await _questions.GetByExternalIdAsync(candidate.QuestionExternalId);
if (question is { IsPublished: true, FlaggedForReviewUtc: null })
{
var question = await _questions.GetByExternalIdAsync(candidate.QuestionExternalId);
if (question is { IsPublished: true, FlaggedForReviewUtc: null }) return candidate;
await _items.UpdateOneAsync(
value => value.Id == candidate.Id && value.State == ReviewQueueStates.Pending,
Builders<ReviewQueueItem>.Update
.Set(value => value.State, ReviewQueueStates.Unavailable)
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
if (requestedSystem is null || Step3SystemCatalog.Normalize(question) == requestedSystem) return candidate;
continue;
}
await _items.UpdateOneAsync(
value => value.Id == candidate.Id && value.State == ReviewQueueStates.Pending,
Builders<ReviewQueueItem>.Update.Set(value => value.State, ReviewQueueStates.Unavailable)
.Set(value => value.UpdatedUtc, DateTime.UtcNow));
}
return null;
}
@@ -17,6 +17,18 @@ public sealed class ServiceMetrics
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicAttempts = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicCorrect = new();
private long _dynamicTargetGapTotal;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditAwards = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditCoins = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditDuplicates = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditCapped = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionAssigned = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionCompleted = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionExpired = new();
private long _dailyMissionReplaced;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _weeklyGoalAssigned = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _weeklyGoalCompleted = new();
private long _weeklyGoalExpiredIncomplete;
private long _weeklyGoalLateFinalized;
public void AttemptCreated() => Interlocked.Increment(ref _attemptsCreated);
public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates);
@@ -43,6 +55,21 @@ public sealed class ServiceMetrics
_dynamicAttempts.AddOrUpdate(tier, 1, (_, value) => value + 1);
if (correct) _dynamicCorrect.AddOrUpdate(tier, 1, (_, value) => value + 1);
}
public void CreditAward(string reason, int amount)
{
_creditAwards.AddOrUpdate(reason, 1, (_, value) => value + 1);
_creditCoins.AddOrUpdate(reason, amount, (_, value) => value + amount);
}
public void CreditDuplicate(string reason) => _creditDuplicates.AddOrUpdate(reason, 1, (_, value) => value + 1);
public void CreditCapped(string reason) => _creditCapped.AddOrUpdate(reason, 1, (_, value) => value + 1);
public void DailyMissionAssigned(string type) => _dailyMissionAssigned.AddOrUpdate(type, 1, (_, value) => value + 1);
public void DailyMissionCompleted(string type) => _dailyMissionCompleted.AddOrUpdate(type, 1, (_, value) => value + 1);
public void DailyMissionExpired(string type) => _dailyMissionExpired.AddOrUpdate(type, 1, (_, value) => value + 1);
public void DailyMissionReplaced() => Interlocked.Increment(ref _dailyMissionReplaced);
public void WeeklyGoalAssigned(string type) => _weeklyGoalAssigned.AddOrUpdate(type, 1, (_, value) => value + 1);
public void WeeklyGoalCompleted(string type) => _weeklyGoalCompleted.AddOrUpdate(type, 1, (_, value) => value + 1);
public void WeeklyGoalExpiredIncomplete() => Interlocked.Increment(ref _weeklyGoalExpiredIncomplete);
public void WeeklyGoalLateFinalized() => Interlocked.Increment(ref _weeklyGoalLateFinalized);
public string RenderPrometheus()
{
@@ -63,6 +90,36 @@ public sealed class ServiceMetrics
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_credit_awards_total counter");
output.AppendLine("# TYPE step3_credit_coins_awarded_total counter");
output.AppendLine("# TYPE step3_credit_award_duplicates_total counter");
output.AppendLine("# TYPE step3_credit_award_capped_total counter");
foreach (var reason in _creditAwards.Keys.Concat(_creditDuplicates.Keys).Concat(_creditCapped.Keys).Distinct().Order())
{
AppendReasonSample(output, "step3_credit_awards_total", reason, _creditAwards.GetValueOrDefault(reason));
AppendReasonSample(output, "step3_credit_coins_awarded_total", reason, _creditCoins.GetValueOrDefault(reason));
AppendReasonSample(output, "step3_credit_award_duplicates_total", reason, _creditDuplicates.GetValueOrDefault(reason));
AppendReasonSample(output, "step3_credit_award_capped_total", reason, _creditCapped.GetValueOrDefault(reason));
}
output.AppendLine("# TYPE step3_daily_missions_assigned_total counter");
output.AppendLine("# TYPE step3_daily_missions_completed_total counter");
output.AppendLine("# TYPE step3_daily_missions_expired_incomplete_total counter");
foreach (var type in _dailyMissionAssigned.Keys.Concat(_dailyMissionCompleted.Keys).Concat(_dailyMissionExpired.Keys).Distinct().Order())
{
AppendMissionSample(output, "step3_daily_missions_assigned_total", type, _dailyMissionAssigned.GetValueOrDefault(type));
AppendMissionSample(output, "step3_daily_missions_completed_total", type, _dailyMissionCompleted.GetValueOrDefault(type));
AppendMissionSample(output, "step3_daily_missions_expired_incomplete_total", type, _dailyMissionExpired.GetValueOrDefault(type));
}
AppendCounter(output, "step3_daily_missions_replaced_total", Interlocked.Read(ref _dailyMissionReplaced));
output.AppendLine("# TYPE step3_weekly_goals_assigned_total counter");
output.AppendLine("# TYPE step3_weekly_goals_completed_total counter");
foreach (var type in _weeklyGoalAssigned.Keys.Concat(_weeklyGoalCompleted.Keys).Distinct().Order())
{
AppendMissionSample(output, "step3_weekly_goals_assigned_total", type, _weeklyGoalAssigned.GetValueOrDefault(type));
AppendMissionSample(output, "step3_weekly_goals_completed_total", type, _weeklyGoalCompleted.GetValueOrDefault(type));
}
AppendCounter(output, "step3_weekly_goals_expired_incomplete_total", Interlocked.Read(ref _weeklyGoalExpiredIncomplete));
AppendCounter(output, "step3_weekly_goals_late_finalized_total", Interlocked.Read(ref _weeklyGoalLateFinalized));
output.AppendLine("# TYPE step3_http_request_duration_seconds summary");
output.Append("step3_http_request_duration_seconds_count ")
.Append(Interlocked.Read(ref _httpRequests))
@@ -91,4 +148,10 @@ public sealed class ServiceMetrics
{
output.Append(name).Append("{tier=\"").Append(tier.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
}
private static void AppendReasonSample(StringBuilder output, string name, string reason, long value) =>
output.Append(name).Append("{reason=\"").Append(reason.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
private static void AppendMissionSample(StringBuilder output, string name, string type, long value) =>
output.Append(name).Append("{type=\"").Append(type.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
}
@@ -0,0 +1,53 @@
using MongoDB.Bson;
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public static class Step3SystemBackfillCommand
{
public const string Confirmation = "BACKFILL_STEP3_SYSTEMS";
public static async Task<int> RunAsync(IConfiguration configuration, string[] args, TextWriter output)
{
var apply = args.Contains("--apply", StringComparer.Ordinal);
var confirmIndex = Array.IndexOf(args, "--confirm");
var confirmation = confirmIndex >= 0 && confirmIndex + 1 < args.Length ? args[confirmIndex + 1] : "";
var database = new MongoDatabaseProvider(configuration).Database;
var questions = database.GetCollection<Question>("Questions");
var all = await questions.Find(FilterDefinition<Question>.Empty).ToListAsync();
var classified = all.Select(question => (Question: question, System: Step3SystemCatalog.Derive(question) ?? "")).ToList();
var changes = classified.Where(item => item.System.Length > 0 && item.Question.Step3System != item.System).ToList();
var unmapped = classified.Count(item => item.System.Length == 0);
await output.WriteLineAsync($"Questions requiring Step3System backfill: {changes.Count}; unmapped: {unmapped}.");
foreach (var group in classified.Where(item => item.System.Length == 0)
.GroupBy(item => $"{item.Question.System} | {item.Question.PerformanceCategory} | {item.Question.Discipline}")
.OrderByDescending(group => group.Count()).ThenBy(group => group.Key, StringComparer.Ordinal))
await output.WriteLineAsync($"Unmapped ({group.Count()}): {group.Key}; ids: {string.Join(", ", group.Select(item => item.Question.ExternalId).Take(10))}");
if (!apply)
{
await output.WriteLineAsync($"Dry run only. Re-run with --apply --confirm {Confirmation}.");
return 0;
}
if (confirmation != Confirmation)
{
await output.WriteLineAsync($"Refusing migration: --confirm {Confirmation} is required.");
return 2;
}
if (changes.Count > 0)
{
var writes = changes.Select(item => new UpdateOneModel<Question>(
Builders<Question>.Filter.Eq(question => question.Id, item.Question.Id),
Builders<Question>.Update.Set(question => question.Step3System, item.System))).ToList();
await questions.BulkWriteAsync(writes, new BulkWriteOptions { IsOrdered = false });
}
var remaining = await questions.CountDocumentsAsync(new BsonDocumentFilterDefinition<Question>(
new BsonDocument("$or", new BsonArray
{
new BsonDocument(nameof(Question.Step3System), new BsonDocument("$exists", false)),
new BsonDocument(nameof(Question.Step3System), "")
})));
await output.WriteLineAsync($"Backfilled {changes.Count}; unmapped or empty Step3System: {remaining}.");
return 0;
}
}
@@ -0,0 +1,74 @@
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public static class Step3SystemCatalog
{
private static readonly IReadOnlyDictionary<string, string> Overrides = new Dictionary<string, string>(StringComparer.Ordinal)
{
["d5-expansion-pediatrics-urea-cycle-hyperammonemia-001"] = "Endocrine System",
["d5-expansion-pediatrics-neonatal-hsv-disseminated-001"] = "Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders",
["d5-expansion-pediatrics-cdh-delivery-stabilization-001"] = "Respiratory System",
["d5-expansion-pediatrics-hie-therapeutic-hypothermia-001"] = "Nervous System & Special Senses",
["d5-expansion-pediatrics-fnait-intracranial-bleeding-001"] = "Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders",
["nbme6-s3-i029-infant-hyperchloremic-acidosis-growth-risk"] = "Renal/Urinary & Male Reproductive Systems",
["form7-s1-i042-genitourinary-syndrome-menopause"] = "Pregnancy/Childbirth & Female Reproductive System & Breast",
["free137-i019-inguinal-node-vulvar-source"] = "Pregnancy/Childbirth & Female Reproductive System & Breast",
["free137-i061-klinefelter-confirmation"] = "Renal/Urinary & Male Reproductive Systems",
["form7-s1-i009-cremasteric-reflex"] = "Renal/Urinary & Male Reproductive Systems",
["form7-s2-i015-acrosome-head"] = "Renal/Urinary & Male Reproductive Systems",
["nbme6-s1-i033-prostate-primary-osteoblastic-metastases"] = "Renal/Urinary & Male Reproductive Systems",
["nbme6-s3-i043-lytic-lesion-hematuria-staging"] = "Renal/Urinary & Male Reproductive Systems"
};
public static readonly string[] All =
[
"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 static string? Normalize(Question question) =>
Normalize(question.Step3System)
?? Derive(question);
public static string? Derive(Question question) =>
Overrides.GetValueOrDefault(question.ExternalId)
?? Normalize(question.System) ?? Normalize(question.PerformanceCategory) ?? Normalize(question.Discipline);
public static string? Normalize(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var trimmed = value.Trim();
var exact = All.FirstOrDefault(system => string.Equals(system, trimmed, StringComparison.OrdinalIgnoreCase));
if (exact is not null) return exact;
var lower = trimmed.ToLowerInvariant();
if (Has(lower, "human development", "well patient", "preventive", "health maintenance", "geriatr", "age-related", "neonat")) return All[0];
if (Has(lower, "social science", "communication", "ethic", "patient safety", "professionalism", "systems-based")) return All[13];
if (Has(lower, "pregnan", "obstetric", "gynec", "female reproductive", "breast", "puerper")) return All[10];
if (Has(lower, "renal", "urinary", "male reproductive", "urolog", "nephro")) return All[9];
if (Has(lower, "biostat", "epidemi", "population health", "medical literature")) return All[12];
if (Has(lower, "immune", "immun", "blood", "hemat", "lymph", "multisystem", "infectious", "toxicolog", "poisoning", "oncolog", "trauma")) return All[1];
if (Has(lower, "behavior", "psychi", "mental health")) return All[2];
if (Has(lower, "nervous", "neuro", "special sense", "ophthalm", "eye & adnexa", "otolaryng", "ear, nose", " ent")) return All[3];
if (Has(lower, "skin", "derm", "subcutaneous", "integument")) return All[4];
if (Has(lower, "musculo", "orthop", "rheumat")) return All[5];
if (Has(lower, "cardio", "circulatory", "peripheral vascular")) return All[6];
if (Has(lower, "resp", "pulmon")) return All[7];
if (Has(lower, "gastro", "digest", "hepato", "oral/dental")) return All[8];
if (Has(lower, "endocr", "metabolic")) return All[11];
return null;
}
private static bool Has(string value, params string[] fragments) => fragments.Any(value.Contains);
}
@@ -0,0 +1,307 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class StudySessionStore
{
private readonly IMongoCollection<StudySession> _sessions;
private readonly DashboardStore _dashboard;
private readonly CreditStore _credits;
private readonly ReviewQueueStore _reviews;
private readonly TimeProvider _clock;
public StudySessionStore(
MongoDatabaseProvider mongo,
DashboardStore dashboard,
CreditStore credits,
ReviewQueueStore reviews,
TimeProvider clock)
{
_sessions = mongo.Database.GetCollection<StudySession>("StudySessions");
_dashboard = dashboard;
_credits = credits;
_reviews = reviews;
_clock = clock;
_sessions.Indexes.CreateOne(new CreateIndexModel<StudySession>(
Builders<StudySession>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.ClientSessionId),
new CreateIndexOptions { Unique = true, Name = "uq_study_session_client" }));
_sessions.Indexes.CreateOne(new CreateIndexModel<StudySession>(
Builders<StudySession>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.State),
new CreateIndexOptions { Name = "study_session_user_state" }));
}
public async Task<StudySession> CreateAsync(string userId, CreateStudySessionRequest request)
{
Validate(request);
var existing = await _sessions.Find(value => value.UserId == userId
&& value.ClientSessionId == request.ClientSessionId).FirstOrDefaultAsync();
if (existing is not null) return existing;
var now = _clock.GetUtcNow().UtcDateTime;
await CloseStaleAsync(userId, now);
var activeSimulation = await _sessions.Find(value => value.UserId == userId
&& value.State == StudySessionStates.Active && value.GoalType == StudyGoalTypes.SimulationBlock)
.AnyAsync();
if (activeSimulation) throw new InvalidOperationException("Finish the active simulation block before starting another study session.");
await _sessions.UpdateManyAsync(
value => value.UserId == userId && value.State == StudySessionStates.Active,
Builders<StudySession>.Update.Set(value => value.State, StudySessionStates.Interrupted)
.Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now));
var dashboard = await _dashboard.GetAsync(userId, now);
var normalizedSystem = string.IsNullOrWhiteSpace(request.Step3System)
? "" : Step3SystemCatalog.Normalize(request.Step3System) ?? throw new ArgumentException("Invalid Step 3 system.");
var target = request.GoalType switch
{
StudyGoalTypes.QuestionCount => Math.Clamp(request.TargetValue, 5, 40),
StudyGoalTypes.Duration => Math.Clamp(request.TargetValue, 10, 60),
StudyGoalTypes.WeakSystem => 10,
StudyGoalTypes.DueReviews => Math.Clamp(request.TargetValue, 1, 20),
_ => request.TargetValue
};
if (request.GoalType == StudyGoalTypes.DueReviews)
{
var pending = checked((int)await _reviews.CountPendingAsync(userId, normalizedSystem));
if (pending == 0) throw new ArgumentException("No reviews are currently due.");
target = Math.Min(target, pending);
}
var mastery = dashboard.Systems.FirstOrDefault(value => value.System == normalizedSystem);
var session = new StudySession
{
UserId = userId, ClientSessionId = request.ClientSessionId,
GoalType = request.GoalType, TargetValue = target, Mode = request.Mode,
Step3System = normalizedSystem, State = StudySessionStates.Active,
CreatedUtc = now, UpdatedUtc = now,
TargetEndUtc = request.GoalType == StudyGoalTypes.Duration ? now.AddMinutes(target) : null,
InitialMasteryScore = mastery?.MasteryScore, InitialMasteryState = mastery?.MasteryState ?? "",
InitialReadiness = dashboard.ExamReadinessProjectedPassPercent
};
await _sessions.InsertOneAsync(session);
return session;
}
public async Task<StudySession?> GetActiveAsync(string userId)
{
await CloseStaleAsync(userId, _clock.GetUtcNow().UtcDateTime);
return await _sessions.Find(value => value.UserId == userId && value.State == StudySessionStates.Active)
.SortByDescending(value => value.CreatedUtc).FirstOrDefaultAsync();
}
public async Task<StudySession?> GetOwnedAsync(string id, string userId) =>
await _sessions.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync();
public async Task<StudySessionEnvelope?> RecordQuestionAsync(
string id, string userId, QuestionAttempt attempt, Question question, IReadOnlyCollection<CreditAward> awards)
{
var session = await GetOwnedAsync(id, userId);
if (session is null || session.State != StudySessionStates.Active) return null;
if (session.GoalType == StudyGoalTypes.DueReviews || session.GoalType == StudyGoalTypes.SimulationBlock) return null;
if (session.GoalType == StudyGoalTypes.WeakSystem
&& Step3SystemCatalog.Normalize(question) != session.Step3System) return null;
if (!attempt.IsSkip)
await ApplyEventAsync(session, $"question:{attempt.Id}", 1, attempt.IsCorrect ? 1 : 0, 0, 0, awards);
return await EvaluateAsync(id, userId);
}
public async Task<StudySessionEnvelope?> RecordReviewAsync(
string id, string userId, QuestionReviewAttempt attempt, bool recovered, IReadOnlyCollection<CreditAward> awards)
{
var session = await GetOwnedAsync(id, userId);
if (session is null || session.State != StudySessionStates.Active || session.GoalType != StudyGoalTypes.DueReviews)
return null;
await ApplyEventAsync(session, $"review:{attempt.Id}", 0, attempt.IsCorrect ? 1 : 0, 1, recovered ? 1 : 0, awards);
return await EvaluateAsync(id, userId);
}
public async Task<StudySessionEnvelope?> FinishEarlyAsync(string id, string userId)
{
var now = _clock.GetUtcNow().UtcDateTime;
await _sessions.UpdateOneAsync(value => value.Id == id && value.UserId == userId
&& value.State == StudySessionStates.Active,
Builders<StudySession>.Update.Set(value => value.State, StudySessionStates.EndedEarly)
.Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now));
var session = await GetOwnedAsync(id, userId);
if (session is null) return null;
await SnapshotFinalAsync(session);
return Envelope(session);
}
public Task<StudySessionEnvelope?> CheckAsync(string id, string userId) => EvaluateAsync(id, userId);
public async Task<StudySession> EnsureSimulationBlockAsync(string userId, string simulationId, int block)
{
var clientId = $"simulation:{simulationId}:{block}";
var existing = await _sessions.Find(value => value.UserId == userId && value.ClientSessionId == clientId).FirstOrDefaultAsync();
if (existing is not null) return existing;
var now = _clock.GetUtcNow().UtcDateTime;
await _sessions.UpdateManyAsync(value => value.UserId == userId && value.State == StudySessionStates.Active,
Builders<StudySession>.Update.Set(value => value.State, StudySessionStates.Interrupted)
.Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now));
var dashboard = await _dashboard.GetAsync(userId, now);
var session = new StudySession
{
UserId = userId, ClientSessionId = clientId, GoalType = StudyGoalTypes.SimulationBlock,
TargetValue = 1, Mode = "Simulation", State = StudySessionStates.Active,
SimulationId = simulationId, SimulationBlock = block, CreatedUtc = now, UpdatedUtc = now,
InitialReadiness = dashboard.ExamReadinessProjectedPassPercent
};
try { await _sessions.InsertOneAsync(session); }
catch (MongoWriteException) { return await _sessions.Find(value => value.UserId == userId && value.ClientSessionId == clientId).FirstAsync(); }
return session;
}
public async Task<StudySessionEnvelope> CompleteSimulationBlockAsync(string userId, string simulationId, int block)
{
var session = await EnsureSimulationBlockAsync(userId, simulationId, block);
if (session.State == StudySessionStates.Active)
{
var now = _clock.GetUtcNow().UtcDateTime;
await _sessions.UpdateOneAsync(value => value.Id == session.Id && value.State == StudySessionStates.Active,
Builders<StudySession>.Update.Set(value => value.QuestionsCompleted, 1)
.Set(value => value.State, StudySessionStates.Completed).Set(value => value.CompletedUtc, now)
.Set(value => value.UpdatedUtc, now));
session = (await GetOwnedAsync(session.Id!, userId))!;
await ApplyCompletionBonusAsync(session);
await SnapshotFinalAsync(session);
}
return Envelope((await GetOwnedAsync(session.Id!, userId))!);
}
public StudySessionEnvelope Envelope(StudySession session) => new()
{
Progress = Progress(session),
Summary = session.State == StudySessionStates.Active ? null : Summary(session)
};
private async Task ApplyEventAsync(
StudySession session, string eventId, int questions, int correct, int reviews, int recovered,
IReadOnlyCollection<CreditAward> awards)
{
var now = _clock.GetUtcNow().UtcDateTime;
var filter = Builders<StudySession>.Filter.Eq(value => value.Id, session.Id)
& Builders<StudySession>.Filter.Eq(value => value.State, StudySessionStates.Active)
& Builders<StudySession>.Filter.AnyNin(value => value.AppliedEventIds, [eventId]);
var update = Builders<StudySession>.Update.AddToSet(value => value.AppliedEventIds, eventId)
.Inc(value => value.QuestionsCompleted, questions).Inc(value => value.Correct, correct)
.Inc(value => value.ReviewsCompleted, reviews).Inc(value => value.Recovered, recovered)
.Inc(value => value.CoinsEarned, awards.Sum(value => value.Amount)).Set(value => value.UpdatedUtc, now);
foreach (var award in awards) update = update.Push(value => value.CoinAwards, award);
await _sessions.UpdateOneAsync(filter, update);
}
private async Task<StudySessionEnvelope?> EvaluateAsync(string id, string userId)
{
var session = await GetOwnedAsync(id, userId);
if (session is null) return null;
if (session.State == StudySessionStates.Active && Reached(session, _clock.GetUtcNow().UtcDateTime))
{
var now = _clock.GetUtcNow().UtcDateTime;
var changed = await _sessions.UpdateOneAsync(value => value.Id == id && value.UserId == userId
&& value.State == StudySessionStates.Active,
Builders<StudySession>.Update.Set(value => value.State, StudySessionStates.Completed)
.Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now));
session = (await GetOwnedAsync(id, userId))!;
if (changed.ModifiedCount == 1)
{
await ApplyCompletionBonusAsync(session);
await SnapshotFinalAsync(session);
session = (await GetOwnedAsync(id, userId))!;
}
}
return Envelope(session);
}
private async Task ApplyCompletionBonusAsync(StudySession session)
{
if (!BonusEligible(session)) return;
var result = await _credits.AwardAsync(session.UserId, new CreditAwardRequest(
$"session:{session.Id}:complete", 3, CreditReasonCodes.SessionGoalComplete, "Completed a study session goal",
"study_session", session.Id!, true));
if (result.Award is null) return;
await _sessions.UpdateOneAsync(value => value.Id == session.Id,
Builders<StudySession>.Update.Inc(value => value.CoinsEarned, result.Award.Amount)
.Push(value => value.CoinAwards, result.Award));
}
private async Task SnapshotFinalAsync(StudySession session)
{
var dashboard = await _dashboard.GetAsync(session.UserId, _clock.GetUtcNow().UtcDateTime);
var mastery = dashboard.Systems.FirstOrDefault(value => value.System == session.Step3System);
await _sessions.UpdateOneAsync(value => value.Id == session.Id,
Builders<StudySession>.Update.Set(value => value.FinalMasteryScore, mastery == null ? null : mastery.MasteryScore)
.Set(value => value.FinalMasteryState, mastery?.MasteryState ?? "")
.Set(value => value.FinalReadiness, dashboard.ExamReadinessProjectedPassPercent));
}
private async Task CloseStaleAsync(string userId, DateTime now) =>
await _sessions.UpdateManyAsync(value => value.UserId == userId && value.State == StudySessionStates.Active
&& value.UpdatedUtc < now.AddHours(-12),
Builders<StudySession>.Update.Set(value => value.State, StudySessionStates.Interrupted)
.Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now));
private static bool Reached(StudySession session, DateTime now) => session.GoalType switch
{
StudyGoalTypes.Duration => session.TargetEndUtc <= now && session.QuestionsCompleted > 0,
StudyGoalTypes.DueReviews => session.ReviewsCompleted >= session.TargetValue,
_ => session.QuestionsCompleted >= session.TargetValue
};
private static bool BonusEligible(StudySession session) => session.GoalType switch
{
StudyGoalTypes.Duration => session.TargetValue >= 10 && session.QuestionsCompleted >= 5,
StudyGoalTypes.DueReviews => session.ReviewsCompleted >= 5,
StudyGoalTypes.SimulationBlock => true,
_ => session.QuestionsCompleted >= 5
};
private StudySessionProgress Progress(StudySession session)
{
var completed = session.GoalType == StudyGoalTypes.DueReviews ? session.ReviewsCompleted
: session.GoalType == StudyGoalTypes.Duration
? Math.Min(session.TargetValue * 60, (int)((session.CompletedUtc ?? _clock.GetUtcNow().UtcDateTime) - session.CreatedUtc).TotalSeconds)
: session.QuestionsCompleted;
return new StudySessionProgress
{
Id = session.Id ?? "", State = session.State, GoalType = session.GoalType,
TargetValue = session.TargetValue, CompletedValue = completed,
QuestionsCompleted = session.QuestionsCompleted,
SecondsRemaining = session.TargetEndUtc.HasValue
? Math.Max(0, (int)(session.TargetEndUtc.Value - _clock.GetUtcNow().UtcDateTime).TotalSeconds) : 0,
GoalReached = session.State == StudySessionStates.Completed
};
}
private StudySessionSummary Summary(StudySession session)
{
var progress = Progress(session);
return new StudySessionSummary
{
Id = session.Id ?? "", State = session.State, GoalType = session.GoalType,
TargetValue = session.TargetValue, CompletedValue = progress.CompletedValue,
QuestionsCompleted = session.QuestionsCompleted, Correct = session.Correct,
Accuracy = session.QuestionsCompleted == 0 ? 0 : (int)Math.Round(100.0 * session.Correct / session.QuestionsCompleted),
ReviewsCompleted = session.ReviewsCompleted, Recovered = session.Recovered,
ElapsedSeconds = Math.Max(0, (int)((session.CompletedUtc ?? _clock.GetUtcNow().UtcDateTime) - session.CreatedUtc).TotalSeconds),
CoinsEarned = session.CoinsEarned, CoinAwards = session.CoinAwards,
Step3System = session.Step3System,
MasteryScoreChange = session.InitialMasteryScore.HasValue && session.FinalMasteryScore.HasValue
? session.FinalMasteryScore - session.InitialMasteryScore : null,
InitialMasteryState = session.InitialMasteryState, FinalMasteryState = session.FinalMasteryState,
ReadinessChange = session.InitialReadiness.HasValue && session.FinalReadiness.HasValue
? session.FinalReadiness - session.InitialReadiness : null,
ReadinessExplanation = session.FinalReadiness != session.InitialReadiness
? "Readiness changed after a completed exam simulation."
: "A completed exam simulation is required to update readiness.",
GoalBonusAwarded = session.CoinAwards.Any(value => value.ReasonCode == CreditReasonCodes.SessionGoalComplete)
};
}
private static void Validate(CreateStudySessionRequest request)
{
if (!Guid.TryParse(request.ClientSessionId, out _)) throw new ArgumentException("clientSessionId must be a UUID.");
if (!StudyGoalTypes.All.Contains(request.GoalType) || request.GoalType == StudyGoalTypes.SimulationBlock)
throw new ArgumentException("Unsupported study goal type.");
if (request.GoalType == StudyGoalTypes.WeakSystem && string.IsNullOrWhiteSpace(request.Step3System))
throw new ArgumentException("A Step 3 system is required for a weak-system goal.");
if (request.Mode is not ("Tutor" or "Timed")) throw new ArgumentException("mode must be Tutor or Timed.");
}
}
@@ -20,7 +20,8 @@ public sealed class UserProfileStore
"ExternalIdentities", "ExternalAuthTransactions",
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
"UserCreditAccounts"
"UserCreditAccounts", "CreditLedgerEntries", "StudySessions", "DailyMissionSets", "WeeklyGoals",
"UserProgressions", "ProgressionWeeklySnapshots"
];
public UserProfileStore(MongoDatabaseProvider mongo)
@@ -0,0 +1,275 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class WeeklyGoalStore
{
private readonly IMongoCollection<WeeklyGoal> _goals;
private readonly IMongoCollection<QuestionAttempt> _attempts;
private readonly IMongoCollection<QuestionReviewAttempt> _reviews;
private readonly IMongoCollection<ReviewQueueItem> _queue;
private readonly IMongoCollection<FlashcardReviewLog> _flashReviews;
private readonly IMongoCollection<FlashcardSchedule> _flashSchedules;
private readonly IMongoCollection<FlashcardNote> _flashNotes;
private readonly IMongoCollection<CcsAttempt> _ccsAttempts;
private readonly IMongoCollection<StudySession> _studySessions;
private readonly IMongoCollection<ExamSimulation> _simulations;
private readonly QuestionStore _questions;
private readonly CreditStore _credits;
private readonly TimeProvider _clock;
private readonly ServiceMetrics _metrics;
public WeeklyGoalStore(MongoDatabaseProvider mongo, QuestionStore questions, CreditStore credits,
TimeProvider clock, ServiceMetrics metrics)
{
var db = mongo.Database;
_goals = db.GetCollection<WeeklyGoal>("WeeklyGoals");
_attempts = db.GetCollection<QuestionAttempt>("QuestionAttempts");
_reviews = db.GetCollection<QuestionReviewAttempt>("QuestionReviewAttempts");
_queue = db.GetCollection<ReviewQueueItem>("ReviewQueueItems");
_flashReviews = db.GetCollection<FlashcardReviewLog>("FlashcardReviewLogs");
_flashSchedules = db.GetCollection<FlashcardSchedule>("FlashcardSchedules");
_flashNotes = db.GetCollection<FlashcardNote>("FlashcardNotes");
_ccsAttempts = db.GetCollection<CcsAttempt>("CcsAttempts");
_studySessions = db.GetCollection<StudySession>("StudySessions");
_simulations = db.GetCollection<ExamSimulation>("ExamSimulations");
_questions = questions;
_credits = credits;
_clock = clock;
_metrics = metrics;
_goals.Indexes.CreateOne(new CreateIndexModel<WeeklyGoal>(
Builders<WeeklyGoal>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.WeekStartUtc),
new CreateIndexOptions { Unique = true, Name = "uq_weekly_goal_user_start" }));
}
public async Task<WeeklyGoalEnvelope> GetAsync(
string userId, DashboardResponse dashboard, IReadOnlyCollection<Question> published)
{
var now = _clock.GetUtcNow().UtcDateTime;
var awards = await FinalizeExpiredAsync(userId, now);
var start = WeekStart(now);
var goal = await _goals.Find(value => value.UserId == userId && value.WeekStartUtc == start)
.FirstOrDefaultAsync();
if (goal is null)
{
var reviewed = await _reviews.CountDocumentsAsync(value => value.UserId == userId && value.IsCorrect
&& value.CreatedUtc >= start && value.CreatedUtc < start.AddDays(7));
var pending = await _queue.CountDocumentsAsync(value => value.UserId == userId
&& value.State == ReviewQueueStates.Pending);
var availableReviews = checked((int)Math.Min(int.MaxValue, reviewed + pending));
var remediation = ChooseRemediation(availableReviews);
var eligible = SelectHardSystems(dashboard, published);
goal = new WeeklyGoal
{
Id = $"{userId}|{start:yyyy-MM-dd}", UserId = userId, WeekStartUtc = start,
ExpiresUtc = start.AddDays(7), CreatedUtc = now,
RemediationType = remediation.Type, RemediationTarget = remediation.Target,
EligibleSystems = eligible
};
try
{
await _goals.InsertOneAsync(goal);
_metrics.WeeklyGoalAssigned(goal.RemediationType);
}
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
goal = await _goals.Find(value => value.UserId == userId && value.WeekStartUtc == start).FirstAsync();
}
}
var evaluated = await EvaluateAsync(goal);
awards.AddRange(evaluated.Awards);
return new WeeklyGoalEnvelope
{
State = evaluated.Response, CreditAwards = awards,
Credits = await _credits.GetStatusAsync(userId)
};
}
public static DateTime WeekStart(DateTime value)
{
var date = value.ToUniversalTime().Date;
var offset = ((int)date.DayOfWeek + 6) % 7;
return date.AddDays(-offset);
}
public static List<string> SelectHardSystems(
DashboardResponse dashboard, IReadOnlyCollection<Question> published)
{
var counts = published.Where(question => question.Difficulty >= 4)
.GroupBy(Step3SystemCatalog.Normalize).Where(group => group.Key is not null)
.ToDictionary(group => group.Key!, group => group.Count());
var ordered = dashboard.Systems.Where(system => counts.GetValueOrDefault(system.System) > 0)
.OrderBy(system => MasteryRank(system.MasteryState)).ThenBy(system => system.MasteryScore)
.ThenBy(system => system.Difficulty4PlusSuccessful).Select(system => system.System).ToList();
var result = new List<string>();
var available = 0;
foreach (var system in ordered)
{
result.Add(system);
available += counts[system];
if (available >= 5 || result.Count == 3) break;
}
return result;
}
public static (string Type, int Target) ChooseRemediation(int availableReviews) =>
availableReviews >= 5
? (WeeklyRemediationTypes.Reviews, Math.Min(15, availableReviews))
: (WeeklyRemediationTypes.HardCorrect, 5);
public static int CountActiveDays(IEnumerable<DateTime> timestamps) =>
timestamps.Select(value => value.ToUniversalTime().Date).Distinct().Count();
public static bool QualifiesHardAttempt(WeeklyGoal goal, QuestionAttempt attempt, Question question) =>
!attempt.IsSkip && attempt.IsCorrect && attempt.CreatedUtc >= goal.WeekStartUtc
&& attempt.CreatedUtc < goal.ExpiresUtc && question.Difficulty >= 4
&& goal.EligibleSystems.Contains(Step3SystemCatalog.Normalize(question) ?? "");
private async Task<List<CreditAward>> FinalizeExpiredAsync(string userId, DateTime now)
{
var awards = new List<CreditAward>();
var expired = await _goals.Find(value => value.UserId == userId && value.ExpiresUtc <= now
&& !value.ExpirationRecorded).ToListAsync();
foreach (var goal in expired)
{
var wasCompleted = goal.CompletedUtc.HasValue;
var result = await EvaluateAsync(goal);
awards.AddRange(result.Awards);
var latest = await _goals.Find(value => value.Id == goal.Id).FirstAsync();
var recorded = await _goals.UpdateOneAsync(value => value.Id == goal.Id && !value.ExpirationRecorded,
Builders<WeeklyGoal>.Update.Set(value => value.ExpirationRecorded, true));
if (recorded.ModifiedCount == 1 && !latest.CompletedUtc.HasValue) _metrics.WeeklyGoalExpiredIncomplete();
if (recorded.ModifiedCount == 1 && !wasCompleted && latest.CompletedUtc.HasValue)
_metrics.WeeklyGoalLateFinalized();
}
return awards;
}
private async Task<(WeeklyGoalResponse Response, List<CreditAward> Awards)> EvaluateAsync(WeeklyGoal goal)
{
var evidence = await EvidenceAsync(goal);
var complete = evidence.ActiveDays >= 4 && evidence.Systems.Count >= 3
&& evidence.Remediation >= goal.RemediationTarget;
if (complete && !goal.CompletedUtc.HasValue)
{
var completedUtc = _clock.GetUtcNow().UtcDateTime;
var changed = await _goals.UpdateOneAsync(value => value.Id == goal.Id && value.CompletedUtc == null,
Builders<WeeklyGoal>.Update.Set(value => value.CompletedUtc, completedUtc));
goal.CompletedUtc = completedUtc;
if (changed.ModifiedCount == 1) _metrics.WeeklyGoalCompleted(goal.RemediationType);
}
var awards = new List<CreditAward>();
if (complete && !goal.RewardIssued)
{
var operationId = $"weekly-goal:{goal.Id}:complete";
var result = await _credits.AwardAsync(goal.UserId, new CreditAwardRequest(
operationId, 10, CreditReasonCodes.WeeklyGoalComplete,
"Completed the balanced weekly study goal", "weekly_goal", goal.Id, true));
if (result.Award is not null) awards.Add(result.Award);
var applied = result.Award ?? await _credits.GetAppliedAwardAsync(goal.UserId, operationId);
var amount = applied?.Amount ?? 0;
await _goals.UpdateOneAsync(value => value.Id == goal.Id && !value.RewardIssued,
Builders<WeeklyGoal>.Update.Set(value => value.RewardIssued, true)
.Set(value => value.RewardAmount, amount));
goal.RewardIssued = true;
goal.RewardAmount = amount;
}
return (Response(goal, evidence), awards);
}
private async Task<WeeklyEvidence> EvidenceAsync(WeeklyGoal goal)
{
var start = goal.WeekStartUtc;
var end = goal.ExpiresUtc;
var attemptsTask = _attempts.Find(value => value.UserId == goal.UserId && !value.IsSkip
&& value.CreatedUtc >= start && value.CreatedUtc < end).ToListAsync();
var reviewsTask = _reviews.Find(value => value.UserId == goal.UserId && value.IsCorrect
&& value.CreatedUtc >= start && value.CreatedUtc < end).ToListAsync();
var flashTask = _flashReviews.Find(value => value.UserId == goal.UserId && value.UndoneUtc == null
&& value.ReviewedUtc >= start && value.ReviewedUtc < end).ToListAsync();
var ccsTask = _ccsAttempts.Find(value => value.UserId == goal.UserId
&& value.State == CcsAttemptStates.Completed && value.CompletedUtc >= start && value.CompletedUtc < end)
.ToListAsync();
var blocksTask = _studySessions.Find(value => value.UserId == goal.UserId
&& value.GoalType == StudyGoalTypes.SimulationBlock && value.State == StudySessionStates.Completed
&& value.CompletedUtc >= start && value.CompletedUtc < end).ToListAsync();
var simulationsTask = _simulations.Find(value => value.UserId == goal.UserId
&& value.Responses.Any(response => response.UpdatedUtc >= start && response.UpdatedUtc < end)).ToListAsync();
await Task.WhenAll(attemptsTask, reviewsTask, flashTask, ccsTask, blocksTask, simulationsTask);
var attempts = attemptsTask.Result;
var reviews = reviewsTask.Result;
var flash = flashTask.Result;
var dates = attempts.Select(value => value.CreatedUtc)
.Concat(reviews.Select(value => value.CreatedUtc.Date))
.Concat(flash.Select(value => value.ReviewedUtc))
.Concat(ccsTask.Result.Select(value => value.CompletedUtc!.Value))
.Concat(blocksTask.Result.Select(value => value.CompletedUtc!.Value)).ToList();
var questionIds = attempts.Select(value => value.QuestionExternalId)
.Concat(reviews.Select(value => value.QuestionExternalId))
.Concat(simulationsTask.Result.SelectMany(value => value.Responses)
.Where(value => value.UpdatedUtc >= start && value.UpdatedUtc < end && !string.IsNullOrWhiteSpace(value.ChoiceId))
.Select(value => value.QuestionExternalId)).ToHashSet(StringComparer.Ordinal);
if (flash.Count > 0)
{
var cardIds = flash.Select(value => value.CardId).Distinct().ToList();
var schedules = await _flashSchedules.Find(value => cardIds.Contains(value.Id!)).ToListAsync();
var noteIds = schedules.Select(value => value.NoteId).Distinct().ToList();
var notes = await _flashNotes.Find(value => noteIds.Contains(value.Id!)
&& value.SourceQuestionExternalId != "").ToListAsync();
foreach (var note in notes) questionIds.Add(note.SourceQuestionExternalId);
}
var questionMap = await _questions.GetByExternalIdsAsync(questionIds);
var systems = questionMap.Values.Select(Step3SystemCatalog.Normalize)
.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value!).Distinct().ToHashSet();
var hardCorrect = attempts.Count(attempt => questionMap.TryGetValue(attempt.QuestionExternalId, out var question)
&& QualifiesHardAttempt(goal, attempt, question));
return new WeeklyEvidence
{
ActiveDays = CountActiveDays(dates), Systems = systems,
Remediation = goal.RemediationType == WeeklyRemediationTypes.Reviews ? reviews.Count : hardCorrect
};
}
private static WeeklyGoalResponse Response(WeeklyGoal goal, WeeklyEvidence evidence)
{
var components = new List<WeeklyGoalComponentResponse>
{
Component("active_days", "Study on 4 different days", evidence.ActiveDays, 4),
Component("system_breadth", "Study 3 Step 3 systems", evidence.Systems.Count, 3),
Component(goal.RemediationType,
goal.RemediationType == WeeklyRemediationTypes.Reviews
? $"Complete {goal.RemediationTarget} scheduled reviews"
: "Get 5 difficulty 4+ answers correct in coverage gaps",
evidence.Remediation, goal.RemediationTarget)
};
return new WeeklyGoalResponse
{
WeekStartUtc = goal.WeekStartUtc, ExpiresUtc = goal.ExpiresUtc,
Completed = components.All(value => value.Completed), RemediationType = goal.RemediationType,
EligibleSystems = goal.EligibleSystems,
SuggestedSystem = goal.EligibleSystems.FirstOrDefault(value => !evidence.Systems.Contains(value))
?? goal.EligibleSystems.FirstOrDefault() ?? "",
RewardAmount = goal.RewardAmount, Components = components
};
}
private static WeeklyGoalComponentResponse Component(string type, string label, int progress, int target) =>
new() { Type = type, Label = label, Progress = Math.Min(progress, target), Target = target,
Completed = progress >= target };
private static int MasteryRank(string state) => state switch
{
MasteryStates.Unseen => 0, MasteryStates.Learning => 1, MasteryStates.Practicing => 2,
MasteryStates.Strong => 3, _ => 4
};
private sealed class WeeklyEvidence
{
public int ActiveDays { get; set; }
public HashSet<string> Systems { get; set; } = [];
public int Remediation { get; set; }
}
}
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/>
<path d="M12 11h4M12 16h4M8 11h.01M8 16h.01"/>
</svg>

After

Width:  |  Height:  |  Size: 379 B

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cj8q5fugoyfhq"
path="res://.godot/imported/medical-history.svg-8cd75462cd9d8822133509e0f23e2799.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ccs_cases_2/icons/medical-history.svg"
dest_files=["res://.godot/imported/medical-history.svg-8cd75462cd9d8822133509e0f23e2799.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
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z"/>
<path d="M3.22 12H9.5l.5-1 2 4.5 2-7 1.5 3.5h5.27"/>
</svg>

After

Width:  |  Height:  |  Size: 394 B

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b8juqb8k774q4"
path="res://.godot/imported/vitals.svg-aeab46849d322f67e09ecb738933ea93.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ccs_cases_2/icons/vitals.svg"
dest_files=["res://.godot/imported/vitals.svg-aeab46849d322f67e09ecb738933ea93.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://crss7xdb4asxw"
path="res://.godot/imported/computer-base.png-c22fe210cc788b2a30551b9078564447.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ccs_cases_2/images/computer/computer-base.png"
dest_files=["res://.godot/imported/computer-base.png-c22fe210cc788b2a30551b9078564447.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 912 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://20mgki4ctdt2"
path="res://.godot/imported/chat-bubble.png-deb8723b28b12c2c18ca72693079a109.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ccs_cases_2/images/doctor/chat-bubble.png"
dest_files=["res://.godot/imported/chat-bubble.png-deb8723b28b12c2c18ca72693079a109.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://coe5g5bmhx4da"
path="res://.godot/imported/clock.png-1472633dccc280df9899fa995cf51944.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ccs_cases_2/images/doctor/clock.png"
dest_files=["res://.godot/imported/clock.png-1472633dccc280df9899fa995cf51944.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 788 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ciyh3pu7oheu7"
path="res://.godot/imported/dialogue-choice-hover.png-74920c58e8a5f4b150faa8f67c77995b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ccs_cases_2/images/doctor/dialogue-choice-hover.png"
dest_files=["res://.godot/imported/dialogue-choice-hover.png-74920c58e8a5f4b150faa8f67c77995b.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 866 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cmgb8fmmsu746"
path="res://.godot/imported/dialogue-choice-normal.png-474d0ee8b44edafede7ea4bf1717544e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ccs_cases_2/images/doctor/dialogue-choice-normal.png"
dest_files=["res://.godot/imported/dialogue-choice-normal.png-474d0ee8b44edafede7ea4bf1717544e.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 830 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dwkq4b24bxaj3"
path="res://.godot/imported/dialogue-choice-pressed.png-ddffca745e1cd0dd9cea64607a4049cb.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ccs_cases_2/images/doctor/dialogue-choice-pressed.png"
dest_files=["res://.godot/imported/dialogue-choice-pressed.png-ddffca745e1cd0dd9cea64607a4049cb.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://blvljbm5ylu14"
path="res://.godot/imported/office-backdrop.png-c4ff7cf7fa21dca6db6c6e4cf3183251.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ccs_cases_2/images/doctor/office-backdrop.png"
dest_files=["res://.godot/imported/office-backdrop.png-c4ff7cf7fa21dca6db6c6e4cf3183251.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d4fyqjakmkr1g"
path="res://.godot/imported/patient-44-white-male.png-01861ff1e5efd318a354f28d52214f12.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/ccs_cases_2/images/doctor/patient-44-white-male.png"
dest_files=["res://.godot/imported/patient-44-white-male.png-01861ff1e5efd318a354f28d52214f12.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
+15
View File
@@ -0,0 +1,15 @@
<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="M10 2v6.29a2 2 0 0 1-.5 1.32L3.9 17.8A2 2 0 0 0 5.4 21h13.2a2 2 0 0 0 1.5-3.2l-5.6-8.19A2 2 0 0 1 14 8.29V2" />
<path d="M6.5 15h11" />
<path d="M8.5 2h7" />
</svg>

After

Width:  |  Height:  |  Size: 376 B

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://c2ud26rg3exo1"
path="res://.godot/imported/flask-conical.svg-f4d9011dce33718bd3c2a0f719a8656a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/lucide/flask-conical.svg"
dest_files=["res://.godot/imported/flask-conical.svg-f4d9011dce33718bd3c2a0f719a8656a.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
+15
View File
@@ -0,0 +1,15 @@
<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="M6 3h12l4 6-10 12L2 9Z" />
<path d="M11 3 8 9l4 12 4-12-3-6" />
<path d="M2 9h20" />
</svg>

After

Width:  |  Height:  |  Size: 303 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://caty3viul0634"
path="res://.godot/imported/gem.svg-33acfe3fd8cfb097f65cc061d34abd2e.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/lucide/gem.svg"
dest_files=["res://.godot/imported/gem.svg-33acfe3fd8cfb097f65cc061d34abd2e.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
+5
View File
@@ -0,0 +1,5 @@
<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="20" height="14" x="2" y="3" rx="2"/>
<line x1="8" x2="16" y1="21" y2="21"/>
<line x1="12" x2="12" y1="17" y2="21"/>
</svg>

After

Width:  |  Height:  |  Size: 324 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://diqn3ntrortw"
path="res://.godot/imported/monitor.svg-1101ac90716471f71a4ffd9814ccca4c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/lucide/monitor.svg"
dest_files=["res://.godot/imported/monitor.svg-1101ac90716471f71a4ffd9814ccca4c.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="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>

After

Width:  |  Height:  |  Size: 292 B

+43
View File
@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cwt1kxtnhfgqo"
path="res://.godot/imported/rotate-ccw.svg-72097dc869bb37466442f5645fc6d506.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/icons/lucide/rotate-ccw.svg"
dest_files=["res://.godot/imported/rotate-ccw.svg-72097dc869bb37466442f5645fc6d506.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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://jwyvc10c44uu"
path="res://.godot/imported/cabinet_frame.png-97e2b6084ba7b318c962ef3eba634aa6.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/slot_machine/cabinet_frame.png"
dest_files=["res://.godot/imported/cabinet_frame.png-97e2b6084ba7b318c962ef3eba634aa6.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=true
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cqrjyobyp5h45"
path="res://.godot/imported/lever.png-08544c384e43fad84ca6f4c75fce0c0f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/slot_machine/lever.png"
dest_files=["res://.godot/imported/lever.png-08544c384e43fad84ca6f4c75fce0c0f.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=true
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bgp8erpmjoel7"
path="res://.godot/imported/slot_coin.png-0cc970c575e374ae6106c943cfe43ee1.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/slot_machine/slot_coin.png"
dest_files=["res://.godot/imported/slot_coin.png-0cc970c575e374ae6106c943cfe43ee1.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=true
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bi1vl8p2q6bgn"
path="res://.godot/imported/slot_coin_stack.png-f16acecc5a2cc4262b0bf33096e5a7d6.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/slot_machine/slot_coin_stack.png"
dest_files=["res://.godot/imported/slot_coin_stack.png-f16acecc5a2cc4262b0bf33096e5a7d6.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=true
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

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