Author SHA1 Message Date
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 9402a5d6de Merge pull request 'Fix simulated exam question flow' (#15) from fix/simulation-exam-flow into production
Reviewed-on: admin/step3#15
2026-07-30 04:27:48 -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 bf56f4192f Fix simulated exam question flow 2026-07-30 04:26:18 -05:00
hz 18802f4e8d Merge pull request 'Require production approval for question media' (#14) from fix/production-media-review into production
Reviewed-on: admin/step3#14
2026-07-30 04:20:22 -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
166 changed files with 87390 additions and 696 deletions
+35
View File
@@ -77,6 +77,41 @@ Promotion is idempotent and does not delete or implicitly unpublish PRD content.
## Verification and rollback ## 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: Check both internal and public readiness after every rollout:
```bash ```bash
+52
View File
@@ -135,6 +135,48 @@ public sealed class CcsCaseTests
Assert.Empty(CcsCaseValidator.ValidateImport(ValidImport(), new HashSet<string>())); 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] [Fact]
public void CcsExpectedScoreUsesSeparateUserAndCaseElo() public void CcsExpectedScoreUsesSeparateUserAndCaseElo()
{ {
@@ -201,4 +243,14 @@ 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 }
]
};
} }
+32 -4
View File
@@ -92,6 +92,33 @@ public sealed class ChoicePoolTests
Assert.Contains("every incorrect choice", QuestionValidator.Validate(request)); 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] [Fact]
public void OptionalReviewFollowupsRequireValidAuthoredContent() public void OptionalReviewFollowupsRequireValidAuthoredContent()
{ {
@@ -275,14 +302,15 @@ public sealed class ChoicePoolTests
id => id, id => id,
id => new WrongAnswerFollowup id => new WrongAnswerFollowup
{ {
Prompt = "Which finding makes this less appropriate?", Prompt = $"Which property of Choice {id} makes it less appropriate?",
Choices = Choices =
[ [
new QuestionChoice { Id = "A", Text = "Finding one" }, new QuestionChoice { Id = "A", Text = $"Disqualifying property of Choice {id}" },
new QuestionChoice { Id = "B", Text = "Finding two" } new QuestionChoice { Id = "B", Text = $"Plausible property one for Choice {id}" },
new QuestionChoice { Id = "C", Text = $"Plausible property two for Choice {id}" }
], ],
CorrectChoiceId = "A", CorrectChoiceId = "A",
TeachingPoint = "Teaching point" TeachingPoint = $"Choice {id} fails because of its specific disqualifying property."
}), }),
ChoiceSelectionPercentages = ids.ToDictionary(id => id, _ => 0), ChoiceSelectionPercentages = ids.ToDictionary(id => id, _ => 0),
Discipline = "Medicine", Discipline = "Medicine",
+47 -1
View File
@@ -8,14 +8,60 @@ public sealed class CreditEconomyTests
[Fact] [Fact]
public void EarningPolicy_UsesTheAgreedRollingLimit() public void EarningPolicy_UsesTheAgreedRollingLimit()
{ {
Assert.Equal(20, CreditEconomy.RollingEarningLimit); Assert.Equal(30, CreditEconomy.RollingEarningLimit);
Assert.Equal(TimeSpan.FromHours(24), CreditEconomy.EarningWindow); 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] [Fact]
public void CreditStatus_DoesNotExposeUserOrOperationIdentifiers() public void CreditStatus_DoesNotExposeUserOrOperationIdentifiers()
{ {
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.UserId))); Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.UserId)));
Assert.Null(typeof(CreditStatusResponse).GetProperty(nameof(UserCreditAccount.RecentEarnings))); 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("Pregnancy", "Pregnancy/Childbirth & Female Reproductive System & Breast")]
[InlineData("Biostatistics", "Biostatistics & Epidemiology/Population Health & Interpretation of the Medical Literature")] [InlineData("Biostatistics", "Biostatistics & Epidemiology/Population Health & Interpretation of the Medical Literature")]
[InlineData("Medical Ethics", "Social Sciences: Communication Skills/Ethics/Patient Safety")] [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) public void SystemAliasesMapToOfficialCategories(string source, string expected)
{ {
Assert.Equal(expected, DashboardStore.NormalizeStep3System(source)); Assert.Equal(expected, DashboardStore.NormalizeStep3System(source));
+154 -3
View File
@@ -44,8 +44,104 @@ public sealed class ExamSimulationTests
Assert.Equal(0, failing.Correct); 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] [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 questions = BuildQuestions(20);
var unseen = BuildItems(20); var unseen = BuildItems(20);
@@ -59,7 +155,9 @@ public sealed class ExamSimulationTests
Assert.Equal(0, unseenScore.SeenCount); Assert.Equal(0, unseenScore.SeenCount);
Assert.Equal(20, seenScore.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] [Theory]
@@ -111,11 +209,64 @@ public sealed class ExamSimulationTests
Assert.False(ExamSimulationStore.TryDecodeHistoryCursor("not-a-cursor", out _)); 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) => private static List<ExamSimulationItem> BuildItems(int count, bool wasSeen = false) =>
Enumerable.Range(0, count).Select(index => new ExamSimulationItem Enumerable.Range(0, count).Select(index => new ExamSimulationItem
{ {
QuestionExternalId = $"question-{index}", QuestionExternalId = $"question-{index}",
QuestionElo = 1000, QuestionElo = 1000,
QuestionDifficulty = 3,
WasSeen = wasSeen, WasSeen = wasSeen,
BlueprintCategory = "Cardiovascular" BlueprintCategory = "Cardiovascular"
}).ToList(); }).ToList();
@@ -123,5 +274,5 @@ public sealed class ExamSimulationTests
private static Dictionary<string, Question> BuildQuestions(int count) => private static Dictionary<string, Question> BuildQuestions(int count) =>
Enumerable.Range(0, count).ToDictionary( Enumerable.Range(0, count).ToDictionary(
index => $"question-{index}", 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("Reported by student", question.FlagReason);
Assert.Equal("user-42", question.FlaggedByUserId); 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.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Services; using QuestionsApi.Services;
using QuestionsApi.Models;
using System.Security.Claims; using System.Security.Claims;
namespace QuestionsApi.Controllers; namespace QuestionsApi.Controllers;
@@ -8,13 +9,84 @@ namespace QuestionsApi.Controllers;
[ApiController] [ApiController]
[Route("api/credits")] [Route("api/credits")]
[Authorize(Roles = "USER,SUPER")] [Authorize(Roles = "USER,SUPER")]
public sealed class CreditsController(CreditStore credits) : ControllerBase public sealed class CreditsController(CreditStore credits, ExamSimulationStore simulations) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IActionResult> Get() => Ok(await credits.GetStatusAsync(UserId())); 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) private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name) ?? User.FindFirstValue(ClaimTypes.Name)
?? User.Identity?.Name ?? User.Identity?.Name
?? throw new UnauthorizedAccessException(); ?? 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] [ApiController]
[Route("api/dashboard")] [Route("api/dashboard")]
[Authorize(Roles = "USER,SUPER")] [Authorize(Roles = "USER,SUPER")]
public sealed class DashboardController(DashboardStore dashboard) : ControllerBase public sealed class DashboardController(DashboardStore dashboard, ProgressionStore progression) : ControllerBase
{ {
[HttpGet] [HttpGet]
public async Task<IActionResult> Get() public async Task<IActionResult> Get()
@@ -18,6 +18,8 @@ public sealed class DashboardController(DashboardStore dashboard) : ControllerBa
?? User.Identity?.Name ?? User.Identity?.Name
?? ""; ?? "";
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); 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] [ApiController]
[Route("api/exam-simulations")] [Route("api/exam-simulations")]
[Authorize(Roles = "USER,SUPER")] [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")] [HttpGet("readiness")]
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId())); public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
@@ -17,12 +22,33 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
[HttpPost] [HttpPost]
public async Task<IActionResult> Create() 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 try
{ {
var simulation = await simulations.CreateAsync(UserId()); var simulation = await simulations.CreateAsync(userId);
return Ok(await simulations.ToResponseAsync(simulation)); 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")] [HttpGet("preview-results")]
@@ -92,7 +118,12 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
{ {
var simulation = await simulations.GetOwnedAsync(id, UserId()); var simulation = await simulations.GetOwnedAsync(id, UserId());
if (simulation is null) return NotFound(); 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); } catch (InvalidOperationException exception) { return Conflict(exception.Message); }
} }
@@ -111,7 +142,36 @@ public class ExamSimulationsController(ExamSimulationStore simulations) : Contro
{ {
var simulation = await simulations.GetOwnedAsync(id, UserId()); var simulation = await simulations.GetOwnedAsync(id, UserId());
if (simulation is null) return NotFound(); 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); } 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 QuestionBookmarkStore _bookmarks;
private readonly ReviewQueueStore _reviewQueue; private readonly ReviewQueueStore _reviewQueue;
private readonly CreditStore _credits; private readonly CreditStore _credits;
private readonly MasteryAwardService _masteryAwards;
private readonly StudySessionStore _studySessions;
private readonly DailyMissionStore _dailyMissions;
private readonly ProgressionStore _progression;
public QuestionsController( public QuestionsController(
QuestionStore questions, QuestionStore questions,
@@ -30,7 +34,11 @@ public class QuestionsController : ControllerBase
ServiceMetrics metrics, ServiceMetrics metrics,
QuestionBookmarkStore bookmarks, QuestionBookmarkStore bookmarks,
ReviewQueueStore reviewQueue, ReviewQueueStore reviewQueue,
CreditStore credits) CreditStore credits,
MasteryAwardService masteryAwards,
StudySessionStore studySessions,
DailyMissionStore dailyMissions,
ProgressionStore progression)
{ {
_questions = questions; _questions = questions;
_attempts = attempts; _attempts = attempts;
@@ -41,6 +49,10 @@ public class QuestionsController : ControllerBase
_bookmarks = bookmarks; _bookmarks = bookmarks;
_reviewQueue = reviewQueue; _reviewQueue = reviewQueue;
_credits = credits; _credits = credits;
_masteryAwards = masteryAwards;
_studySessions = studySessions;
_dailyMissions = dailyMissions;
_progression = progression;
} }
[HttpGet("systems")] [HttpGet("systems")]
@@ -55,7 +67,9 @@ public class QuestionsController : ControllerBase
[Authorize(Roles = "USER,SUPER")] [Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> Search([FromBody] QuestionSearchRequest req) 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 percentages = await _statistics.GetPercentagesByQuestionAsync(questions.Select(q => q.ExternalId));
var questionElos = await _ratings.GetQuestionElosAsync(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)); var bookmarkedIds = await _bookmarks.GetBookmarkedIdsAsync(UserId(), questions.Select(q => q.ExternalId));
@@ -150,6 +164,9 @@ public class QuestionsController : ControllerBase
if (string.IsNullOrWhiteSpace(userId)) if (string.IsNullOrWhiteSpace(userId))
return Unauthorized(); return Unauthorized();
await _masteryAwards.PrepareAsync(userId);
await _progression.PrepareAsync(userId);
var record = await _attempts.RecordFirstAttemptAsync( var record = await _attempts.RecordFirstAttemptAsync(
question.ExternalId, question.ExternalId,
userId, userId,
@@ -157,7 +174,8 @@ public class QuestionsController : ControllerBase
req.ChoiceId.Trim() == question.CorrectChoiceId, req.ChoiceId.Trim() == question.CorrectChoiceId,
false, false,
presentedChoiceIds, presentedChoiceIds,
req.DurationSeconds); req.DurationSeconds,
await _credits.GetBankCycleAsync(userId));
if (record.Inserted) if (record.Inserted)
{ {
_metrics.AttemptCreated(); _metrics.AttemptCreated();
@@ -175,8 +193,18 @@ public class QuestionsController : ControllerBase
_metrics.AttemptDuplicate(); _metrics.AttemptDuplicate();
} }
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt); await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
if (record.Inserted && record.Attempt.IsCorrect && req.MetTimeLimit) var creditAwards = new List<CreditAward>();
await _credits.AwardForFirstAnswerAsync(userId, record.Attempt.Id ?? ""); 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 ratingOwner = Guid.NewGuid().ToString("N");
var shouldRate = await _attempts.TryAcquireRatingLeaseAsync( var shouldRate = await _attempts.TryAcquireRatingLeaseAsync(
@@ -202,6 +230,19 @@ public class QuestionsController : ControllerBase
throw; 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 return Ok(new FirstQuestionAttemptResponse
{ {
QuestionExternalId = question.ExternalId, QuestionExternalId = question.ExternalId,
@@ -210,7 +251,12 @@ public class QuestionsController : ControllerBase
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)), await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo, QuestionElo = rating.QuestionElo,
Rating = rating, 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)) if (string.IsNullOrWhiteSpace(userId))
return Unauthorized(); return Unauthorized();
await _progression.PrepareAsync(userId);
var record = await _attempts.RecordFirstAttemptAsync( var record = await _attempts.RecordFirstAttemptAsync(
question.ExternalId, question.ExternalId,
userId, userId,
"", "",
false, false,
true); true,
bankCycle: await _credits.GetBankCycleAsync(userId));
if (record.Inserted) if (record.Inserted)
_metrics.AttemptCreated(); _metrics.AttemptCreated();
else else
@@ -302,6 +351,7 @@ public class QuestionsController : ControllerBase
throw; throw;
} }
var progression = await _progression.RefreshAsync(userId);
return Ok(new SkipQuestionResponse return Ok(new SkipQuestionResponse
{ {
QuestionExternalId = question.ExternalId, QuestionExternalId = question.ExternalId,
@@ -310,7 +360,8 @@ public class QuestionsController : ControllerBase
question, question,
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)), await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
QuestionElo = rating.QuestionElo, QuestionElo = rating.QuestionElo,
Rating = rating Rating = rating,
Progression = progression.State
}); });
} }
@@ -14,7 +14,12 @@ public sealed class ReviewQueueController(
QuestionStore questions, QuestionStore questions,
AnswerStatisticsStore statistics, AnswerStatisticsStore statistics,
RatingStore ratings, RatingStore ratings,
QuestionBookmarkStore bookmarks) : ControllerBase QuestionBookmarkStore bookmarks,
CreditStore credits,
MasteryAwardService masteryAwards,
StudySessionStore studySessions,
DailyMissionStore dailyMissions,
ProgressionStore progression) : ControllerBase
{ {
[HttpPost("next")] [HttpPost("next")]
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req) public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
@@ -22,9 +27,11 @@ public sealed class ReviewQueueController(
var userId = UserId(); var userId = UserId();
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
await queue.EnsureUserQueueAsync(userId); 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) 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); var question = await questions.GetByExternalIdAsync(item.QuestionExternalId);
if (question is null || !question.IsPublished) return NotFound(); if (question is null || !question.IsPublished) return NotFound();
@@ -39,7 +46,7 @@ public sealed class ReviewQueueController(
{ {
Question = response, Question = response,
Cycle = item.Cycle, 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"); return BadRequest("presentedChoiceIds must include valid selected and correct choices");
} }
await masteryAwards.PrepareAsync(userId);
await progression.PrepareAsync(userId);
var result = await queue.RecordReviewAsync( var result = await queue.RecordReviewAsync(
userId, userId,
req.ClientAttemptId, req.ClientAttemptId,
@@ -79,12 +88,44 @@ public sealed class ReviewQueueController(
choiceId == question.CorrectChoiceId, choiceId == question.CorrectChoiceId,
req.DurationSeconds); req.DurationSeconds);
if (result is null) return Conflict("review cycle is no longer active"); 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 return Ok(new ReviewQueueAttemptResponse
{ {
QuestionExternalId = question.ExternalId, QuestionExternalId = question.ExternalId,
IsCorrect = result.Attempt.IsCorrect, IsCorrect = result.Attempt.IsCorrect,
QueueState = result.Item?.State ?? ReviewQueueStates.Pending, 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();
}
+34
View File
@@ -65,6 +65,7 @@ public class CcsCaseRevision
public int Difficulty { get; set; } = 2; public int Difficulty { get; set; } = 2;
public List<string> Tags { get; set; } = []; public List<string> Tags { get; set; } = [];
public CcsInitialChart InitialChart { get; set; } = new(); public CcsInitialChart InitialChart { get; set; } = new();
public CcsInitialInterview InitialInterview { get; set; } = new();
public List<string> AllowedLocations { get; set; } = []; public List<string> AllowedLocations { get; set; } = [];
public List<CcsExamDefinition> Exams { get; set; } = []; public List<CcsExamDefinition> Exams { get; set; } = [];
public List<CcsCaseOrderDefinition> OrderResponses { get; set; } = []; public List<CcsCaseOrderDefinition> OrderResponses { get; set; } = [];
@@ -74,6 +75,27 @@ public class CcsCaseRevision
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
} }
public class CcsInitialInterview
{
public string StartNodeId { get; set; } = "";
public List<CcsDialogueNode> Nodes { get; set; } = [];
}
public class CcsDialogueNode
{
public string Id { get; set; } = "";
public string PatientText { 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 class CcsInitialChart
{ {
public string PatientSummary { get; set; } = ""; public string PatientSummary { get; set; } = "";
@@ -148,6 +170,8 @@ public class CcsAttempt
public int Revision { get; set; } public int Revision { get; set; }
public int SimulatedMinute { get; set; } public int SimulatedMinute { get; set; }
[BsonIgnoreIfNull] public bool? OpeningCompleted { 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 string Location { get; set; } = "";
public Dictionary<string, string> CurrentVitals { get; set; } = []; public Dictionary<string, string> CurrentVitals { get; set; } = [];
public List<CcsAttemptAction> Actions { get; set; } = []; public List<CcsAttemptAction> Actions { get; set; } = [];
@@ -287,6 +311,7 @@ public class CcsCaseSummary
public int RealTimeMinutes { get; set; } public int RealTimeMinutes { get; set; }
public int Difficulty { get; set; } public int Difficulty { get; set; }
public List<string> Tags { get; set; } = []; public List<string> Tags { get; set; } = [];
public bool InteractiveInterviewAvailable { get; set; }
} }
public class CcsAttemptResponse public class CcsAttemptResponse
@@ -320,11 +345,20 @@ public class CcsAttemptResponse
public List<CcsPendingResult> PendingResults { get; set; } = []; public List<CcsPendingResult> PendingResults { get; set; } = [];
public List<CcsExamDefinition> AvailableExams { get; set; } = []; public List<CcsExamDefinition> AvailableExams { get; set; } = [];
public List<string> AllowedLocations { get; set; } = []; public List<string> AllowedLocations { get; set; } = [];
public CcsInterviewStateResponse? Interview { get; set; }
public CcsCaseScore? Score { get; set; } public CcsCaseScore? Score { get; set; }
public CcsRatingChange? Rating { get; set; } public CcsRatingChange? Rating { get; set; }
public List<QuestionReference> References { 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 class CcsHistoryItem
{ {
public string Id { get; set; } = ""; public string Id { get; set; } = "";
+225 -1
View File
@@ -5,13 +5,175 @@ namespace QuestionsApi.Models;
public static class CreditEconomy public static class CreditEconomy
{ {
public const int RollingEarningLimit = 20; public const int RollingEarningLimit = 30;
public static readonly TimeSpan EarningWindow = TimeSpan.FromHours(24); 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 sealed class CreditEvent
{ {
public string OperationId { get; set; } = ""; 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; } public DateTime CreatedUtc { get; set; }
} }
@@ -22,7 +184,16 @@ public sealed class UserCreditAccount
public string? Id { get; set; } public string? Id { get; set; }
public string UserId { get; set; } = ""; public string UserId { get; set; } = "";
public int Credits { 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<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; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
} }
@@ -32,4 +203,57 @@ public sealed class CreditStatusResponse
public int EarnedInRollingWindow { get; set; } public int EarnedInRollingWindow { get; set; }
public int RemainingEarningCapacity { get; set; } public int RemainingEarningCapacity { get; set; }
public int RollingWindowHours { get; set; } = 24; 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<DashboardSystemPerformance> Systems { get; set; } = [];
public List<DashboardActivityDay> WeeklyActivity { get; set; } = []; public List<DashboardActivityDay> WeeklyActivity { get; set; } = [];
public List<DashboardRecentActivity> RecentActivity { get; set; } = []; public List<DashboardRecentActivity> RecentActivity { get; set; } = [];
public ProgressionResponse? Progression { get; set; }
} }
public sealed class DashboardTrendPoint public sealed class DashboardTrendPoint
@@ -49,6 +50,32 @@ public sealed class DashboardSystemPerformance
public int Correct { get; set; } public int Correct { get; set; }
public int Total { get; set; } public int Total { get; set; }
public int Accuracy { 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 public sealed class DashboardActivityDay
+11 -1
View File
@@ -41,6 +41,7 @@ public class ExamSimulationItem
public int Position { get; set; } public int Position { get; set; }
public List<string> PresentedChoiceIds { get; set; } = []; public List<string> PresentedChoiceIds { get; set; } = [];
public int QuestionElo { get; set; } = 1000; public int QuestionElo { get; set; } = 1000;
public int QuestionDifficulty { get; set; }
public bool WasSeen { get; set; } public bool WasSeen { get; set; }
public string BlueprintCategory { get; set; } = "Other"; public string BlueprintCategory { get; set; } = "Other";
} }
@@ -52,6 +53,7 @@ public class ExamSimulationResponse
public List<string> EliminatedChoiceIds { get; set; } = []; public List<string> EliminatedChoiceIds { get; set; } = [];
public bool Flagged { get; set; } public bool Flagged { get; set; }
public int ElapsedSeconds { get; set; } public int ElapsedSeconds { get; set; }
public bool? IsSubmitted { get; set; }
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow; public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
} }
@@ -64,7 +66,9 @@ public class ExamSimulationScore
public int Correct { get; set; } public int Correct { get; set; }
public int Total { get; set; } public int Total { get; set; }
public int SeenCount { 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; } = []; public Dictionary<string, ExamSimulationCategoryScore> Categories { get; set; } = [];
} }
@@ -81,6 +85,7 @@ public class SaveSimulationResponseRequest
public List<string> EliminatedChoiceIds { get; set; } = []; public List<string> EliminatedChoiceIds { get; set; } = [];
public bool Flagged { get; set; } public bool Flagged { get; set; }
public int ElapsedSeconds { get; set; } public int ElapsedSeconds { get; set; }
public bool IsSubmitted { get; set; }
} }
public class ExamSimulationQuestionResponse public class ExamSimulationQuestionResponse
@@ -112,6 +117,11 @@ public class ExamSimulationStateResponse
public Dictionary<string, int> BlueprintTargets { get; set; } = []; public Dictionary<string, int> BlueprintTargets { get; set; } = [];
public Dictionary<string, int> BlueprintActual { get; set; } = []; public Dictionary<string, int> BlueprintActual { get; set; } = [];
public List<QuestionResponse> ReviewQuestions { 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 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 Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public string Discipline { get; set; } = ""; public string Discipline { get; set; } = "";
public string System { get; set; } = ""; public string System { get; set; } = "";
public string Step3System { get; set; } = "";
public string PerformanceCategory { get; set; } = ""; public string PerformanceCategory { get; set; } = "";
public int Difficulty { get; set; } = 1; public int Difficulty { get; set; } = 1;
public List<QuestionLab> Labs { get; set; } = []; public List<QuestionLab> Labs { get; set; } = [];
@@ -11,6 +11,7 @@ public class QuestionAttempt
public string QuestionExternalId { get; set; } = ""; public string QuestionExternalId { get; set; } = "";
public string UserId { get; set; } = ""; public string UserId { get; set; } = "";
public int BankCycle { get; set; }
public string ChoiceId { get; set; } = ""; public string ChoiceId { get; set; } = "";
public List<string> PresentedChoiceIds { get; set; } = []; public List<string> PresentedChoiceIds { get; set; } = [];
public bool TracksPresentation { get; set; } public bool TracksPresentation { get; set; }
@@ -3,6 +3,7 @@ namespace QuestionsApi.Models;
public class QuestionSearchRequest public class QuestionSearchRequest
{ {
public string? System { get; set; } public string? System { get; set; }
public string? Step3System { get; set; }
public string? Discipline { get; set; } public string? Discipline { get; set; }
public List<string> Tags { get; set; } = []; public List<string> Tags { get; set; } = [];
public int? DifficultyMin { get; set; } public int? DifficultyMin { get; set; }
@@ -50,4 +51,5 @@ public class FirstQuestionAttemptRequest
public List<string> PresentedChoiceIds { get; set; } = []; public List<string> PresentedChoiceIds { get; set; } = [];
public int DurationSeconds { get; set; } public int DurationSeconds { get; set; }
public bool MetTimeLimit { get; set; } = true; public bool MetTimeLimit { get; set; } = true;
public string? StudySessionId { get; set; }
} }
@@ -1,3 +1,5 @@
using QuestionsApi.Services;
namespace QuestionsApi.Models; namespace QuestionsApi.Models;
public class QuestionResponse public class QuestionResponse
@@ -18,6 +20,7 @@ public class QuestionResponse
public string ChoiceSelectionRateBasis { get; set; } = "presented"; public string ChoiceSelectionRateBasis { get; set; } = "presented";
public string Discipline { get; set; } = ""; public string Discipline { get; set; } = "";
public string System { get; set; } = ""; public string System { get; set; } = "";
public string Step3System { get; set; } = "";
public string PerformanceCategory { get; set; } = ""; public string PerformanceCategory { get; set; } = "";
public int Difficulty { get; set; } public int Difficulty { get; set; }
public int QuestionElo { get; set; } = 1000; public int QuestionElo { get; set; } = 1000;
@@ -59,6 +62,7 @@ public class QuestionResponse
.ToDictionary(), .ToDictionary(),
Discipline = question.Discipline, Discipline = question.Discipline,
System = question.System, System = question.System,
Step3System = Step3SystemCatalog.Normalize(question) ?? "",
PerformanceCategory = RatingCategory.Normalize(question.PerformanceCategory, question.System, question.Discipline), PerformanceCategory = RatingCategory.Normalize(question.PerformanceCategory, question.System, question.Discipline),
Difficulty = question.Difficulty, Difficulty = question.Difficulty,
QuestionElo = questionElo, QuestionElo = questionElo,
@@ -142,6 +146,11 @@ public class FirstQuestionAttemptResponse
public int QuestionElo { get; set; } = 1000; public int QuestionElo { get; set; } = 1000;
public RatingChangeResponse? Rating { get; set; } public RatingChangeResponse? Rating { get; set; }
public CreditStatusResponse? Credits { 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 public class SkipQuestionResponse
@@ -151,6 +160,7 @@ public class SkipQuestionResponse
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = []; public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
public int QuestionElo { get; set; } = 1000; public int QuestionElo { get; set; } = 1000;
public RatingChangeResponse? Rating { get; set; } public RatingChangeResponse? Rating { get; set; }
public ProgressionResponse? Progression { get; set; }
} }
public static class RatingCategory public static class RatingCategory
@@ -55,6 +55,7 @@ public sealed class ReviewQueueNextRequest
{ {
public List<string> ExcludeExternalIds { get; set; } = []; public List<string> ExcludeExternalIds { get; set; } = [];
public bool SupportsChoicePools { get; set; } = true; public bool SupportsChoicePools { get; set; } = true;
public string? Step3System { get; set; }
} }
public sealed class ReviewQueueNextResponse public sealed class ReviewQueueNextResponse
@@ -72,6 +73,7 @@ public sealed class ReviewQueueAttemptRequest
public string ChoiceId { get; set; } = ""; public string ChoiceId { get; set; } = "";
public List<string> PresentedChoiceIds { get; set; } = []; public List<string> PresentedChoiceIds { get; set; } = [];
public int DurationSeconds { get; set; } public int DurationSeconds { get; set; }
public string? StudySessionId { get; set; }
} }
public sealed class ReviewQueueAttemptResponse public sealed class ReviewQueueAttemptResponse
@@ -80,6 +82,12 @@ public sealed class ReviewQueueAttemptResponse
public bool IsCorrect { get; set; } public bool IsCorrect { get; set; }
public string QueueState { get; set; } = ReviewQueueStates.Pending; public string QueueState { get; set; } = ReviewQueueStates.Pending;
public int PendingCount { get; set; } 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 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; } = [];
}
+29
View File
@@ -55,6 +55,28 @@ if (args.FirstOrDefault() == "flag-media-questions")
return; 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); var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.Configure<ForwardedHeadersOptions>(options => builder.Services.Configure<ForwardedHeadersOptions>(options =>
@@ -68,6 +90,8 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
builder.Services.AddSingleton<MongoDatabaseProvider>(); builder.Services.AddSingleton<MongoDatabaseProvider>();
builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<CreditStore>(); builder.Services.AddSingleton<CreditStore>();
builder.Services.AddSingleton<MasteryAwardService>();
builder.Services.AddHostedService<CreditLedgerReconciliationService>();
builder.Services.AddSingleton<QuestionStore>(); builder.Services.AddSingleton<QuestionStore>();
builder.Services.AddSingleton<QuestionAttemptStore>(); builder.Services.AddSingleton<QuestionAttemptStore>();
builder.Services.AddSingleton<AnswerStatisticsStore>(); builder.Services.AddSingleton<AnswerStatisticsStore>();
@@ -87,6 +111,10 @@ builder.Services.AddSingleton<UserNotepadStore>();
builder.Services.AddSingleton<QuestionBookmarkStore>(); builder.Services.AddSingleton<QuestionBookmarkStore>();
builder.Services.AddSingleton<ReviewQueueStore>(); builder.Services.AddSingleton<ReviewQueueStore>();
builder.Services.AddSingleton<DashboardStore>(); 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<UserProfileStore>();
builder.Services.AddSingleton<IFlashcardScheduler, FsrsFlashcardScheduler>(); builder.Services.AddSingleton<IFlashcardScheduler, FsrsFlashcardScheduler>();
builder.Services.AddSingleton<FlashcardDeckStore>(); builder.Services.AddSingleton<FlashcardDeckStore>();
@@ -114,6 +142,7 @@ builder.Services.AddSingleton<DonationStore>();
builder.Services.AddSingleton<IStripeDonationGateway, StripeDonationGateway>(); builder.Services.AddSingleton<IStripeDonationGateway, StripeDonationGateway>();
builder.Services.AddSingleton<ServiceMetrics>(); builder.Services.AddSingleton<ServiceMetrics>();
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>(); builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
builder.Services.AddHostedService<ExamSimulationScoringMigrationService>();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddRateLimiter(options => 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 questions without references remain readable but must add references before a
future authoring update. 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 /readyz` verifies MongoDB connectivity for readiness checks.
- `GET /metrics` exposes Prometheus-format service and reconciliation metrics. - `GET /metrics` exposes Prometheus-format service and reconciliation metrics.
+46 -2
View File
@@ -4,7 +4,7 @@ namespace QuestionsApi.Services;
public static class CcsCaseValidator 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) public static List<string> ValidateImport(CcsImportRequest request, IReadOnlySet<string> existingOrderCodes)
{ {
@@ -38,6 +38,7 @@ public static class CcsCaseValidator
errors.Add(prefix + "initial patient summary and reason are required"); errors.Add(prefix + "initial patient summary and reason are required");
if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase)) if (!item.AllowedLocations.Contains(item.PresentingLocation, StringComparer.OrdinalIgnoreCase))
errors.Add(prefix + "allowedLocations must include presentingLocation"); errors.Add(prefix + "allowedLocations must include presentingLocation");
ValidateInterview(item.InitialInterview, prefix, errors);
if (item.IsPublished) if (item.IsPublished)
{ {
var referenceError = QuestionValidator.ValidateReferences(item.References, true); var referenceError = QuestionValidator.ValidateReferences(item.References, true);
@@ -90,7 +91,7 @@ public static class CcsCaseValidator
if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required"; if (string.IsNullOrWhiteSpace(request.CommandId)) return "commandId required";
if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative"; if (request.ExpectedRevision < 0) return "expectedRevision cannot be negative";
if (!CommandTypes.Contains(request.Type)) return "unsupported command type"; 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"; if (request.Type == "advance" && request.Minutes is < 1 or > 525600) return "advance minutes must be between 1 and 525600";
return null; return null;
} }
@@ -119,4 +120,47 @@ public static class CcsCaseValidator
return "history and examinations cannot be mixed with order changes in one batch"; return "history and examinations cannot be mixed with order changes in one batch";
return null; return null;
} }
private static void ValidateInterview(CcsInitialInterview interview, string prefix, List<string> errors)
{
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");
}
} }
+35 -1
View File
@@ -117,6 +117,7 @@ public sealed class CcsStore
CaseVersion = definition.Version, CaseVersion = definition.Version,
Mode = CcsAttemptModes.Timed, Mode = CcsAttemptModes.Timed,
OpeningCompleted = false, OpeningCompleted = false,
InterviewNodeId = definition.InitialInterview.StartNodeId,
Location = definition.PresentingLocation, Location = definition.PresentingLocation,
CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals), CurrentVitals = new Dictionary<string, string>(definition.InitialChart.Vitals),
VitalHistory = VitalHistory =
@@ -227,6 +228,7 @@ public sealed class CcsStore
case "acknowledge_opening": case "acknowledge_opening":
attempt.OpeningCompleted = true; attempt.OpeningCompleted = true;
break; break;
case "dialogue_choice": ApplyDialogueChoice(attempt, definition, command.Code, now); break;
case "end_case": EnterFinalOrders(attempt); break; case "end_case": EnterFinalOrders(attempt); break;
case "exit_case": await CompleteAsync(attempt); break; case "exit_case": await CompleteAsync(attempt); break;
} }
@@ -412,6 +414,19 @@ public sealed class CcsStore
List<CcsVitalSnapshot> vitalHistory = attempt.VitalHistory.Count > 0 List<CcsVitalSnapshot> vitalHistory = attempt.VitalHistory.Count > 0
? attempt.VitalHistory ? attempt.VitalHistory
: [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }]; : [new CcsVitalSnapshot { SimulatedMinute = attempt.SimulatedMinute, Values = new Dictionary<string, string>(attempt.CurrentVitals) }];
CcsInterviewStateResponse? interview = null;
var currentInterviewNode = definition.InitialInterview.Nodes.FirstOrDefault(value =>
value.Id.Equals(attempt.InterviewNodeId, StringComparison.OrdinalIgnoreCase));
if (currentInterviewNode is not null)
{
interview = new CcsInterviewStateResponse
{
CurrentNodeId = currentInterviewNode.Id,
CurrentNode = currentInterviewNode,
VisitedChoiceIds = attempt.InterviewChoiceIds,
Completed = attempt.OpeningCompleted ?? false
};
}
return new CcsAttemptResponse return new CcsAttemptResponse
{ {
Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId, Id = attempt.Id ?? "", CaseExternalId = attempt.CaseExternalId,
@@ -434,11 +449,29 @@ public sealed class CcsStore
}).ToList(), }).ToList(),
AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [], AvailableExams = attempt.State == CcsAttemptStates.Active ? definition.Exams : [],
AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [], AllowedLocations = attempt.State == CcsAttemptStates.Active ? definition.AllowedLocations : [],
Interview = interview,
Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null, Score = completed ? attempt.Score : null, Rating = completed ? attempt.Rating : null,
References = completed ? definition.References : [] References = completed ? definition.References : []
}; };
} }
public static void ApplyDialogueChoice(CcsAttempt attempt, CcsCaseRevision definition, string choiceId, DateTime now)
{
if (attempt.OpeningCompleted == true) throw new InvalidOperationException("The initial interview is already complete.");
var node = definition.InitialInterview.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 = definition.InitialInterview.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) public async Task AbandonAsync(CcsAttempt attempt)
{ {
attempt.State = CcsAttemptStates.Abandoned; attempt.State = CcsAttemptStates.Abandoned;
@@ -693,7 +726,8 @@ public sealed class CcsStore
{ {
ExternalId = value.ExternalId, Title = value.Title, System = value.System, ExternalId = value.ExternalId, Title = value.Title, System = value.System,
PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes, PresentingLocation = value.PresentingLocation, RealTimeMinutes = value.RealTimeMinutes,
Difficulty = value.Difficulty, Tags = value.Tags Difficulty = value.Difficulty, Tags = value.Tags,
InteractiveInterviewAvailable = !string.IsNullOrWhiteSpace(value.InitialInterview.StartNodeId) && value.InitialInterview.Nodes.Count > 0
}; };
internal static int Rank(CcsOrderDefinition order, string query) internal static int Rank(CcsOrderDefinition order, string query)
{ {
@@ -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 public sealed class CreditStore
{ {
private readonly IMongoCollection<UserCreditAccount> _accounts; private readonly IMongoCollection<UserCreditAccount> _accounts;
private readonly IMongoCollection<CreditLedgerEntry> _ledger;
private readonly TimeProvider _clock; 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"); _accounts = mongo.Database.GetCollection<UserCreditAccount>("UserCreditAccounts");
_ledger = mongo.Database.GetCollection<CreditLedgerEntry>("CreditLedgerEntries");
_clock = clock; _clock = clock;
_metrics = metrics;
_accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>( _accounts.Indexes.CreateOne(new CreateIndexModel<UserCreditAccount>(
Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId), Builders<UserCreditAccount>.IndexKeys.Ascending(value => value.UserId),
new CreateIndexOptions { Unique = true, Name = "uq_credit_user" })); 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; var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now); await EnsureAndCleanAsync(userId, now);
var entryId = LedgerId(userId, request.OperationId);
var filters = Builders<UserCreditAccount>.Filter; var entry = await _ledger.Find(value => value.Id == entryId).FirstOrDefaultAsync();
var belowCap = new BsonDocumentFilterDefinition<UserCreditAccount>( if (entry is not null && (entry.ReasonCode != request.ReasonCode || entry.SourceId != request.SourceId))
new BsonDocument("$expr", new BsonDocument("$lt", new BsonArray 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"), Id = entryId, UserId = userId, OperationId = request.OperationId,
CreditEconomy.RollingEarningLimit RequestedAmount = request.Amount, Amount = request.SubjectToRollingCap ? 0 : request.Amount,
}))); ReasonCode = request.ReasonCode,
var filter = filters.Eq(value => value.UserId, userId) & Description = request.Description, SourceType = request.SourceType,
filters.Not(filters.ElemMatch(value => value.RecentEarnings, SourceId = request.SourceId, SubjectToRollingCap = request.SubjectToRollingCap,
earning => earning.OperationId == attemptId)) &
belowCap;
var update = Builders<UserCreditAccount>.Update
.Inc(value => value.Credits, 1)
.Push(value => value.RecentEarnings, new CreditEvent
{
OperationId = attemptId,
CreatedUtc = now CreatedUtc = now
}) };
.Set(value => value.UpdatedUtc, now); try { await _ledger.InsertOneAsync(entry); }
return (await _accounts.UpdateOneAsync(filter, update)).ModifiedCount == 1; 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) public async Task<CreditStatusResponse> GetStatusAsync(string userId)
@@ -51,15 +175,187 @@ public sealed class CreditStore
var now = _clock.GetUtcNow().UtcDateTime; var now = _clock.GetUtcNow().UtcDateTime;
await EnsureAndCleanAsync(userId, now); await EnsureAndCleanAsync(userId, now);
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync(); 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, Amount = entry.Amount, ReasonCode = entry.ReasonCode, Description = entry.Description
EarnedInRollingWindow = account.RecentEarnings.Count,
RemainingEarningCapacity = Math.Max(
0, CreditEconomy.RollingEarningLimit - account.RecentEarnings.Count)
}; };
} }
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) private async Task EnsureAndCleanAsync(string userId, DateTime now)
{ {
await _accounts.UpdateOneAsync( await _accounts.UpdateOneAsync(
@@ -67,18 +363,130 @@ public sealed class CreditStore
Builders<UserCreditAccount>.Update Builders<UserCreditAccount>.Update
.SetOnInsert(value => value.UserId, userId) .SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.Credits, 0) .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.RecentEarnings, [])
.SetOnInsert(value => value.Purchases, [])
.SetOnInsert(value => value.Spins, [])
.SetOnInsert(value => value.RecentSpins, [])
.SetOnInsert(value => value.AppliedOperations, [])
.SetOnInsert(value => value.UpdatedUtc, now), .SetOnInsert(value => value.UpdatedUtc, now),
new UpdateOptions { IsUpsert = true }); 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( await _accounts.UpdateOneAsync(
Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) & Builders<UserCreditAccount>.Filter.Eq(value => value.UserId, userId) &
Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false), Builders<UserCreditAccount>.Filter.Exists(nameof(UserCreditAccount.RecentEarnings), false),
Builders<UserCreditAccount>.Update.Set(value => value.RecentEarnings, [])); 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; var cutoff = now - CreditEconomy.EarningWindow;
await _accounts.UpdateOneAsync( await _accounts.UpdateOneAsync(
value => value.UserId == userId, value => value.UserId == userId,
Builders<UserCreditAccount>.Update.PullFilter( Builders<UserCreditAccount>.Update.PullFilter(
value => value.RecentEarnings, value => value.RecentEarnings,
earning => earning.CreatedUtc <= cutoff)); 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 CcsRatingStore _ccsRatingStore;
private readonly IMongoCollection<UserRating> _ratings; private readonly IMongoCollection<UserRating> _ratings;
public static readonly string[] Step3Systems = public static readonly string[] Step3Systems = Step3SystemCatalog.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 DashboardStore( public DashboardStore(
MongoDatabaseProvider mongo, MongoDatabaseProvider mongo,
@@ -69,6 +53,7 @@ public sealed class DashboardStore
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc) public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
{ {
await _reviewQueue.EnsureUserQueueAsync(userId);
var today = nowUtc.Date; var today = nowUtc.Date;
var trendStart = today.AddDays(-29); var trendStart = today.AddDays(-29);
var weekStart = today.AddDays(-6); var weekStart = today.AddDays(-6);
@@ -86,22 +71,25 @@ public sealed class DashboardStore
var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId); var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync(); var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart); var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
var allReviewAttemptsTask = _reviewQueue.GetUserReviewsAsync(userId);
var reviewItemsTask = _reviewQueue.GetUserItemsAsync(userId);
var flashcardCountsTask = _flashcards.GetDashboardCountsAsync(userId, nowUtc, weekStart); var flashcardCountsTask = _flashcards.GetDashboardCountsAsync(userId, nowUtc, weekStart);
var flashcardReviewsTask = _flashcardSchedules.GetReviewsSinceAsync(userId, weekStart); var flashcardReviewsTask = _flashcardSchedules.GetReviewsSinceAsync(userId, weekStart);
var dynamicReviewsTask = _dynamicQuestions.DueReviewsAsync(userId, nowUtc); var dynamicReviewsTask = _dynamicQuestions.DueReviewsAsync(userId, nowUtc);
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, ccsAllAttemptsTask, completedCcsCountTask, await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, ccsAllAttemptsTask, completedCcsCountTask,
simulationsTask, completedSimulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask, simulationsTask, completedSimulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask,
flashcardReviewsTask, dynamicReviewsTask); flashcardReviewsTask, dynamicReviewsTask, allReviewAttemptsTask, reviewItemsTask);
var attempts = attemptsTask.Result; var attempts = attemptsTask.Result;
var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList(); var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList();
var recentAnswered = answeredAttempts.OrderByDescending(value => value.CreatedUtc).Take(100).ToList(); var recentAnswered = answeredAttempts.OrderByDescending(value => value.CreatedUtc).Take(100).ToList();
var previousHundred = answeredAttempts.OrderByDescending(value => value.CreatedUtc).Skip(100).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 ccsAttempts = ccsAttemptsTask.Result;
var simulations = simulationsTask.Result; var simulations = simulationsTask.Result;
var completedCcs = ccsAttempts.Where(value => value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue).ToList(); var completedCcs = ccsAttempts.Where(value => value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue).ToList();
await _reviewQueue.EnsureUserQueueAsync(userId);
var pendingReviewCount = await _reviewQueue.CountPendingAsync(userId); var pendingReviewCount = await _reviewQueue.CountPendingAsync(userId);
var qbankElo = ratingTask.Result.OverallElo; var qbankElo = ratingTask.Result.OverallElo;
@@ -148,7 +136,8 @@ public sealed class DashboardStore
}; };
response.PerformanceTrend = performanceTrend; 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 => response.WeeklyActivity = Enumerable.Range(0, 7).Select(offset =>
{ {
@@ -303,59 +292,10 @@ public sealed class DashboardStore
} }
public static string? NormalizeStep3System(Question question) public static string? NormalizeStep3System(Question question)
{ => Step3SystemCatalog.Normalize(question);
foreach (var candidate in new[] { question.System, question.PerformanceCategory, question.Discipline })
{
var normalized = NormalizeStep3System(candidate);
if (normalized is not null)
return normalized;
}
return null;
}
public static string? NormalizeStep3System(string? value) public static string? NormalizeStep3System(string? value)
{ => Step3SystemCatalog.Normalize(value);
if (string.IsNullOrWhiteSpace(value))
return null;
var exact = Step3Systems.FirstOrDefault(
system => string.Equals(system, value.Trim(), StringComparison.OrdinalIgnoreCase));
if (exact is not null)
return exact;
var lower = value.Trim().ToLowerInvariant();
if (ContainsAny(lower, "human development", "well patient", "preventive", "health maintenance", "geriatr", "age-related"))
return Step3Systems[0];
if (ContainsAny(lower, "social science", "communication", "ethic", "patient safety", "professionalism", "systems-based"))
return Step3Systems[13];
if (ContainsAny(lower, "pregnan", "obstetric", "gynec", "female reproductive", "breast", "puerper"))
return Step3Systems[10];
if (ContainsAny(lower, "renal", "urinary", "male reproductive", "urolog", "nephro"))
return Step3Systems[9];
if (ContainsAny(lower, "biostat", "epidemi", "population health", "medical literature"))
return Step3Systems[12];
if (ContainsAny(lower, "immune", "immun", "blood", "hemat", "lymph", "multisystem", "infectious"))
return Step3Systems[1];
if (ContainsAny(lower, "behavior", "psychi", "mental health"))
return Step3Systems[2];
if (ContainsAny(lower, "nervous", "neuro", "special sense", "ophthalm", "otolaryng", " ent"))
return Step3Systems[3];
if (ContainsAny(lower, "skin", "derm", "subcutaneous"))
return Step3Systems[4];
if (ContainsAny(lower, "musculo", "orthop", "rheumat"))
return Step3Systems[5];
if (ContainsAny(lower, "cardio", "circulatory"))
return Step3Systems[6];
if (ContainsAny(lower, "resp", "pulmon"))
return Step3Systems[7];
if (ContainsAny(lower, "gastro", "digest", "hepato"))
return Step3Systems[8];
if (ContainsAny(lower, "endocr", "metabolic"))
return Step3Systems[11];
return null;
}
private static bool ContainsAny(string value, params string[] fragments) =>
fragments.Any(value.Contains);
private async Task<List<DashboardRecentActivity>> BuildRecentActivityAsync( private async Task<List<DashboardRecentActivity>> BuildRecentActivityAsync(
List<QuestionAttempt> attempts, List<QuestionAttempt> attempts,
@@ -283,7 +283,8 @@ public sealed class DynamicQuestionGenerator
IReadOnlyDictionary<string, string> values) IReadOnlyDictionary<string, string> values)
{ {
var result = new Dictionary<string, WrongAnswerFollowup>(); 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)) if (templates.TryGetValue(distractor.Id, out var template))
{ {
@@ -297,16 +298,18 @@ public sealed class DynamicQuestionGenerator
}; };
continue; continue;
} }
var alternatives = distractorList.Where(value => value.Id != distractor.Id).Take(2).ToArray();
result[distractor.Id] = new WrongAnswerFollowup 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 = Choices =
[ [
new QuestionChoice { Id = correct.Id, Text = Render(correct.TextTemplate, values) }, new QuestionChoice { Id = "A", Text = Render(distractor.ExplanationTemplate, values) },
new QuestionChoice { Id = distractor.Id, Text = Render(distractor.TextTemplate, values) } new QuestionChoice { Id = "B", Text = Render(alternatives[0].ExplanationTemplate, values) },
new QuestionChoice { Id = "C", Text = Render(alternatives[1].ExplanationTemplate, values) }
], ],
CorrectChoiceId = correct.Id, CorrectChoiceId = "A",
TeachingPoint = $"{Render(distractor.ExplanationTemplate, values)} {Render(correct.ExplanationTemplate, values)}" TeachingPoint = Render(distractor.ExplanationTemplate, values)
}; };
} }
return result; return result;
@@ -4,33 +4,35 @@ namespace QuestionsApi.Services;
public static class ExamSimulationScoring public static class ExamSimulationScoring
{ {
public const string ModelVersion = "provisional-1"; public const string ModelVersion = "provisional-2";
private const double PriorMean = 1000.0; private const double PassingAccuracy = 0.60;
private const double PriorSd = 400.0; private const double ScoreIntercept = 50.0;
private const double ScoreScale = 250.0;
public static ExamSimulationScore Calculate( public static ExamSimulationScore Calculate(
IReadOnlyList<ExamSimulationItem> items, IReadOnlyList<ExamSimulationItem> items,
IReadOnlyDictionary<string, ExamSimulationResponse> responses, IReadOnlyDictionary<string, ExamSimulationResponse> responses,
IReadOnlyDictionary<string, Question> questions) 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 correct = 0;
var seen = 0; var seen = 0;
var weightedCorrect = 0.0;
var weightSum = 0.0;
var squaredWeightSum = 0.0;
var categories = new Dictionary<string, ExamSimulationCategoryScore>(); var categories = new Dictionary<string, ExamSimulationCategoryScore>();
foreach (var item in items) foreach (var item in items)
{ {
if (!questions.TryGetValue(item.QuestionExternalId, out var question)) questions.TryGetValue(item.QuestionExternalId, out var question);
continue;
responses.TryGetValue(item.QuestionExternalId, out var response); 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 (isCorrect) correct++;
if (item.WasSeen) seen++; if (item.WasSeen) seen++;
var weight = item.WasSeen ? 0.5 : 1.0; var difficulty = ResolveDifficulty(item, question);
var probability = Expected(ability, item.QuestionElo); var weight = DifficultyWeight(difficulty);
information += weight * probability * (1.0 - probability) * Math.Pow(Math.Log(10.0) / 400.0, 2); weightSum += weight;
squaredWeightSum += weight * weight;
if (isCorrect) weightedCorrect += weight;
if (!categories.TryGetValue(item.BlueprintCategory, out var category)) if (!categories.TryGetValue(item.BlueprintCategory, out var category))
{ {
category = new ExamSimulationCategoryScore(); category = new ExamSimulationCategoryScore();
@@ -40,51 +42,53 @@ public static class ExamSimulationScoring
if (isCorrect) category.Correct++; if (isCorrect) category.Correct++;
} }
var abilitySe = Math.Sqrt(1.0 / information); var rawAccuracy = items.Count == 0 ? 0.0 : (double)correct / items.Count;
var scoreSe = abilitySe * 15.0 / 200.0; var adjustedAccuracy = weightSum == 0.0 ? 0.0 : weightedCorrect / weightSum;
var combinedSe = Math.Sqrt(7.0 * 7.0 + 15.0 * 15.0 + scoreSe * scoreSe); var predicted = ToScore(adjustedAccuracy);
var range = (int)Math.Ceiling(combinedSe); var effectiveSampleSize = squaredWeightSum == 0.0 ? 0.0 : weightSum * weightSum / squaredWeightSum;
var passProbability = NormalCdf((predicted - 200.0) / combinedSe); 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 return new ExamSimulationScore
{ {
PredictedScore = predicted, PredictedScore = predicted,
RangeLow = Math.Clamp(predicted - range, 1, 300), RangeLow = rangeLow,
RangeHigh = Math.Clamp(predicted + range, 1, 300), RangeHigh = rangeHigh,
PassProbability = Math.Round(passProbability, 4), PassProbability = Math.Round(passProbability, 4),
Correct = correct, Correct = correct,
Total = items.Count, Total = items.Count,
SeenCount = seen, SeenCount = seen,
AbilityElo = Math.Round(ability, 1), RawAccuracy = Math.Round(rawAccuracy, 4),
DifficultyAdjustedAccuracy = Math.Round(adjustedAccuracy, 4),
AbilityElo = null,
Categories = categories Categories = categories
}; };
} }
private static double EstimateAbility( public static int ResolveDifficulty(ExamSimulationItem item, Question? question = null)
IReadOnlyList<ExamSimulationItem> items,
IReadOnlyDictionary<string, ExamSimulationResponse> responses,
IReadOnlyDictionary<string, Question> questions)
{ {
var low = 100.0; if (item.QuestionDifficulty is >= 1 and <= 5) return item.QuestionDifficulty;
var high = 1900.0; return question?.Difficulty is >= 1 and <= 5 ? question.Difficulty : 3;
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;
} }
private static double Expected(double ability, int difficulty) => private static double DifficultyWeight(int difficulty) => difficulty switch
1.0 / (1.0 + Math.Pow(10.0, (difficulty - ability) / 400.0)); {
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))); 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 IMongoCollection<Step3Outcome> _outcomes;
private readonly QuestionStore _questions; private readonly QuestionStore _questions;
private readonly QuestionAttemptStore _attempts; private readonly QuestionAttemptStore _attempts;
private readonly CreditStore _credits;
private readonly AnswerStatisticsStore _statistics; private readonly AnswerStatisticsStore _statistics;
private readonly RatingStore _ratings; private readonly RatingStore _ratings;
private readonly ServiceMetrics _metrics; private readonly ServiceMetrics _metrics;
@@ -22,6 +23,7 @@ public class ExamSimulationStore
MongoDatabaseProvider mongo, MongoDatabaseProvider mongo,
QuestionStore questions, QuestionStore questions,
QuestionAttemptStore attempts, QuestionAttemptStore attempts,
CreditStore credits,
AnswerStatisticsStore statistics, AnswerStatisticsStore statistics,
RatingStore ratings, RatingStore ratings,
ServiceMetrics metrics, ServiceMetrics metrics,
@@ -31,6 +33,7 @@ public class ExamSimulationStore
_outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes"); _outcomes = mongo.Database.GetCollection<Step3Outcome>("Step3Outcomes");
_questions = questions; _questions = questions;
_attempts = attempts; _attempts = attempts;
_credits = credits;
_statistics = statistics; _statistics = statistics;
_ratings = ratings; _ratings = ratings;
_metrics = metrics; _metrics = metrics;
@@ -68,7 +71,8 @@ public class ExamSimulationStore
var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList(); var bank = (await _questions.GetPublishedAsync()).Where(question => question.Choices.Count >= 5).ToList();
if (bank.Count < RequiredQuestions) if (bank.Count < RequiredQuestions)
throw new InvalidOperationException($"At least {RequiredQuestions} published questions are required."); 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 elos = await _ratings.GetQuestionElosAsync(bank.Select(question => question.ExternalId));
var targets = BuildTargets(); var targets = BuildTargets();
var selected = SelectForm(bank, seen, targets); var selected = SelectForm(bank, seen, targets);
@@ -87,6 +91,7 @@ public class ExamSimulationStore
Position = offset, Position = offset,
PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)), PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)),
QuestionElo = elos.GetValueOrDefault(question.ExternalId, 1000), QuestionElo = elos.GetValueOrDefault(question.ExternalId, 1000),
QuestionDifficulty = question.Difficulty,
WasSeen = seen.Contains(question.ExternalId), WasSeen = seen.Contains(question.ExternalId),
BlueprintCategory = BlueprintCategory(question) BlueprintCategory = BlueprintCategory(question)
}); });
@@ -124,6 +129,7 @@ public class ExamSimulationStore
Position = PositionWithinBlock(index), Position = PositionWithinBlock(index),
PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)), PresentedChoiceIds = SelectChoices(question, choicePercentages.GetValueOrDefault(question.ExternalId)),
QuestionElo = 1000, QuestionElo = 1000,
QuestionDifficulty = question.Difficulty,
WasSeen = false, WasSeen = false,
BlueprintCategory = BlueprintCategory(question) BlueprintCategory = BlueprintCategory(question)
}).ToList(); }).ToList();
@@ -169,15 +175,22 @@ public class ExamSimulationStore
(item.State == ExamSimulationStates.BetweenBlocks || item.State == ExamSimulationStates.BlockActive)) (item.State == ExamSimulationStates.BetweenBlocks || item.State == ExamSimulationStates.BlockActive))
.SortByDescending(item => item.UpdatedUtc).FirstOrDefaultAsync(); .SortByDescending(item => item.UpdatedUtc).FirstOrDefaultAsync();
public async Task<ExamSimulationScore?> GetLatestCompletedScoreAsync(string userId) => 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; 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) public async Task<ExamSimulation?> GetOwnedAsync(string id, string userId)
{ {
var simulation = await _simulations.Find(item => item.Id == id && item.UserId == userId).FirstOrDefaultAsync(); 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) if (simulation is not null && simulation.State == ExamSimulationStates.BlockActive && simulation.BlockDeadlineUtc <= DateTime.UtcNow)
simulation = await SubmitBlockAsync(simulation, true); simulation = await SubmitBlockAsync(simulation, true);
if (simulation is not null)
simulation = await UpgradeScoringModelAsync(simulation);
return 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 simulations = await _simulations.Find(filter).SortByDescending(item => item.CompletedUtc).Limit(limit + 1).ToListAsync();
var hasMore = simulations.Count > limit; var hasMore = simulations.Count > limit;
if (hasMore) simulations.RemoveAt(simulations.Count - 1); if (hasMore) simulations.RemoveAt(simulations.Count - 1);
await UpgradeScoringModelsAsync(simulations);
return new CompletedExamSimulationPage return new CompletedExamSimulationPage
{ {
Items = simulations.Select(item => new CompletedExamSimulationSummary 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 simulations = await _simulations.Find(filter).Sort(sortDefinition).Limit(limit + 1).ToListAsync();
var hasMore = simulations.Count > limit; var hasMore = simulations.Count > limit;
if (hasMore) simulations.RemoveAt(simulations.Count - 1); if (hasMore) simulations.RemoveAt(simulations.Count - 1);
await UpgradeScoringModelsAsync(simulations.Where(item => item.State == ExamSimulationStates.Completed));
var last = simulations.LastOrDefault(); var last = simulations.LastOrDefault();
return new ExamSimulationHistoryPage return new ExamSimulationHistoryPage
{ {
@@ -333,23 +348,53 @@ public class ExamSimulationStore
if (!item.PresentedChoiceIds.Contains(request.ChoiceId, StringComparer.Ordinal) && request.ChoiceId != "") if (!item.PresentedChoiceIds.Contains(request.ChoiceId, StringComparer.Ordinal) && request.ChoiceId != "")
throw new ArgumentException("Choice is not presented for this question."); throw new ArgumentException("Choice is not presented for this question.");
var response = simulation.Responses.FirstOrDefault(candidate => candidate.QuestionExternalId == request.QuestionExternalId); 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) if (response is null)
{ {
response = new ExamSimulationResponse { QuestionExternalId = request.QuestionExternalId }; response = new ExamSimulationResponse
{
QuestionExternalId = request.QuestionExternalId,
IsSubmitted = false
};
simulation.Responses.Add(response); simulation.Responses.Add(response);
} }
response.ChoiceId = request.ChoiceId; response.ChoiceId = request.ChoiceId;
response.EliminatedChoiceIds = request.EliminatedChoiceIds.Where(item.PresentedChoiceIds.Contains).Distinct().ToList(); response.EliminatedChoiceIds = request.EliminatedChoiceIds.Where(item.PresentedChoiceIds.Contains).Distinct().ToList();
response.Flagged = request.Flagged; response.Flagged = request.Flagged;
response.ElapsedSeconds = Math.Clamp(request.ElapsedSeconds, 0, 1800); response.ElapsedSeconds = Math.Clamp(request.ElapsedSeconds, 0, 1800);
response.IsSubmitted = request.IsSubmitted;
response.UpdatedUtc = DateTime.UtcNow; response.UpdatedUtc = DateTime.UtcNow;
simulation.UpdatedUtc = DateTime.UtcNow; simulation.UpdatedUtc = DateTime.UtcNow;
await ReplaceAsync(simulation); await ReplaceAsync(simulation);
return 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) public async Task<ExamSimulation> SubmitBlockAsync(ExamSimulation simulation, bool expired = false)
{ {
if (IsAlreadySubmittedState(simulation.State))
return simulation;
if (simulation.State != ExamSimulationStates.BlockActive) if (simulation.State != ExamSimulationStates.BlockActive)
throw new InvalidOperationException("There is no active block to submit."); throw new InvalidOperationException("There is no active block to submit.");
if (!expired && simulation.BlockDeadlineUtc is not null) if (!expired && simulation.BlockDeadlineUtc is not null)
@@ -366,6 +411,9 @@ public class ExamSimulationStore
return simulation; return simulation;
} }
public static bool IsAlreadySubmittedState(string state) =>
state is ExamSimulationStates.BetweenBlocks or ExamSimulationStates.Completed;
public async Task AbandonAsync(ExamSimulation simulation) public async Task AbandonAsync(ExamSimulation simulation)
{ {
simulation.State = ExamSimulationStates.Abandoned; simulation.State = ExamSimulationStates.Abandoned;
@@ -433,7 +481,9 @@ public class ExamSimulationStore
{ {
var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId)); var questionMap = await _questions.GetByExternalIdsAsync(simulation.Items.Select(item => item.QuestionExternalId));
var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal); var responses = simulation.Responses.ToDictionary(item => item.QuestionExternalId, StringComparer.Ordinal);
SnapshotDifficulties(simulation, questionMap);
simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap); simulation.Score = ExamSimulationScoring.Calculate(simulation.Items, responses, questionMap);
simulation.ModelVersion = ExamSimulationScoring.ModelVersion;
simulation.State = ExamSimulationStates.Completed; simulation.State = ExamSimulationStates.Completed;
simulation.CompletedUtc = DateTime.UtcNow; simulation.CompletedUtc = DateTime.UtcNow;
simulation.UpdatedUtc = DateTime.UtcNow; simulation.UpdatedUtc = DateTime.UtcNow;
@@ -443,7 +493,10 @@ public class ExamSimulationStore
if (!questionMap.TryGetValue(item.QuestionExternalId, out var question)) continue; if (!questionMap.TryGetValue(item.QuestionExternalId, out var question)) continue;
responses.TryGetValue(item.QuestionExternalId, out var response); responses.TryGetValue(item.QuestionExternalId, out var response);
var choiceId = response?.ChoiceId ?? ""; 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); await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
if (!record.Inserted) continue; if (!record.Inserted) continue;
_metrics.AttemptCreated(); _metrics.AttemptCreated();
@@ -468,6 +521,71 @@ public class ExamSimulationStore
private Task ReplaceAsync(ExamSimulation simulation) => private Task ReplaceAsync(ExamSimulation simulation) =>
_simulations.ReplaceOneAsync(item => item.Id == simulation.Id && item.UserId == simulation.UserId, 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) private static int BlockIndexForPosition(int position)
{ {
var consumed = 0; 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 var firstAttemptIndex = Builders<QuestionAttempt>.IndexKeys
.Ascending(a => a.QuestionExternalId) .Ascending(a => a.QuestionExternalId)
.Ascending(a => a.UserId); .Ascending(a => a.UserId)
.Ascending(a => a.BankCycle);
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>( _col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
firstAttemptIndex, firstAttemptIndex,
new CreateIndexOptions { Unique = true })); new CreateIndexOptions { Unique = true, Name = "uq_question_user_cycle" }));
var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId); var questionIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.QuestionExternalId);
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex)); _col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(questionIndex));
@@ -38,12 +39,14 @@ public class QuestionAttemptStore
bool isCorrect, bool isCorrect,
bool isSkip = false, bool isSkip = false,
IReadOnlyCollection<string>? presentedChoiceIds = null, IReadOnlyCollection<string>? presentedChoiceIds = null,
int durationSeconds = 0) int durationSeconds = 0,
int bankCycle = 0)
{ {
var attempt = new QuestionAttempt var attempt = new QuestionAttempt
{ {
QuestionExternalId = questionExternalId, QuestionExternalId = questionExternalId,
UserId = userId, UserId = userId,
BankCycle = bankCycle,
ChoiceId = choiceId, ChoiceId = choiceId,
PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [], PresentedChoiceIds = presentedChoiceIds?.ToList() ?? [],
TracksPresentation = presentedChoiceIds is { Count: > 0 }, TracksPresentation = presentedChoiceIds is { Count: > 0 },
@@ -63,7 +66,8 @@ public class QuestionAttemptStore
catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey)
{ {
var existing = await _col var existing = await _col
.Find(a => a.QuestionExternalId == questionExternalId && a.UserId == userId) .Find(a => a.QuestionExternalId == questionExternalId &&
a.UserId == userId && a.BankCycle == bankCycle)
.FirstAsync(); .FirstAsync();
return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing }; return new QuestionAttemptRecordResult { Inserted = false, Attempt = existing };
} }
@@ -75,11 +79,17 @@ public class QuestionAttemptStore
Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc)) Builders<QuestionAttempt>.Filter.Gte(attempt => attempt.CreatedUtc, sinceUtc))
.ToListAsync(); .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( _col.Distinct(
attempt => attempt.QuestionExternalId, attempt => attempt.QuestionExternalId,
Builders<QuestionAttempt>.Filter.Eq(attempt => attempt.UserId, userId)) filter)
.ToListAsync(); .ToListAsync();
}
public IMongoCollection<QuestionAttempt> Collection => _col; public IMongoCollection<QuestionAttempt> Collection => _col;
+21 -4
View File
@@ -21,6 +21,10 @@ public class QuestionStore
_col.Indexes.CreateOne(new CreateIndexModel<Question>(systemIndex)); _col.Indexes.CreateOne(new CreateIndexModel<Question>(systemIndex));
var tagsIndex = Builders<Question>.IndexKeys.Ascending(q => q.Tags); var tagsIndex = Builders<Question>.IndexKeys.Ascending(q => q.Tags);
_col.Indexes.CreateOne(new CreateIndexModel<Question>(tagsIndex)); _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) 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 filter = filters.Count == 0 ? builder.Empty : builder.And(filters);
var limit = Math.Clamp(req.Limit, 1, 80); var limit = Math.Clamp(req.Limit, 1, 80);
return await _col.Aggregate() if (string.IsNullOrWhiteSpace(req.Step3System))
.Match(filter) return await _col.Aggregate().Match(filter).Sample(limit).ToListAsync();
.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) => public async Task<Question?> GetByIdAsync(string id) =>
@@ -280,6 +292,11 @@ public class QuestionStore
ChoiceSelectionPercentages = req.ChoiceSelectionPercentages, ChoiceSelectionPercentages = req.ChoiceSelectionPercentages,
Discipline = req.Discipline.Trim(), Discipline = req.Discipline.Trim(),
System = req.System.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), PerformanceCategory = RatingCategory.Normalize(req.PerformanceCategory, req.System, req.Discipline),
Difficulty = req.Difficulty, Difficulty = req.Difficulty,
Labs = req.Labs, Labs = req.Labs,
@@ -49,6 +49,21 @@ public static class QuestionValidator
if (!entry.Value.Choices.Any(choice => choice.Id == entry.Value.CorrectChoiceId)) if (!entry.Value.Choices.Any(choice => choice.Id == entry.Value.CorrectChoiceId))
return "wrongAnswerFollowups correctChoiceId must match a follow-up choice id"; 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 is null) return "reviewFollowups must be an array";
if (req.ReviewFollowups.Count > 10) return "questions support at most ten review follow-ups"; if (req.ReviewFollowups.Count > 10) return "questions support at most ten review follow-ups";
@@ -75,6 +90,16 @@ public static class QuestionValidator
return null; 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) public static string? ValidateLabs(IReadOnlyCollection<QuestionLab>? labs, bool requireComplete)
{ {
if (labs is null) if (labs is null)
+66 -28
View File
@@ -46,15 +46,20 @@ public sealed class ReviewQueueStore
var update = Builders<ReviewQueueItem>.Update var update = Builders<ReviewQueueItem>.Update
.SetOnInsert(value => value.UserId, attempt.UserId) .SetOnInsert(value => value.UserId, attempt.UserId)
.SetOnInsert(value => value.QuestionExternalId, attempt.QuestionExternalId) .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.Cycle, 1)
.SetOnInsert(value => value.CreatedUtc, attempt.CreatedUtc) .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 try
{ {
await _items.UpdateOneAsync( await _items.UpdateOneAsync(
value => value.UserId == attempt.UserId && value.QuestionExternalId == attempt.QuestionExternalId, filter,
update, update,
new UpdateOptions { IsUpsert = true }); new UpdateOptions { IsUpsert = true });
} }
@@ -66,26 +71,36 @@ public sealed class ReviewQueueStore
public async Task EnsureUserQueueAsync(string userId) public async Task EnsureUserQueueAsync(string userId)
{ {
var checkpoint = await _backfills.Find(value => value.UserId == userId).FirstOrDefaultAsync();
var scanThroughUtc = DateTime.UtcNow; var scanThroughUtc = DateTime.UtcNow;
var filter = Builders<QuestionAttempt>.Filter.Where(value => value.UserId == userId && !value.IsCorrect && !value.IsSkip); 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); 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) if (misses.Count > 0)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var writes = misses.Select(attempt => new UpdateOneModel<ReviewQueueItem>( 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 Builders<ReviewQueueItem>.Update
.SetOnInsert(value => value.UserId, userId) .SetOnInsert(value => value.UserId, userId)
.SetOnInsert(value => value.QuestionExternalId, attempt.QuestionExternalId) .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.Cycle, 1)
.SetOnInsert(value => value.CreatedUtc, attempt.CreatedUtc) .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(); { IsUpsert = true }).ToList();
try try
{ {
@@ -115,27 +130,50 @@ public sealed class ReviewQueueStore
public Task<long> CountPendingAsync(string userId) => public Task<long> CountPendingAsync(string userId) =>
_items.CountDocumentsAsync(value => value.UserId == userId && value.State == ReviewQueueStates.Pending); _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(); 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) var question = await _questions.GetByExternalIdAsync(candidate.QuestionExternalId);
& Builders<ReviewQueueItem>.Filter.Eq(value => value.State, ReviewQueueStates.Pending); if (question is { IsPublished: true, FlaggedForReviewUtc: null })
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 (requestedSystem is null || Step3SystemCatalog.Normalize(question) == requestedSystem) return candidate;
if (question is { IsPublished: true, FlaggedForReviewUtc: null }) 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));
} }
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; 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> _dynamicAttempts = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicCorrect = new(); private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dynamicCorrect = new();
private long _dynamicTargetGapTotal; 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 AttemptCreated() => Interlocked.Increment(ref _attemptsCreated);
public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates); public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates);
@@ -43,6 +55,21 @@ public sealed class ServiceMetrics
_dynamicAttempts.AddOrUpdate(tier, 1, (_, value) => value + 1); _dynamicAttempts.AddOrUpdate(tier, 1, (_, value) => value + 1);
if (correct) _dynamicCorrect.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() public string RenderPrometheus()
{ {
@@ -63,6 +90,36 @@ public sealed class ServiceMetrics
AppendLabeledSample(output, "step3_dynamic_correct_total", tier, _dynamicCorrect.GetValueOrDefault(tier)); AppendLabeledSample(output, "step3_dynamic_correct_total", tier, _dynamicCorrect.GetValueOrDefault(tier));
} }
AppendCounter(output, "step3_dynamic_target_gap_points_total", Interlocked.Read(ref _dynamicTargetGapTotal)); 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.AppendLine("# TYPE step3_http_request_duration_seconds summary");
output.Append("step3_http_request_duration_seconds_count ") output.Append("step3_http_request_duration_seconds_count ")
.Append(Interlocked.Read(ref _httpRequests)) .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(); 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", "ExternalIdentities", "ExternalAuthTransactions",
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs", "FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews", "FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
"UserCreditAccounts" "UserCreditAccounts", "CreditLedgerEntries", "StudySessions", "DailyMissionSets", "WeeklyGoals",
"UserProgressions", "ProgressionWeeklySnapshots"
]; ];
public UserProfileStore(MongoDatabaseProvider mongo) 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; }
}
}
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
+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
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dd00eb70k5wbn"
path="res://.godot/imported/slot_gem.png-2012a867d096c962431a19f1d3e3a5c9.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/slot_machine/slot_gem.png"
dest_files=["res://.godot/imported/slot_gem.png-2012a867d096c962431a19f1d3e3a5c9.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: 98 KiB

+40
View File
@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dqmvdujjawr5f"
path="res://.godot/imported/slot_gift.png-8717ecd0f5473181fb969b2aa00068db.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/slot_machine/slot_gift.png"
dest_files=["res://.godot/imported/slot_gift.png-8717ecd0f5473181fb969b2aa00068db.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: 89 KiB

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

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://fsejopj5yup5"
path="res://.godot/imported/slot_shield.png-f53f74d032415edb239232a0bab0bef4.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://assets/slot_machine/slot_shield.png"
dest_files=["res://.godot/imported/slot_shield.png-f53f74d032415edb239232a0bab0bef4.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
+55
View File
@@ -47,6 +47,61 @@
"simulatedStartUtc": "2026-07-15T08:00:00Z", "simulatedStartUtc": "2026-07-15T08:00:00Z",
"initialOrderCodes": [] "initialOrderCodes": []
}, },
"initialInterview": {
"startNodeId": "greeting",
"nodes": [
{
"id": "greeting",
"patientText": "Doctor, I feel awful. I can barely keep my eyes open.",
"isTerminal": false,
"choices": [
{ "id": "ask_chief_concern", "text": "Tell me what brought you in today.", "nextNodeId": "chief_concern" },
{ "id": "ask_current_problem", "text": "What has been happening recently?", "nextNodeId": "chief_concern" }
]
},
{
"id": "chief_concern",
"patientText": "I have been vomiting for two days, my stomach hurts, and I have become more and more tired.",
"isTerminal": false,
"choices": [
{ "id": "ask_insulin_use", "text": "Have you been taking your insulin?", "nextNodeId": "insulin_history" },
{ "id": "ask_diabetes_treatment", "text": "How have you managed your diabetes during this illness?", "nextNodeId": "insulin_history" }
]
},
{
"id": "insulin_history",
"patientText": "I stopped all of my insulin about a day and a half ago because I was not eating.",
"isTerminal": false,
"choices": [
{ "id": "ask_associated_symptoms", "text": "Have you noticed any other symptoms?", "nextNodeId": "associated_symptoms" },
{ "id": "ask_infection_symptoms", "text": "Any fever, cough, urinary symptoms, or diarrhea?", "nextNodeId": "associated_symptoms" }
]
},
{
"id": "associated_symptoms",
"patientText": "No cough, burning with urination, or diarrhea. I do not think I have had a fever.",
"isTerminal": false,
"choices": [
{ "id": "ask_medical_history", "text": "Tell me about your medical history and medications.", "nextNodeId": "background" },
{ "id": "ask_allergies", "text": "Do you have kidney problems, take other medicines, or have medication allergies?", "nextNodeId": "background" }
]
},
{
"id": "background",
"patientText": "I have type 1 diabetes and normally use long-acting and mealtime insulin. I have no kidney disease and no known drug allergies.",
"isTerminal": false,
"choices": [
{ "id": "finish_interview", "text": "Thank you. I am going to examine you and begin your evaluation.", "nextNodeId": "interview_complete" }
]
},
{
"id": "interview_complete",
"patientText": "Okay, doctor.",
"isTerminal": true,
"choices": []
}
]
},
"allowedLocations": ["emergency_department", "icu", "ward"], "allowedLocations": ["emergency_department", "icu", "ward"],
"exams": [ "exams": [
{ "code": "general_exam", "name": "General and hydration examination", "durationMinutes": 1, "finding": "Lethargic but arousable; dry mucous membranes, delayed capillary refill, and poor skin turgor." }, { "code": "general_exam", "name": "General and hydration examination", "durationMinutes": 1, "finding": "Lethargic but arousable; dry mucous membranes, delayed capillary refill, and poor skin turgor." },
File diff suppressed because it is too large Load Diff
+3
View File
@@ -7,6 +7,7 @@ Use `content/ccs/dka-emergency-001.json` as the canonical example. Set `validate
## Case structure ## Case structure
- `initialChart` defines the opening history, appearance, vitals, simulated start, and standing orders. Use ordered `historySections` (`code`, `title`, `text`) for the HPI, medical history, medications, allergies, family/social history, and review of systems. The legacy `history` field remains the fallback. - `initialChart` defines the opening history, appearance, vitals, simulated start, and standing orders. Use ordered `historySections` (`code`, `title`, `text`) for the HPI, medical history, medications, allergies, family/social history, and review of systems. The legacy `history` field remains the fallback.
- `initialInterview` optionally defines the graphical office interview. It contains a `startNodeId` and an acyclic set of dialogue nodes. Each node has stable `id`, authored `patientText`, `isTerminal`, and clinician `choices` with globally unique IDs and valid `nextNodeId` references. Every branch must reach a terminal node.
- `allowedLocations` controls patient movement. - `allowedLocations` controls patient movement.
- `exams` defines selectable examination components and their simulated duration. - `exams` defines selectable examination components and their simulated duration.
- Catalog orders may define `displayRoute`, `displayFrequency`, and `resultSection`. These are presentation defaults, not user-entered dosing instructions. - Catalog orders may define `displayRoute`, `displayFrequency`, and `resultSection`. These are presentation defaults, not user-entered dosing instructions.
@@ -23,4 +24,6 @@ Valid result sections are `lab`, `imaging`, `other_test`, and `treatment`; progr
New CCS attempts are timed. `realTimeMinutes` is the total wall-clock allowance: the runtime reserves its final two minutes for final orders, so a 20-minute case has 18 minutes of active management plus two minutes of case-end orders. Opening dialogs count against this limit and reconnecting does not pause it. New CCS attempts are timed. `realTimeMinutes` is the total wall-clock allowance: the runtime reserves its final two minutes for final orders, so a 20-minute case has 18 minutes of active management plus two minutes of case-end orders. Opening dialogs count against this limit and reconnecting does not pause it.
Graphical interview choices are persisted as attempt actions and support reconnect/resume. They consume real deadline time but do not advance simulated time or contribute directly to scoring. Cases without `initialInterview` remain available in classic CCS and are omitted from CCS Cases 2.
Only the first completed timed attempt for each distinct case contributes to the CCS estimate. Legacy practice attempts remain reviewable but cannot be newly created. The combined provisional prediction uses the latest Day 1 simulation and a separately reported CCS estimate; it is not presented as an official USMLE scoring formula. Only the first completed timed attempt for each distinct case contributes to the CCS estimate. Legacy practice attempts remain reviewable but cannot be newly created. The combined provisional prediction uses the latest Day 1 simulation and a separately reported CCS estimate; it is not presented as an official USMLE scoring formula.
+213
View File
@@ -0,0 +1,213 @@
# Question bank gamification
## Objective
Make studying easy to begin, satisfying to continue, and valuable to repeat while
optimizing for durable learning rather than compulsive use. The core engagement
loop is:
```text
Answer -> immediate insight -> visible progress -> useful reward -> clear next goal
```
Success means learners return consistently, improve weak areas, complete spaced
reviews, and become more exam-ready. Raw time in the application is not itself a
success metric.
## Product principles
- Reward demonstrated learning, recovery from errors, and breadth of coverage.
- Make reward rules understandable and resistant to farming.
- Give every study session a user-selected finish line and satisfying conclusion.
- Keep stopping controls as prominent as continuation controls.
- Do not lock core questions, explanations, or remediation behind RZ Coin.
- Prefer weekly consistency over an indefinitely escalating daily streak.
- Keep social participation optional and private by default.
- Never use paid randomized rewards, near-miss effects, disguised odds, or
loss-heavy messaging.
## Mastery paths
Show mastery for each system, topic, and tested concept. Mastery should combine:
- First-attempt correctness
- Question difficulty
- Recency and spaced-review status
- Successful recovery after an incorrect answer
- Breadth across related concepts
Mastery must not be a reskinned count of questions answered. Each topic should
show its current state, evidence behind that state, and the next useful action.
Suggested states are `Unseen`, `Learning`, `Practicing`, `Strong`, and `Mastered`.
The dashboard should highlight progress in weak areas and recently mastered
concepts. A system cannot reach its highest rank by repeatedly farming a narrow
set of easy questions.
## RZ Coin economy
Award RZ Coin for learning behaviors:
- A first correct attempt
- Correctly answering a genuinely difficult question
- Completing a scheduled review
- Converting a previously missed concept into a correct delayed review
- Improving a weak topic
- Completing a timed or simulated-exam block
- Meeting a balanced daily or weekly study goal
Do not primarily reward elapsed time, retries, repeated easy questions, or raw
volume. Apply diminishing or zero rewards to repeated content where necessary.
All awards should be server-authoritative, idempotent, and recorded in the coin
ledger with a reason code.
Appropriate purchases include:
- Question-bank reset tokens
- Cosmetic themes, avatars, profile frames, titles, and study environments
- Optional analytics presentations
- Limited streak protection
- Collectible medical artifacts and achievement displays
Purchases must disclose their exact price and effect. Randomized paid purchases
and purchasable power over exam scoring or question correctness are prohibited.
## Daily and weekly missions
Offer three short daily missions that reflect the learner's needs, for example:
- Complete 10 scheduled reviews.
- Answer five difficulty-4 questions.
- Improve one weak system.
- Complete a timed block without skipping.
Allow a learner to replace a mission that does not fit the current study plan.
Missions should not require excessive daily volume or encourage answering after
fatigue has clearly degraded performance.
Weekly goals should emphasize consistency and breadth, such as studying on four
days, reviewing three systems, or clearing a reasonable number of overdue
reviews. A flexible weekly target is preferred to an endless daily streak.
## Session goals and completion
At the beginning of a session, let the learner choose a finish line:
- A number of questions
- A study duration
- One weak topic
- One simulated-exam block
- All currently due reviews, subject to a reasonable cap
At the finish line, present a completion screen before offering more work. The
primary actions should give equal visual weight to `Finish for today` and a
clearly sized optional next block. Do not create artificial unfinished states to
pressure continuation.
The end-of-session summary should include:
- Questions completed and first-attempt accuracy
- Concepts newly mastered
- Previously missed concepts recovered
- Weak areas improved
- RZ Coin earned, with award reasons
- Readiness change, with an explanation of how it was calculated
- A recommended next session
## Progression and personal records
Introduce skill-based ranks such as `Intern`, `Resident`, `Chief`, and
`Attending`. Rank advancement should require mastery across several systems,
successful delayed reviews, and performance on appropriately difficult items.
It must not depend only on account age or total answers.
Personal records should measure meaningful improvement:
- Best timed-block accuracy at a defined difficulty mix
- Longest first-attempt correct streak at difficulty 4 or higher
- Most concepts mastered in a week
- Largest reduction in overdue reviews
- Greatest improvement in a previously weak content area
Record definitions must remain stable and visible so the learner understands
what was achieved.
## Social features
Potential opt-in features include private study groups, cooperative weekly
goals, friend challenges, and anonymous percentile bands. Prefer cooperative
goals over leaderboards based on raw volume.
Do not expose failure, study time, predicted score, or inactivity publicly by
default. Users must be able to leave a group, hide a statistic, and disable
social notifications.
## Notifications and streak safeguards
- Let users choose notification categories, quiet hours, and frequency.
- Use factual reminders rather than guilt, urgency, or threatened loss.
- Allow recovery days and planned breaks.
- Cap streak-protection use and describe it transparently.
- Never send repeated prompts after a learner chooses to finish a session.
- Detect unusually long sessions and offer a neutral break reminder.
## Recommended delivery order
### Phase 1: foundation
- [x] Define the mastery model and expose topic-level progress.
- [x] Add server-authoritative RZ Coin awards for approved learning events.
- [x] Add user-selected session goals and an end-of-session summary.
- [x] Add daily missions with one replacement per day.
### Phase 2: progression
1. [x] Add weekly consistency goals.
2. [x] Add breadth-based ranks and personal records.
3. Expand the RZ Coin store with cosmetic and utility items.
4. Add limited streak protection and recovery days.
### Phase 3: social and optimization
1. Add private cooperative groups and friend challenges.
2. Personalize missions using weak-area and spaced-review data.
3. Experiment with presentation and pacing under the guardrails below.
## Measurement
Primary metrics should measure learning and healthy retention:
- Weekly active learners completing at least one meaningful study goal
- Scheduled-review completion rate
- Delayed retention after 7, 21, and 45 days
- Recovery rate for previously missed concepts
- Breadth of systems studied
- Timed-block and simulation improvement
- Percentage of sessions ending at the learner's selected finish line
Monitor guardrail metrics alongside product experiments:
- Extremely long sessions and late-night continuation
- Rapid repetitive answering consistent with coin farming
- Notification opt-out and mute rates
- Mission abandonment after oversized goals
- Increased accuracy volatility associated with fatigue
- Support reports involving pressure, anxiety, or confusing rewards
Do not optimize an experiment solely for session length, questions per session,
notification opens, or coin spending. A change should improve at least one
learning or healthy-retention metric without materially degrading the guardrails.
## Initial acceptance criteria
The first release is complete when:
- Mastery is computed from correctness, difficulty, recency, and review outcomes.
- Coin awards are idempotent and cannot be earned repeatedly from the same event.
- Every award and purchase has a ledger entry and visible reason.
- A learner can set and complete a bounded session goal.
- The completion screen offers both stopping and continuing without deceptive
emphasis.
- Daily missions target due reviews or genuine coverage gaps and can be replaced.
- Core educational content remains available without coin purchases.
- Product analytics include the primary and guardrail metrics above.

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