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