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 | ||
|
|
9402a5d6de | ||
|
|
a58aa401c8 | ||
|
|
bf56f4192f | ||
|
|
18802f4e8d | ||
|
|
0676c7f95e | ||
|
|
09502944ec | ||
|
|
3fd670d111 | ||
|
|
0bd74fcabf | ||
|
|
1bc7fbfb3c | ||
|
|
bcb411bed8 | ||
|
|
1860e6ede6 | ||
|
|
4909cf0b71 | ||
|
|
a162ce31c6 | ||
|
|
19371b7a93 | ||
|
|
da641a8d1d | ||
|
|
f39bfb42be | ||
|
|
c14ac1d65b | ||
|
|
f082be38e0 | ||
|
|
845907e825 | ||
|
|
89dbe3e31a | ||
|
|
dd203b24f9 | ||
|
|
6f41906101 | ||
|
|
efae7e0b2b | ||
|
|
05a5298ed1 | ||
|
|
b511d27a20 | ||
|
|
22b0df7aed | ||
|
|
63fa073530 | ||
|
|
41731f5804 | ||
|
|
e3e18f6415 | ||
|
|
9f836c8765 | ||
|
|
86ae650272 | ||
|
|
23d573e221 | ||
|
|
548b519d44 | ||
|
|
1ff2d612a4 | ||
|
|
1d5d03a4f6 | ||
|
|
efe60f36c5 | ||
|
|
f680024dc3 | ||
|
|
94e0e5dd70 | ||
|
|
74398133f7 | ||
|
|
447f080552 | ||
|
|
ef44347bf6 | ||
|
|
4251885a73 | ||
|
|
34d283e213 | ||
|
|
500477b612 | ||
|
|
46953ee71a | ||
|
|
c0252a916d | ||
|
|
aa5497c516 | ||
|
|
6b1e41c187 | ||
|
|
eb9e018339 | ||
|
|
bff16cea98 | ||
|
|
5a2d825181 | ||
|
|
7e977e0d30 | ||
|
|
ba8e0ec256 | ||
|
|
7be83661f9 | ||
|
|
bb9f59b3cc | ||
|
|
854c887a1a | ||
|
|
ce4db7cec8 | ||
|
|
191aa685fa | ||
|
|
81e543fdf5 | ||
|
|
65ae99e2ce | ||
|
|
b7b290c63b | ||
|
|
c1a71b1192 | ||
|
|
ca218a6ae5 | ||
|
|
f3a2e65627 | ||
|
|
bca31bd142 | ||
|
|
0e473343d0 | ||
|
|
6df0a88fd4 | ||
|
|
118c215608 | ||
|
|
cd1648fe38 | ||
|
|
f1b290aa73 | ||
|
|
50f54b1bec | ||
|
|
62e65fd22c | ||
|
|
5197234210 | ||
|
|
576b48d581 | ||
|
|
d073f82a76 | ||
|
|
a94cc5188f | ||
|
|
0902cc238a | ||
|
|
974de67032 | ||
|
|
7fb5f8fe78 | ||
|
|
ee57dbc3e7 |
@@ -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
|
||||
|
||||
@@ -4,6 +4,10 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'tools/**'
|
||||
- 'tests/**'
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -4,6 +4,10 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'tools/**'
|
||||
- 'tests/*.gd'
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Promote UAT content to PRD
|
||||
name: Promote ready UAT questions to PRD
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: step3-content-promotion
|
||||
group: step3-question-promotion
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
@@ -45,26 +45,26 @@ jobs:
|
||||
test "$CONFIRMATION" = "PROMOTE_TO_PRD"
|
||||
fi
|
||||
|
||||
- name: Configure promotion credentials
|
||||
- name: Configure question promotion credentials
|
||||
env:
|
||||
SOURCE_CONNECTION: ${{ secrets.UAT_MONGODB_CONNECTION_STRING }}
|
||||
TARGET_CONNECTION: ${{ secrets.PRD_MONGODB_CONNECTION_STRING }}
|
||||
run: |
|
||||
test -n "$SOURCE_CONNECTION"
|
||||
test -n "$TARGET_CONNECTION"
|
||||
kubectl create secret generic step3-content-promotion \
|
||||
kubectl create secret generic step3-question-promotion \
|
||||
--from-literal=source-connection="$SOURCE_CONNECTION" \
|
||||
--from-literal=target-connection="$TARGET_CONNECTION" \
|
||||
--dry-run=client -o yaml | kubectl apply -n step3-prd -f -
|
||||
|
||||
- name: Run promotion
|
||||
- name: Run ready-question promotion
|
||||
env:
|
||||
MODE: ${{ inputs.mode }}
|
||||
CONFIRMATION: ${{ inputs.confirmation }}
|
||||
run: |
|
||||
IMAGE="$(kubectl get deployment step3-api -n step3-prd -o jsonpath='{.spec.template.spec.containers[0].image}')"
|
||||
REVISION="${IMAGE##*:}"
|
||||
POD="content-promotion-${GITHUB_RUN_NUMBER}"
|
||||
POD="question-promotion-${GITHUB_RUN_NUMBER}"
|
||||
APPLY_ARGS=""
|
||||
if [ "$MODE" = "apply" ]; then
|
||||
APPLY_ARGS="--apply --confirm $CONFIRMATION"
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
name: ${POD}
|
||||
namespace: step3-prd
|
||||
labels:
|
||||
app: step3-content-promotion
|
||||
app: step3-question-promotion
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
command: ["/bin/sh", "-c"]
|
||||
args:
|
||||
- >-
|
||||
dotnet QuestionsApi.dll promote-content
|
||||
dotnet QuestionsApi.dll promote-questions
|
||||
--source-database ranaze_step3_uat
|
||||
--target-database ranaze_step3_prd
|
||||
--operator "${GITHUB_ACTOR:-gitea-operator}"
|
||||
@@ -96,12 +96,12 @@ jobs:
|
||||
- name: SOURCE_MONGODB_CONNECTION_STRING
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: step3-content-promotion
|
||||
name: step3-question-promotion
|
||||
key: source-connection
|
||||
- name: TARGET_MONGODB_CONNECTION_STRING
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: step3-content-promotion
|
||||
name: step3-question-promotion
|
||||
key: target-connection
|
||||
EOF
|
||||
for attempt in $(seq 1 120); do
|
||||
@@ -115,6 +115,5 @@ jobs:
|
||||
- name: Remove promotion credentials and pod
|
||||
if: always()
|
||||
run: |
|
||||
kubectl delete pod "content-promotion-${GITHUB_RUN_NUMBER}" -n step3-prd --ignore-not-found
|
||||
kubectl delete secret step3-content-promotion -n step3-prd --ignore-not-found
|
||||
|
||||
kubectl delete pod "question-promotion-${GITHUB_RUN_NUMBER}" -n step3-prd --ignore-not-found
|
||||
kubectl delete secret step3-question-promotion -n step3-prd --ignore-not-found
|
||||
|
||||
@@ -57,9 +57,9 @@ The hostname change creates a new browser origin, so testers should expect to si
|
||||
|
||||
1. Create and protect the `production` branch, then merge the tested `main` revision.
|
||||
2. Route `step3.ranaze.com` and `s3q.ranaze.com` to PRD NodePorts.
|
||||
3. Run **Promote UAT content to PRD** in `dry-run` mode and inspect the counts and hash.
|
||||
3. Run **Promote ready UAT questions to PRD** in `dry-run` mode and inspect the insert, update, unchanged, extra, media, and hash values.
|
||||
4. Run it again in `apply` mode with confirmation `PROMOTE_TO_PRD`.
|
||||
5. Verify `ranaze_step3_prd` contains published content and has no user/activity documents.
|
||||
5. Verify `ranaze_step3_prd` contains the published, unflagged UAT questions and has no user/activity documents.
|
||||
6. Register the initial PRD account normally, then promote it manually:
|
||||
|
||||
```javascript
|
||||
@@ -69,14 +69,49 @@ The hostname change creates a new browser origin, so testers should expect to si
|
||||
|
||||
7. Configure and test the live Stripe webhook and Steam callback before announcing PRD.
|
||||
|
||||
## Content promotion behavior
|
||||
## Question promotion behavior
|
||||
|
||||
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only published questions, referenced media, current published CCS revisions, and the CCS order catalog. It never copies users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
|
||||
The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. Any promoted question containing media is flagged again in PRD and remains hidden from students until a SUPER user explicitly approves it in production. UAT media approval is not treated as production approval. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations.
|
||||
|
||||
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Every applied promotion writes a `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
|
||||
Promotion is idempotent and does not delete or implicitly unpublish PRD content. Its dry run reports inserts, updates, unchanged questions, and production extras. Apply mode verifies the promoted target records and writes a scoped `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation.
|
||||
|
||||
## Verification and rollback
|
||||
|
||||
## 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()
|
||||
{
|
||||
@@ -148,6 +175,45 @@ public sealed class ChoicePoolTests
|
||||
Assert.Null(QuestionValidator.Validate(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishedQuestionRequiresCompleteLabReferenceRanges()
|
||||
{
|
||||
var request = BuildRequest(5);
|
||||
request.Labs =
|
||||
[
|
||||
new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" }
|
||||
];
|
||||
|
||||
Assert.Contains("requires a reference range", QuestionValidator.Validate(request));
|
||||
|
||||
request.Labs[0].Reference = "136–145 mEq/L";
|
||||
Assert.Null(QuestionValidator.Validate(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DraftQuestionMaySaveLabWithoutReferenceRange()
|
||||
{
|
||||
var request = BuildRequest(5);
|
||||
request.IsPublished = false;
|
||||
request.Labs =
|
||||
[
|
||||
new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" }
|
||||
];
|
||||
|
||||
Assert.Null(QuestionValidator.Validate(request));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LabEntriesAlwaysRequireNamesAndValues()
|
||||
{
|
||||
var request = BuildRequest(5);
|
||||
request.Labs = [new QuestionLab { Name = "", Value = "128", Reference = "136–145 mEq/L" }];
|
||||
Assert.Contains("lab name and value", QuestionValidator.Validate(request));
|
||||
|
||||
request.Labs = [new QuestionLab { Name = "Sodium", Value = "", Reference = "136–145 mEq/L" }];
|
||||
Assert.Contains("lab name and value", QuestionValidator.Validate(request));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://example.org/guideline", "HTTPS")]
|
||||
[InlineData("https://example.org/guideline", null)]
|
||||
@@ -236,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",
|
||||
|
||||
@@ -31,4 +31,167 @@ public sealed class ContentPromotionTests
|
||||
ContentPromotionCommand.HashDocuments([first, second]),
|
||||
ContentPromotionCommand.HashDocuments([second, first]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReadyQuestionEligibilityRequiresPublishedAndUnflagged()
|
||||
{
|
||||
Assert.True(QuestionPromotionCommand.IsEligible(new BsonDocument
|
||||
{
|
||||
["IsPublished"] = true,
|
||||
["FlaggedForReviewUtc"] = BsonNull.Value
|
||||
}));
|
||||
Assert.True(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", true)));
|
||||
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument
|
||||
{
|
||||
["IsPublished"] = true,
|
||||
["FlaggedForReviewUtc"] = DateTime.UtcNow
|
||||
}));
|
||||
Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", false)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionPromotionFlagsMediaForSeparateApproval()
|
||||
{
|
||||
var updated = new DateTime(2026, 7, 30, 12, 0, 0, DateTimeKind.Utc);
|
||||
var source = new BsonDocument
|
||||
{
|
||||
["ExternalId"] = "media-question",
|
||||
["IsPublished"] = true,
|
||||
["FlaggedForReviewUtc"] = BsonNull.Value,
|
||||
["UpdatedUtc"] = updated,
|
||||
["Media"] = new BsonArray { ObjectId.GenerateNewId().ToString() }
|
||||
};
|
||||
|
||||
var prepared = QuestionPromotionCommand.PrepareForProduction(source);
|
||||
|
||||
Assert.Equal(updated, prepared["FlaggedForReviewUtc"].ToUniversalTime());
|
||||
Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewReason, prepared["FlagReason"].AsString);
|
||||
Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewActor, prepared["FlaggedByUserId"].AsString);
|
||||
Assert.True(source["FlaggedForReviewUtc"].IsBsonNull);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProductionPromotionLeavesMediaFreeQuestionApprovalStateAlone()
|
||||
{
|
||||
var source = new BsonDocument
|
||||
{
|
||||
["ExternalId"] = "text-question",
|
||||
["IsPublished"] = true,
|
||||
["FlaggedForReviewUtc"] = BsonNull.Value,
|
||||
["Media"] = new BsonArray()
|
||||
};
|
||||
|
||||
var prepared = QuestionPromotionCommand.PrepareForProduction(source);
|
||||
|
||||
Assert.True(prepared["FlaggedForReviewUtc"].IsBsonNull);
|
||||
Assert.False(prepared.Contains("FlagReason"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuestionMediaIdsSupportStoredApiUrlsAndRawIds()
|
||||
{
|
||||
var first = ObjectId.GenerateNewId();
|
||||
var second = ObjectId.GenerateNewId();
|
||||
var questions = new[]
|
||||
{
|
||||
new BsonDocument
|
||||
{
|
||||
["ExternalId"] = "one",
|
||||
["Media"] = new BsonArray { $"/api/questions/media/{first}", second.ToString() }
|
||||
},
|
||||
new BsonDocument
|
||||
{
|
||||
["ExternalId"] = "two",
|
||||
["Media"] = new BsonArray { $"https://example.test/api/questions/media/{first}" }
|
||||
}
|
||||
};
|
||||
|
||||
var result = QuestionPromotionCommand.CollectMediaIds(questions);
|
||||
|
||||
Assert.Equal(new HashSet<ObjectId> { first, second }, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InvalidLocalQuestionMediaReferenceFailsValidation()
|
||||
{
|
||||
var question = new BsonDocument
|
||||
{
|
||||
["ExternalId"] = "bad-media",
|
||||
["Media"] = new BsonArray { "/api/questions/media/not-an-id" }
|
||||
};
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(
|
||||
() => QuestionPromotionCommand.CollectMediaIds([question]));
|
||||
|
||||
Assert.Contains("bad-media", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuestionComparisonClassifiesInsertUpdateAndUnchanged()
|
||||
{
|
||||
var source = new[]
|
||||
{
|
||||
Question("insert", "new"),
|
||||
Question("update", "new"),
|
||||
Question("same", "same")
|
||||
};
|
||||
var target = new[]
|
||||
{
|
||||
Question("update", "old"),
|
||||
new BsonDocument
|
||||
{
|
||||
["Stem"] = "same",
|
||||
["ExternalId"] = "same",
|
||||
["_id"] = ObjectId.GenerateNewId()
|
||||
}
|
||||
};
|
||||
|
||||
var result = QuestionPromotionCommand.ClassifyQuestions(source, target);
|
||||
|
||||
Assert.Equal(1, result.Inserted);
|
||||
Assert.Equal(1, result.Updated);
|
||||
Assert.Equal(1, result.Unchanged);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void QuestionHashIgnoresMongoIdsFieldOrderAndInputOrder()
|
||||
{
|
||||
var first = Question("a", "one");
|
||||
first["_id"] = ObjectId.GenerateNewId();
|
||||
var second = Question("b", "two");
|
||||
var equivalentFirst = new BsonDocument
|
||||
{
|
||||
["Stem"] = "one",
|
||||
["_id"] = ObjectId.GenerateNewId(),
|
||||
["ExternalId"] = "a"
|
||||
};
|
||||
|
||||
Assert.Equal(
|
||||
QuestionPromotionCommand.HashQuestions([first, second]),
|
||||
QuestionPromotionCommand.HashQuestions([second, equivalentFirst]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadyQuestionApplyRequiresExplicitProductionConfirmation()
|
||||
{
|
||||
using var output = new StringWriter();
|
||||
|
||||
var result = await QuestionPromotionCommand.RunAsync(
|
||||
[
|
||||
"--source-connection", "mongodb://source.invalid",
|
||||
"--source-database", "uat",
|
||||
"--target-connection", "mongodb://target.invalid",
|
||||
"--target-database", "prd",
|
||||
"--apply"
|
||||
], output);
|
||||
|
||||
Assert.Equal(1, result);
|
||||
Assert.Contains("--confirm PROMOTE_TO_PRD is required", output.ToString());
|
||||
}
|
||||
|
||||
private static BsonDocument Question(string externalId, string stem) => new()
|
||||
{
|
||||
["ExternalId"] = externalId,
|
||||
["Stem"] = stem
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using QuestionsApi.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace QuestionsApi.Tests;
|
||||
|
||||
public sealed class CreditEconomyTests
|
||||
{
|
||||
[Fact]
|
||||
public void EarningPolicy_UsesTheAgreedRollingLimit()
|
||||
{
|
||||
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();
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using QuestionsApi.Models;
|
||||
using QuestionsApi.Services;
|
||||
using Xunit;
|
||||
|
||||
@@ -27,4 +28,209 @@ public sealed class DashboardTests
|
||||
var dates = new[] { today, today.AddDays(-1), today.AddDays(-3) };
|
||||
Assert.Equal(2, DashboardStore.CalculateCurrentStreak(dates, today));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemPerformanceReturnsEveryOfficialSystemInOrder()
|
||||
{
|
||||
var attempts = new[]
|
||||
{
|
||||
Attempt("cardio-1", true),
|
||||
Attempt("cardio-2", false),
|
||||
Attempt("ethics-1", true),
|
||||
Attempt("ignored", true),
|
||||
Attempt("skipped-cardio", false, true)
|
||||
};
|
||||
var questions = new Dictionary<string, Question>
|
||||
{
|
||||
["cardio-1"] = Question("Cardiology"),
|
||||
["cardio-2"] = Question("Cardiovascular System"),
|
||||
["ethics-1"] = Question("Medical Ethics"),
|
||||
["ignored"] = Question("Unmapped Specialty"),
|
||||
["skipped-cardio"] = Question("Cardiology")
|
||||
};
|
||||
|
||||
var result = DashboardStore.BuildSystemPerformance(attempts, questions);
|
||||
|
||||
Assert.Equal(14, result.Count);
|
||||
Assert.Equal(DashboardStore.Step3Systems, result.Select(value => value.System));
|
||||
var cardiovascular = result.Single(value => value.System == "Cardiovascular System");
|
||||
Assert.Equal(1, cardiovascular.Correct);
|
||||
Assert.Equal(2, cardiovascular.Total);
|
||||
Assert.Equal(50, cardiovascular.Accuracy);
|
||||
var social = result.Single(value => value.System.StartsWith("Social Sciences"));
|
||||
Assert.Equal(1, social.Correct);
|
||||
Assert.Equal(1, social.Total);
|
||||
Assert.All(result.Where(value => value.System != cardiovascular.System && value.System != social.System),
|
||||
value => Assert.Equal(0, value.Total));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Preventive Medicine", "Human Development")]
|
||||
[InlineData("Infectious Disease", "Immune System, Blood & Lymphoreticular System, and Multisystem Processes/Disorders")]
|
||||
[InlineData("Dermatology", "Skin & Subcutaneous Tissue")]
|
||||
[InlineData("Pulmonary", "Respiratory System")]
|
||||
[InlineData("Pregnancy", "Pregnancy/Childbirth & Female Reproductive System & Breast")]
|
||||
[InlineData("Biostatistics", "Biostatistics & Epidemiology/Population Health & Interpretation of the Medical Literature")]
|
||||
[InlineData("Medical Ethics", "Social Sciences: Communication Skills/Ethics/Patient Safety")]
|
||||
[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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrendCarriesRatingForwardAndLeavesInactiveAccuracyAsGapData()
|
||||
{
|
||||
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
|
||||
var baselineDate = today.AddDays(-2).AddHours(8);
|
||||
var attempts = new[]
|
||||
{
|
||||
Attempt("one", true, createdUtc: today.AddDays(-2).AddHours(9),
|
||||
rating: 1018, ratingUtc: today.AddDays(-2).AddHours(9)),
|
||||
Attempt("two", false, createdUtc: today.AddDays(-2).AddHours(10),
|
||||
rating: 1002, ratingUtc: today.AddDays(-2).AddHours(10)),
|
||||
Attempt("three", true, createdUtc: today.AddHours(11),
|
||||
rating: 1020, ratingUtc: today.AddHours(11)),
|
||||
Attempt("skip", false, true, today.AddDays(-1).AddHours(4),
|
||||
990, today.AddDays(-1).AddHours(4))
|
||||
};
|
||||
var rating = new UserRating
|
||||
{
|
||||
OverallElo = 1020,
|
||||
HistoryBaselineElo = 1000,
|
||||
HistoryStartedUtc = baselineDate
|
||||
};
|
||||
|
||||
var result = DashboardStore.BuildPerformanceTrend(
|
||||
attempts, rating, Array.Empty<CcsAttempt>(), new CcsUserRating(), today);
|
||||
|
||||
Assert.Equal(30, result.Count);
|
||||
Assert.Null(result[^4].QuestionBankRating);
|
||||
Assert.Equal(1002, result[^3].QuestionBankRating);
|
||||
Assert.Equal(50, result[^3].Accuracy);
|
||||
Assert.Equal(2, result[^3].Total);
|
||||
Assert.Equal(990, result[^2].QuestionBankRating);
|
||||
Assert.Equal(0, result[^2].Total);
|
||||
Assert.Equal(1020, result[^1].QuestionBankRating);
|
||||
Assert.Equal(100, result[^1].Accuracy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TrendCarriesCcsRatingForwardIndependentlyOfQuestionBankRating()
|
||||
{
|
||||
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
|
||||
var baselineDate = today.AddDays(-2).AddHours(8);
|
||||
var rating = new UserRating();
|
||||
var ccsRating = new CcsUserRating
|
||||
{
|
||||
Elo = 1050,
|
||||
HistoryBaselineElo = 1000,
|
||||
HistoryStartedUtc = baselineDate
|
||||
};
|
||||
var ccsAttempts = new[]
|
||||
{
|
||||
RatedCcsAttempt("one", today.AddDays(-1).AddHours(9), 1030),
|
||||
RatedCcsAttempt("two", today.AddHours(10), 1050)
|
||||
};
|
||||
|
||||
var result = DashboardStore.BuildPerformanceTrend(
|
||||
Array.Empty<QuestionAttempt>(), rating, ccsAttempts, ccsRating, today);
|
||||
|
||||
Assert.Equal(30, result.Count);
|
||||
Assert.Equal(1000, result[^3].CcsRating);
|
||||
Assert.Equal(1030, result[^2].CcsRating);
|
||||
Assert.Equal(1050, result[^1].CcsRating);
|
||||
}
|
||||
|
||||
private static CcsAttempt RatedCcsAttempt(string caseExternalId, DateTime appliedUtc, int userElo) => new()
|
||||
{
|
||||
CaseExternalId = caseExternalId,
|
||||
Rating = new CcsRatingChange { Rated = true, UserElo = userElo, AppliedUtc = appliedUtc }
|
||||
};
|
||||
|
||||
private static QuestionAttempt Attempt(
|
||||
string questionId,
|
||||
bool correct,
|
||||
bool skip = false,
|
||||
DateTime? createdUtc = null,
|
||||
int? rating = null,
|
||||
DateTime? ratingUtc = null) => new()
|
||||
{
|
||||
QuestionExternalId = questionId,
|
||||
IsCorrect = correct,
|
||||
IsSkip = skip,
|
||||
CreatedUtc = createdUtc ?? DateTime.UtcNow,
|
||||
UserOverallEloAfter = rating,
|
||||
RatingAppliedUtc = ratingUtc
|
||||
};
|
||||
|
||||
private static Question Question(string system) => new()
|
||||
{
|
||||
ExternalId = system,
|
||||
System = system
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void PercentileReturnsNullWithNoUsers()
|
||||
{
|
||||
Assert.Null(DashboardStore.Percentile(0, 0));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PercentileComputesTopPercentFromUsersRatedHigher()
|
||||
{
|
||||
// 18 of 100 users rate higher than this user - "top 18%"
|
||||
Assert.Equal(18, DashboardStore.Percentile(18, 100));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PercentileNeverReturnsZero()
|
||||
{
|
||||
// Even the single best-rated user should read as "top 1%", not "top 0%"
|
||||
Assert.Equal(1, DashboardStore.Percentile(0, 100));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.85, "High")]
|
||||
[InlineData(0.7, "High")]
|
||||
[InlineData(0.55, "Medium")]
|
||||
[InlineData(0.4, "Medium")]
|
||||
[InlineData(0.1, "Low")]
|
||||
public void ReadinessLabelUsesPassProbabilityThresholds(double passProbability, string expected)
|
||||
{
|
||||
Assert.Equal(expected, DashboardStore.ReadinessLabel(passProbability));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectLatestReadinessScoreSkipsSampleSimulationsEvenWhenMostRecent()
|
||||
{
|
||||
var today = new DateTime(2026, 7, 25, 0, 0, 0, DateTimeKind.Utc);
|
||||
var simulations = new[]
|
||||
{
|
||||
CompletedSimulation(isSample: true, completedUtc: today, passProbability: 0.98),
|
||||
CompletedSimulation(isSample: false, completedUtc: today.AddDays(-3), passProbability: 0.12)
|
||||
};
|
||||
|
||||
var score = DashboardStore.SelectLatestReadinessScore(simulations);
|
||||
|
||||
Assert.NotNull(score);
|
||||
Assert.Equal(0.12, score.PassProbability);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectLatestReadinessScoreReturnsNullWhenOnlySamplesExist()
|
||||
{
|
||||
var simulations = new[] { CompletedSimulation(isSample: true, completedUtc: DateTime.UtcNow, passProbability: 0.98) };
|
||||
|
||||
Assert.Null(DashboardStore.SelectLatestReadinessScore(simulations));
|
||||
}
|
||||
|
||||
private static ExamSimulation CompletedSimulation(bool isSample, DateTime completedUtc, double passProbability) => new()
|
||||
{
|
||||
IsSample = isSample,
|
||||
CompletedUtc = completedUtc,
|
||||
Score = new ExamSimulationScore { PassProbability = passProbability }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Services;
|
||||
using QuestionsApi.Models;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/credits")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,11 @@ public class QuestionsController : ControllerBase
|
||||
private readonly ServiceMetrics _metrics;
|
||||
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,
|
||||
@@ -28,7 +33,12 @@ public class QuestionsController : ControllerBase
|
||||
AnswerStatisticsStore statistics,
|
||||
ServiceMetrics metrics,
|
||||
QuestionBookmarkStore bookmarks,
|
||||
ReviewQueueStore reviewQueue)
|
||||
ReviewQueueStore reviewQueue,
|
||||
CreditStore credits,
|
||||
MasteryAwardService masteryAwards,
|
||||
StudySessionStore studySessions,
|
||||
DailyMissionStore dailyMissions,
|
||||
ProgressionStore progression)
|
||||
{
|
||||
_questions = questions;
|
||||
_attempts = attempts;
|
||||
@@ -38,6 +48,11 @@ public class QuestionsController : ControllerBase
|
||||
_metrics = metrics;
|
||||
_bookmarks = bookmarks;
|
||||
_reviewQueue = reviewQueue;
|
||||
_credits = credits;
|
||||
_masteryAwards = masteryAwards;
|
||||
_studySessions = studySessions;
|
||||
_dailyMissions = dailyMissions;
|
||||
_progression = progression;
|
||||
}
|
||||
|
||||
[HttpGet("systems")]
|
||||
@@ -52,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));
|
||||
@@ -147,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,
|
||||
@@ -154,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();
|
||||
@@ -172,6 +193,18 @@ public class QuestionsController : ControllerBase
|
||||
_metrics.AttemptDuplicate();
|
||||
}
|
||||
await _reviewQueue.AddFromFirstAttemptAsync(record.Attempt);
|
||||
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(
|
||||
@@ -187,7 +220,7 @@ public class QuestionsController : ControllerBase
|
||||
record.Attempt.IsCorrect,
|
||||
shouldRate);
|
||||
if (shouldRate)
|
||||
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner);
|
||||
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner, rating.UserOverallElo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -197,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,
|
||||
@@ -204,7 +250,13 @@ public class QuestionsController : ControllerBase
|
||||
question,
|
||||
await _statistics.GetPercentagesForQuestionAsync(question.ExternalId)),
|
||||
QuestionElo = rating.QuestionElo,
|
||||
Rating = rating
|
||||
Rating = rating,
|
||||
Credits = await _credits.GetStatusAsync(userId),
|
||||
CreditAwards = creditAwards,
|
||||
StudySessionProgress = studySession?.Progress,
|
||||
SessionSummary = studySession?.Summary,
|
||||
DailyMissions = dailyMissions?.State,
|
||||
Progression = progression.State
|
||||
});
|
||||
}
|
||||
|
||||
@@ -265,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
|
||||
@@ -286,7 +341,7 @@ public class QuestionsController : ControllerBase
|
||||
{
|
||||
rating = await _ratings.ApplyFirstResultAsync(userId, question, false, shouldRate);
|
||||
if (shouldRate)
|
||||
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner);
|
||||
await _attempts.MarkRatingAppliedAsync(record.Attempt.Id ?? "", ratingOwner, rating.UserOverallElo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -296,6 +351,7 @@ public class QuestionsController : ControllerBase
|
||||
throw;
|
||||
}
|
||||
|
||||
var progression = await _progression.RefreshAsync(userId);
|
||||
return Ok(new SkipQuestionResponse
|
||||
{
|
||||
QuestionExternalId = question.ExternalId,
|
||||
@@ -304,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; } = "";
|
||||
@@ -349,6 +445,8 @@ public class CcsUserRating
|
||||
public string UserId { get; set; } = "";
|
||||
public int Elo { get; set; } = 1000;
|
||||
public int RatedCaseCount { get; set; }
|
||||
public int? HistoryBaselineElo { get; set; }
|
||||
public DateTime? HistoryStartedUtc { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public static class CreditEconomy
|
||||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
public sealed class UserCreditAccount
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
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;
|
||||
}
|
||||
|
||||
public sealed class CreditStatusResponse
|
||||
{
|
||||
public int Credits { get; set; }
|
||||
public int EarnedInRollingWindow { get; set; }
|
||||
public int RemainingEarningCapacity { get; set; }
|
||||
public int RollingWindowHours { get; set; } = 24;
|
||||
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; } = [];
|
||||
}
|
||||
@@ -10,6 +10,13 @@ public sealed class DashboardResponse
|
||||
public int CcsCasesCompleted { get; set; }
|
||||
public int FirstAttemptAccuracy { get; set; }
|
||||
public int AccuracySampleSize { get; set; }
|
||||
public int? AccuracyDeltaVsPreviousSample { get; set; }
|
||||
public int? QuestionBankRatingDeltaThisWeek { get; set; }
|
||||
public int? QuestionBankPercentile { get; set; }
|
||||
public int? CcsRatingDeltaThisWeek { get; set; }
|
||||
public int? CcsPercentile { get; set; }
|
||||
public string? ExamReadinessLabel { get; set; }
|
||||
public int? ExamReadinessProjectedPassPercent { get; set; }
|
||||
public int QuestionsThisWeek { get; set; }
|
||||
public int StudyTimeSecondsThisWeek { get; set; }
|
||||
public int CurrentStreak { get; set; }
|
||||
@@ -24,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
|
||||
@@ -32,6 +40,8 @@ public sealed class DashboardTrendPoint
|
||||
public int Correct { get; set; }
|
||||
public int Total { get; set; }
|
||||
public int Accuracy { get; set; }
|
||||
public int? QuestionBankRating { get; set; }
|
||||
public int? CcsRating { get; set; }
|
||||
}
|
||||
|
||||
public sealed class DashboardSystemPerformance
|
||||
@@ -40,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; }
|
||||
@@ -19,6 +20,8 @@ public class QuestionAttempt
|
||||
public int DurationSeconds { get; set; }
|
||||
public int CounterBucket { get; set; }
|
||||
public bool? RatingApplied { get; set; }
|
||||
public int? UserOverallEloAfter { get; set; }
|
||||
public DateTime? RatingAppliedUtc { get; set; }
|
||||
public string RatingLeaseOwner { get; set; } = "";
|
||||
public DateTime RatingLeaseUntilUtc { get; set; }
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -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; }
|
||||
@@ -49,4 +50,6 @@ public class FirstQuestionAttemptRequest
|
||||
public string ChoiceId { get; set; } = "";
|
||||
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,
|
||||
@@ -141,6 +145,12 @@ public class FirstQuestionAttemptResponse
|
||||
public Dictionary<string, int> ChoiceSelectionPercentages { get; set; } = [];
|
||||
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
|
||||
@@ -150,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
|
||||
|
||||
@@ -13,6 +13,8 @@ public class UserRating
|
||||
public int OverallElo { get; set; } = 1000;
|
||||
public Dictionary<string, int> CategoryElos { get; set; } = [];
|
||||
public int RatedQuestionCount { get; set; }
|
||||
public int? HistoryBaselineElo { get; set; }
|
||||
public DateTime? HistoryStartedUtc { get; set; }
|
||||
public DateTime UpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -45,4 +47,6 @@ public class RatingProfileResponse
|
||||
public int OverallElo { get; set; }
|
||||
public int RatedQuestionCount { get; set; }
|
||||
public Dictionary<string, int> CategoryElos { get; set; } = [];
|
||||
public string Tier { get; set; } = "";
|
||||
public int TierDivision { get; set; }
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -17,12 +17,24 @@ if (args.FirstOrDefault() == "promote-content")
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "promote-questions")
|
||||
{
|
||||
Environment.ExitCode = await QuestionPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "promote-dynamic-questions")
|
||||
{
|
||||
Environment.ExitCode = await DynamicBlueprintCommand.RunAsync(args.Skip(1).ToArray(), Console.Out);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.FirstOrDefault() == "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);
|
||||
@@ -49,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 =>
|
||||
@@ -60,6 +94,10 @@ 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>();
|
||||
@@ -79,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>();
|
||||
@@ -106,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;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,12 @@ public sealed class CcsRatingStore
|
||||
return new CcsRatingProfileResponse { Elo = rating.Elo, RatedCaseCount = rating.RatedCaseCount };
|
||||
}
|
||||
|
||||
public async Task<CcsUserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
|
||||
{
|
||||
var rating = await GetOrCreateUserAsync(userId);
|
||||
return await EnsureHistoryBaselineAsync(rating, nowUtc);
|
||||
}
|
||||
|
||||
public async Task<CcsRatingChange> ApplyAsync(CcsAttempt attempt)
|
||||
{
|
||||
var actual = Math.Clamp((attempt.Score?.Percent ?? 0) / 100.0, 0, 1);
|
||||
@@ -52,6 +58,7 @@ public sealed class CcsRatingStore
|
||||
}
|
||||
|
||||
var user = await GetOrCreateUserAsync(attempt.UserId);
|
||||
user = await EnsureHistoryBaselineAsync(user, DateTime.UtcNow);
|
||||
var caseRating = await GetOrCreateCaseAsync(attempt.CaseExternalId);
|
||||
var expected = ExpectedScore(user.Elo, caseRating.Elo);
|
||||
var userDelta = (int)Math.Round(UserK(user.RatedCaseCount) * (actual - expected), MidpointRounding.AwayFromZero);
|
||||
@@ -137,6 +144,26 @@ public sealed class CcsRatingStore
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<CcsUserRating> EnsureHistoryBaselineAsync(CcsUserRating rating, DateTime nowUtc)
|
||||
{
|
||||
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
|
||||
return rating;
|
||||
|
||||
var result = await _users.UpdateOneAsync(
|
||||
value => value.UserId == rating.UserId && value.HistoryStartedUtc == null,
|
||||
Builders<CcsUserRating>.Update
|
||||
.Set(value => value.HistoryBaselineElo, rating.Elo)
|
||||
.Set(value => value.HistoryStartedUtc, nowUtc));
|
||||
if (result.ModifiedCount > 0)
|
||||
{
|
||||
rating.HistoryBaselineElo = rating.Elo;
|
||||
rating.HistoryStartedUtc = nowUtc;
|
||||
return rating;
|
||||
}
|
||||
|
||||
return await _users.Find(value => value.UserId == rating.UserId).FirstAsync();
|
||||
}
|
||||
|
||||
private async Task<CcsCaseRating> UpdateCaseAsync(CcsCaseRating current, int delta)
|
||||
{
|
||||
while (true)
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -25,8 +25,11 @@ public static class ContentPromotionCommand
|
||||
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
|
||||
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
|
||||
|
||||
var questions = await source.GetCollection<BsonDocument>("Questions")
|
||||
var sourceQuestions = await source.GetCollection<BsonDocument>("Questions")
|
||||
.Find(Builders<BsonDocument>.Filter.Eq("IsPublished", true)).ToListAsync();
|
||||
var questions = sourceQuestions
|
||||
.Select(QuestionPromotionCommand.PrepareForProduction)
|
||||
.ToList();
|
||||
var mediaIds = CollectMediaIds(questions);
|
||||
var media = mediaIds.Count == 0
|
||||
? []
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
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, 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<CreditAwardResult> AwardAsync(string userId, CreditAwardRequest request)
|
||||
{
|
||||
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 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
|
||||
{
|
||||
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
|
||||
};
|
||||
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)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
await EnsureAndCleanAsync(userId, now);
|
||||
var account = await _accounts.Find(value => value.UserId == userId).FirstAsync();
|
||||
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
|
||||
{
|
||||
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(
|
||||
value => value.UserId == userId,
|
||||
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()
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,6 @@ namespace QuestionsApi.Services;
|
||||
public sealed class DashboardStore
|
||||
{
|
||||
private readonly IMongoCollection<QuestionAttempt> _attempts;
|
||||
private readonly IMongoCollection<UserRating> _ratings;
|
||||
private readonly IMongoCollection<CcsUserRating> _ccsRatings;
|
||||
private readonly IMongoCollection<CcsAttempt> _ccsAttempts;
|
||||
private readonly IMongoCollection<CcsCaseRevision> _ccsCases;
|
||||
@@ -19,6 +18,11 @@ public sealed class DashboardStore
|
||||
private readonly DynamicQuestionStore _dynamicQuestions;
|
||||
private readonly FlashcardStudyService _flashcards;
|
||||
private readonly FlashcardScheduleStore _flashcardSchedules;
|
||||
private readonly RatingStore _ratingStore;
|
||||
private readonly CcsRatingStore _ccsRatingStore;
|
||||
private readonly IMongoCollection<UserRating> _ratings;
|
||||
|
||||
public static readonly string[] Step3Systems = Step3SystemCatalog.All;
|
||||
|
||||
public DashboardStore(
|
||||
MongoDatabaseProvider mongo,
|
||||
@@ -26,65 +30,101 @@ public sealed class DashboardStore
|
||||
ReviewQueueStore reviewQueue,
|
||||
DynamicQuestionStore dynamicQuestions,
|
||||
FlashcardStudyService flashcards,
|
||||
FlashcardScheduleStore flashcardSchedules)
|
||||
FlashcardScheduleStore flashcardSchedules,
|
||||
RatingStore ratingStore,
|
||||
CcsRatingStore ccsRatingStore)
|
||||
{
|
||||
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
|
||||
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
|
||||
_ccsAttempts = mongo.Database.GetCollection<CcsAttempt>("CcsAttempts");
|
||||
_ccsCases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
|
||||
_simulations = mongo.Database.GetCollection<ExamSimulation>("ExamSimulations");
|
||||
_bookmarks = mongo.Database.GetCollection<QuestionBookmark>("QuestionBookmarks");
|
||||
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
|
||||
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
|
||||
_questions = questions;
|
||||
_reviewQueue = reviewQueue;
|
||||
_dynamicQuestions = dynamicQuestions;
|
||||
_flashcards = flashcards;
|
||||
_flashcardSchedules = flashcardSchedules;
|
||||
_ratingStore = ratingStore;
|
||||
_ccsRatingStore = ccsRatingStore;
|
||||
}
|
||||
|
||||
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
|
||||
{
|
||||
await _reviewQueue.EnsureUserQueueAsync(userId);
|
||||
var today = nowUtc.Date;
|
||||
var trendStart = today.AddDays(-29);
|
||||
var weekStart = today.AddDays(-6);
|
||||
var attemptsTask = _attempts.Find(value => value.UserId == userId).ToListAsync();
|
||||
var ratingTask = _ratings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||
var ccsRatingTask = _ccsRatings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||
var ratingTask = _ratingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
|
||||
var ccsRatingTask = _ccsRatingStore.GetOrInitializeHistoryAsync(userId, nowUtc);
|
||||
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
|
||||
var ccsAllAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId).ToListAsync();
|
||||
var completedCcsCountTask = _ccsAttempts.CountDocumentsAsync(value => value.UserId == userId
|
||||
&& value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue);
|
||||
var simulationsTask = _simulations.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
|
||||
var simulationsTask = _simulations.Find(value => value.UserId == userId && !value.IsSample && value.CreatedUtc >= trendStart).ToListAsync();
|
||||
var completedSimulationsTask = _simulations.Find(value => value.UserId == userId
|
||||
&& value.State == ExamSimulationStates.Completed && value.Score != null)
|
||||
.SortByDescending(value => value.CompletedUtc).ToListAsync();
|
||||
var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
|
||||
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||
var reviewAttemptsTask = _reviewQueue.GetReviewsSinceAsync(userId, weekStart);
|
||||
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, completedCcsCountTask,
|
||||
simulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask,
|
||||
flashcardReviewsTask, dynamicReviewsTask);
|
||||
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask, ccsAllAttemptsTask, completedCcsCountTask,
|
||||
simulationsTask, completedSimulationsTask, bookmarkCountTask, notepadTask, reviewAttemptsTask, flashcardCountsTask,
|
||||
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 questionMap = await _questions.GetByExternalIdsAsync(attempts.Select(value => value.QuestionExternalId));
|
||||
var previousHundred = answeredAttempts.OrderByDescending(value => value.CreatedUtc).Skip(100).Take(100).ToList();
|
||||
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;
|
||||
var ccsElo = ccsRatingTask.Result.Elo;
|
||||
var qbankHigherTask = _ratings.CountDocumentsAsync(value => value.OverallElo > qbankElo);
|
||||
var qbankTotalTask = _ratings.CountDocumentsAsync(FilterDefinition<UserRating>.Empty);
|
||||
var ccsHigherTask = _ccsRatings.CountDocumentsAsync(value => value.Elo > ccsElo);
|
||||
var ccsTotalTask = _ccsRatings.CountDocumentsAsync(FilterDefinition<CcsUserRating>.Empty);
|
||||
await Task.WhenAll(qbankHigherTask, qbankTotalTask, ccsHigherTask, ccsTotalTask);
|
||||
|
||||
var latestScore = SelectLatestReadinessScore(completedSimulationsTask.Result);
|
||||
var accuracySampleSize = recentAnswered.Count;
|
||||
var firstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count);
|
||||
var performanceTrend = BuildPerformanceTrend(attempts, ratingTask.Result, ccsAllAttemptsTask.Result, ccsRatingTask.Result, today);
|
||||
var weekAgoTrend = performanceTrend[^8];
|
||||
|
||||
var response = new DashboardResponse
|
||||
{
|
||||
QuestionBankRating = ratingTask.Result?.OverallElo ?? 1000,
|
||||
RatedQuestionCount = ratingTask.Result?.RatedQuestionCount ?? 0,
|
||||
CcsRating = ccsRatingTask.Result?.Elo ?? 1000,
|
||||
RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0,
|
||||
QuestionBankRating = qbankElo,
|
||||
RatedQuestionCount = ratingTask.Result.RatedQuestionCount,
|
||||
CcsRating = ccsElo,
|
||||
RatedCcsCaseCount = ccsRatingTask.Result.RatedCaseCount,
|
||||
QuestionsCompleted = answeredAttempts.Count,
|
||||
CcsCasesCompleted = checked((int)completedCcsCountTask.Result),
|
||||
AccuracySampleSize = recentAnswered.Count,
|
||||
FirstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count),
|
||||
AccuracySampleSize = accuracySampleSize,
|
||||
FirstAttemptAccuracy = firstAttemptAccuracy,
|
||||
AccuracyDeltaVsPreviousSample = previousHundred.Count == 0
|
||||
? null
|
||||
: firstAttemptAccuracy - Percentage(previousHundred.Count(value => value.IsCorrect), previousHundred.Count),
|
||||
QuestionBankRatingDeltaThisWeek = weekAgoTrend.QuestionBankRating is null ? null : qbankElo - weekAgoTrend.QuestionBankRating,
|
||||
QuestionBankPercentile = Percentile(qbankHigherTask.Result, qbankTotalTask.Result),
|
||||
CcsRatingDeltaThisWeek = weekAgoTrend.CcsRating is null ? null : ccsElo - weekAgoTrend.CcsRating,
|
||||
CcsPercentile = Percentile(ccsHigherTask.Result, ccsTotalTask.Result),
|
||||
ExamReadinessLabel = latestScore is null ? null : ReadinessLabel(latestScore.PassProbability),
|
||||
ExamReadinessProjectedPassPercent = latestScore is null ? null : (int)Math.Round(latestScore.PassProbability * 100),
|
||||
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
|
||||
BookmarkCount = checked((int)bookmarkCountTask.Result),
|
||||
NoteCount = CountNotes(notepadTask.Result?.Content),
|
||||
@@ -95,31 +135,9 @@ public sealed class DashboardStore
|
||||
FlashcardsReviewedThisWeek = flashcardCountsTask.Result.ReviewedThisWeek
|
||||
};
|
||||
|
||||
response.PerformanceTrend = Enumerable.Range(0, 30).Select(offset =>
|
||||
{
|
||||
var date = trendStart.AddDays(offset);
|
||||
var daily = answeredAttempts.Where(value => value.CreatedUtc.Date == date).ToList();
|
||||
return new DashboardTrendPoint
|
||||
{
|
||||
DateUtc = date,
|
||||
Correct = daily.Count(value => value.IsCorrect),
|
||||
Total = daily.Count,
|
||||
Accuracy = Percentage(daily.Count(value => value.IsCorrect), daily.Count)
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
response.Systems = answeredAttempts
|
||||
.Where(value => questionMap.ContainsKey(value.QuestionExternalId))
|
||||
.GroupBy(value => NormalizeSystem(questionMap[value.QuestionExternalId].System))
|
||||
.Select(group => new DashboardSystemPerformance
|
||||
{
|
||||
System = group.Key,
|
||||
Correct = group.Count(value => value.IsCorrect),
|
||||
Total = group.Count(),
|
||||
Accuracy = Percentage(group.Count(value => value.IsCorrect), group.Count())
|
||||
})
|
||||
.OrderByDescending(value => value.Total).ThenByDescending(value => value.Accuracy)
|
||||
.ToList();
|
||||
response.PerformanceTrend = performanceTrend;
|
||||
response.Systems = MasteryCalculator.Calculate(
|
||||
attempts, allReviewAttemptsTask.Result, reviewItemsTask.Result, questionMap, nowUtc);
|
||||
|
||||
response.WeeklyActivity = Enumerable.Range(0, 7).Select(offset =>
|
||||
{
|
||||
@@ -152,6 +170,22 @@ public sealed class DashboardStore
|
||||
public static int Percentage(int correct, int total) =>
|
||||
total <= 0 ? 0 : (int)Math.Round(correct * 100.0 / total, MidpointRounding.AwayFromZero);
|
||||
|
||||
public static int? Percentile(long usersRatedHigher, long totalUsers) =>
|
||||
totalUsers <= 0 ? null : Math.Max(1, (int)Math.Round(usersRatedHigher * 100.0 / totalUsers, MidpointRounding.AwayFromZero));
|
||||
|
||||
// Thresholds are a reasonable default for labeling an existing, tested
|
||||
// pass-probability estimate (ExamSimulationScoring.Calculate) - tune freely,
|
||||
// this isn't a validated clinical cutoff.
|
||||
public static string ReadinessLabel(double passProbability) =>
|
||||
passProbability >= 0.7 ? "High" : passProbability >= 0.4 ? "Medium" : "Low";
|
||||
|
||||
// completedSimulationsByRecency must already be sorted newest-first (by CompletedUtc).
|
||||
// Sample simulations (QA-only, seeded via the SUPER-role "preview results" tooling with
|
||||
// scripted answers) never reflect real performance, so they're skipped here rather than
|
||||
// in the Mongo filter - keeping the "which one counts" decision unit-testable.
|
||||
public static ExamSimulationScore? SelectLatestReadinessScore(IEnumerable<ExamSimulation> completedSimulationsByRecency) =>
|
||||
completedSimulationsByRecency.FirstOrDefault(value => !value.IsSample)?.Score;
|
||||
|
||||
public static int CalculateCurrentStreak(IEnumerable<DateTime> activeDates, DateTime todayUtc)
|
||||
{
|
||||
var dates = activeDates.Select(value => value.Date).ToHashSet();
|
||||
@@ -166,6 +200,103 @@ public sealed class DashboardStore
|
||||
return streak;
|
||||
}
|
||||
|
||||
public static List<DashboardTrendPoint> BuildPerformanceTrend(
|
||||
IEnumerable<QuestionAttempt> attempts,
|
||||
UserRating rating,
|
||||
IEnumerable<CcsAttempt> ccsAttempts,
|
||||
CcsUserRating ccsRating,
|
||||
DateTime todayUtc)
|
||||
{
|
||||
var attemptList = attempts.ToList();
|
||||
var ccsAttemptList = ccsAttempts.ToList();
|
||||
var trendStart = todayUtc.Date.AddDays(-29);
|
||||
var ratingEvents = new List<(DateTime AtUtc, int Elo)>();
|
||||
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
|
||||
ratingEvents.Add((rating.HistoryStartedUtc.Value, rating.HistoryBaselineElo.Value));
|
||||
ratingEvents.AddRange(attemptList
|
||||
.Where(value => value.RatingAppliedUtc.HasValue && value.UserOverallEloAfter.HasValue)
|
||||
.Select(value => (value.RatingAppliedUtc!.Value, value.UserOverallEloAfter!.Value)));
|
||||
ratingEvents.Sort((left, right) => left.AtUtc.CompareTo(right.AtUtc));
|
||||
|
||||
var ccsRatingEvents = new List<(DateTime AtUtc, int Elo)>();
|
||||
if (ccsRating.HistoryBaselineElo.HasValue && ccsRating.HistoryStartedUtc.HasValue)
|
||||
ccsRatingEvents.Add((ccsRating.HistoryStartedUtc.Value, ccsRating.HistoryBaselineElo.Value));
|
||||
ccsRatingEvents.AddRange(ccsAttemptList
|
||||
.Where(value => value.Rating is { AppliedUtc: not null })
|
||||
.Select(value => (value.Rating!.AppliedUtc!.Value, value.Rating!.UserElo)));
|
||||
ccsRatingEvents.Sort((left, right) => left.AtUtc.CompareTo(right.AtUtc));
|
||||
|
||||
int? currentRating = null;
|
||||
int? currentCcsRating = null;
|
||||
var eventIndex = 0;
|
||||
var ccsEventIndex = 0;
|
||||
var result = new List<DashboardTrendPoint>();
|
||||
foreach (var offset in Enumerable.Range(0, 30))
|
||||
{
|
||||
var date = trendStart.AddDays(offset);
|
||||
var nextDate = date.AddDays(1);
|
||||
while (eventIndex < ratingEvents.Count && ratingEvents[eventIndex].AtUtc < nextDate)
|
||||
{
|
||||
currentRating = ratingEvents[eventIndex].Elo;
|
||||
eventIndex++;
|
||||
}
|
||||
while (ccsEventIndex < ccsRatingEvents.Count && ccsRatingEvents[ccsEventIndex].AtUtc < nextDate)
|
||||
{
|
||||
currentCcsRating = ccsRatingEvents[ccsEventIndex].Elo;
|
||||
ccsEventIndex++;
|
||||
}
|
||||
|
||||
var daily = attemptList
|
||||
.Where(value => !value.IsSkip && value.CreatedUtc >= date && value.CreatedUtc < nextDate)
|
||||
.ToList();
|
||||
var correct = daily.Count(value => value.IsCorrect);
|
||||
result.Add(new DashboardTrendPoint
|
||||
{
|
||||
DateUtc = date,
|
||||
Correct = correct,
|
||||
Total = daily.Count,
|
||||
Accuracy = Percentage(correct, daily.Count),
|
||||
QuestionBankRating = currentRating,
|
||||
CcsRating = currentCcsRating
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static List<DashboardSystemPerformance> BuildSystemPerformance(
|
||||
IEnumerable<QuestionAttempt> attempts,
|
||||
IReadOnlyDictionary<string, Question> questionMap)
|
||||
{
|
||||
var totals = Step3Systems.ToDictionary(
|
||||
system => system,
|
||||
_ => (Correct: 0, Total: 0),
|
||||
StringComparer.Ordinal);
|
||||
foreach (var attempt in attempts.Where(value => !value.IsSkip))
|
||||
{
|
||||
if (!questionMap.TryGetValue(attempt.QuestionExternalId, out var question))
|
||||
continue;
|
||||
var system = NormalizeStep3System(question);
|
||||
if (system is null)
|
||||
continue;
|
||||
var current = totals[system];
|
||||
totals[system] = (current.Correct + (attempt.IsCorrect ? 1 : 0), current.Total + 1);
|
||||
}
|
||||
|
||||
return Step3Systems.Select(system => new DashboardSystemPerformance
|
||||
{
|
||||
System = system,
|
||||
Correct = totals[system].Correct,
|
||||
Total = totals[system].Total,
|
||||
Accuracy = Percentage(totals[system].Correct, totals[system].Total)
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public static string? NormalizeStep3System(Question question)
|
||||
=> Step3SystemCatalog.Normalize(question);
|
||||
|
||||
public static string? NormalizeStep3System(string? value)
|
||||
=> Step3SystemCatalog.Normalize(value);
|
||||
|
||||
private async Task<List<DashboardRecentActivity>> BuildRecentActivityAsync(
|
||||
List<QuestionAttempt> attempts,
|
||||
Dictionary<string, Question> questionMap,
|
||||
|
||||
@@ -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();
|
||||
@@ -454,8 +507,8 @@ public class ExamSimulationStore
|
||||
try
|
||||
{
|
||||
shouldRate = await _attempts.TryAcquireRatingLeaseAsync(attemptId, owner, TimeSpan.FromSeconds(30));
|
||||
await _ratings.ApplyFirstResultAsync(simulation.UserId, question, record.Attempt.IsCorrect, shouldRate);
|
||||
if (shouldRate) await _attempts.MarkRatingAppliedAsync(attemptId, owner);
|
||||
var rating = await _ratings.ApplyFirstResultAsync(simulation.UserId, question, record.Attempt.IsCorrect, shouldRate);
|
||||
if (shouldRate) await _attempts.MarkRatingAppliedAsync(attemptId, owner, rating.UserOverallElo);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -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;
|
||||
|
||||
@@ -103,11 +113,13 @@ public class QuestionAttemptStore
|
||||
return leased?.RatingLeaseOwner == owner;
|
||||
}
|
||||
|
||||
public Task MarkRatingAppliedAsync(string attemptId, string owner) =>
|
||||
public Task MarkRatingAppliedAsync(string attemptId, string owner, int userOverallElo) =>
|
||||
_col.UpdateOneAsync(
|
||||
attempt => attempt.Id == attemptId && attempt.RatingLeaseOwner == owner,
|
||||
Builders<QuestionAttempt>.Update
|
||||
.Set(attempt => attempt.RatingApplied, true)
|
||||
.Set(attempt => attempt.UserOverallEloAfter, userOverallElo)
|
||||
.Set(attempt => attempt.RatingAppliedUtc, DateTime.UtcNow)
|
||||
.Set(attempt => attempt.RatingLeaseUntilUtc, DateTime.MinValue));
|
||||
|
||||
public Task ReleaseRatingLeaseAsync(string attemptId, string owner) =>
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public static class QuestionPromotionCommand
|
||||
{
|
||||
public const string Confirmation = "PROMOTE_TO_PRD";
|
||||
internal const string ProductionMediaReviewReason = "Contains media - pending production review";
|
||||
internal const string ProductionMediaReviewActor = "system:production-promotion";
|
||||
|
||||
public static async Task<int> RunAsync(string[] args, TextWriter output)
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = Parse(args);
|
||||
if (options.Apply && options.Confirmation != Confirmation)
|
||||
throw new ArgumentException($"--confirm {Confirmation} is required with --apply");
|
||||
if (options.SourceDatabase.Equals(options.TargetDatabase, StringComparison.OrdinalIgnoreCase)
|
||||
&& options.SourceConnection == options.TargetConnection)
|
||||
throw new ArgumentException("source and target databases must be different");
|
||||
|
||||
var source = new MongoClient(options.SourceConnection).GetDatabase(options.SourceDatabase);
|
||||
var target = new MongoClient(options.TargetConnection).GetDatabase(options.TargetDatabase);
|
||||
await source.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
|
||||
await target.RunCommandAsync<BsonDocument>(new BsonDocument("ping", 1));
|
||||
|
||||
var questionFilter = Builders<BsonDocument>.Filter.Eq("IsPublished", true)
|
||||
& Builders<BsonDocument>.Filter.Eq("FlaggedForReviewUtc", BsonNull.Value);
|
||||
var sourceQuestions = await source.GetCollection<BsonDocument>("Questions")
|
||||
.Find(questionFilter).ToListAsync();
|
||||
var duplicateIds = sourceQuestions
|
||||
.GroupBy(ExternalId, StringComparer.Ordinal)
|
||||
.Where(group => group.Count() > 1)
|
||||
.Select(group => group.Key)
|
||||
.ToList();
|
||||
if (duplicateIds.Count > 0)
|
||||
throw new InvalidOperationException($"duplicate eligible ExternalIds: {string.Join(", ", duplicateIds)}");
|
||||
|
||||
var questions = sourceQuestions.Select(PrepareForProduction).ToList();
|
||||
var mediaIds = CollectMediaIds(questions);
|
||||
var media = mediaIds.Count == 0
|
||||
? []
|
||||
: await source.GetCollection<BsonDocument>("QuestionMediaAssets")
|
||||
.Find(Builders<BsonDocument>.Filter.In("_id", mediaIds)).ToListAsync();
|
||||
if (media.Count != mediaIds.Count)
|
||||
throw new InvalidOperationException($"{mediaIds.Count - media.Count} referenced question media assets are missing");
|
||||
|
||||
var sourceIds = questions.Select(ExternalId).ToHashSet(StringComparer.Ordinal);
|
||||
var targetCollection = target.GetCollection<BsonDocument>("Questions");
|
||||
var targetQuestions = sourceIds.Count == 0
|
||||
? []
|
||||
: await targetCollection.Find(
|
||||
Builders<BsonDocument>.Filter.In("ExternalId", sourceIds)).ToListAsync();
|
||||
var targetTotal = await targetCollection.CountDocumentsAsync(FilterDefinition<BsonDocument>.Empty);
|
||||
var comparison = ClassifyQuestions(questions, targetQuestions);
|
||||
var summary = new PromotionSummary(
|
||||
questions.Count,
|
||||
media.Count,
|
||||
comparison.Inserted,
|
||||
comparison.Updated,
|
||||
comparison.Unchanged,
|
||||
checked((int)Math.Max(0, targetTotal - targetQuestions.Count)),
|
||||
HashQuestions(questions));
|
||||
|
||||
await WriteSummaryAsync(output, options.Apply ? "apply" : "dry-run", options, summary);
|
||||
if (!options.Apply)
|
||||
return 0;
|
||||
|
||||
await UpsertMediaAsync(target, media);
|
||||
await UpsertQuestionsAsync(target, questions);
|
||||
|
||||
var verifiedQuestions = sourceIds.Count == 0
|
||||
? []
|
||||
: await targetCollection.Find(
|
||||
Builders<BsonDocument>.Filter.In("ExternalId", sourceIds)).ToListAsync();
|
||||
var verification = ClassifyQuestions(questions, verifiedQuestions);
|
||||
if (verification.Inserted != 0 || verification.Updated != 0)
|
||||
throw new InvalidOperationException(
|
||||
$"target verification failed: {verification.Inserted} missing and {verification.Updated} mismatched questions");
|
||||
var verifiedMedia = mediaIds.Count == 0
|
||||
? 0
|
||||
: checked((int)await target.GetCollection<BsonDocument>("QuestionMediaAssets")
|
||||
.CountDocumentsAsync(Builders<BsonDocument>.Filter.In("_id", mediaIds)));
|
||||
if (verifiedMedia != mediaIds.Count)
|
||||
throw new InvalidOperationException(
|
||||
$"target verification failed: {mediaIds.Count - verifiedMedia} media assets are missing");
|
||||
|
||||
await target.GetCollection<BsonDocument>("ContentPromotions").InsertOneAsync(new BsonDocument
|
||||
{
|
||||
["PromotionId"] = Guid.NewGuid().ToString("N"),
|
||||
["AppliedUtc"] = DateTime.UtcNow,
|
||||
["Operator"] = options.Operator,
|
||||
["BuildRevision"] = options.BuildRevision,
|
||||
["SourceDatabase"] = options.SourceDatabase,
|
||||
["TargetDatabase"] = options.TargetDatabase,
|
||||
["Scope"] = "questions",
|
||||
["Eligibility"] = "published_unflagged; media_requires_production_review",
|
||||
["Questions"] = summary.EligibleQuestions,
|
||||
["Inserted"] = summary.Inserted,
|
||||
["Updated"] = summary.Updated,
|
||||
["Unchanged"] = summary.Unchanged,
|
||||
["MediaAssets"] = summary.MediaAssets,
|
||||
["TargetExtras"] = summary.TargetExtras,
|
||||
["ContentHash"] = summary.ContentHash
|
||||
});
|
||||
await output.WriteLineAsync("Question promotion completed and verified.");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
await output.WriteLineAsync($"Question promotion failed: {exception.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
internal static bool IsEligible(BsonDocument question) =>
|
||||
question.GetValue("IsPublished", false).ToBoolean()
|
||||
&& (!question.TryGetValue("FlaggedForReviewUtc", out var flagged) || flagged.IsBsonNull);
|
||||
|
||||
internal static BsonDocument PrepareForProduction(BsonDocument source)
|
||||
{
|
||||
var prepared = new BsonDocument(source);
|
||||
if (!HasMedia(prepared))
|
||||
return prepared;
|
||||
|
||||
var reviewUtc = prepared.TryGetValue("UpdatedUtc", out var updated) && updated.IsValidDateTime
|
||||
? updated
|
||||
: new BsonDateTime(DateTime.UnixEpoch);
|
||||
prepared["FlaggedForReviewUtc"] = reviewUtc;
|
||||
prepared["FlagReason"] = ProductionMediaReviewReason;
|
||||
prepared["FlaggedByUserId"] = ProductionMediaReviewActor;
|
||||
return prepared;
|
||||
}
|
||||
|
||||
internal static HashSet<ObjectId> CollectMediaIds(IEnumerable<BsonDocument> questions)
|
||||
{
|
||||
var ids = new HashSet<ObjectId>();
|
||||
foreach (var question in questions)
|
||||
{
|
||||
if (!question.TryGetValue("Media", out var raw) || !raw.IsBsonArray)
|
||||
continue;
|
||||
foreach (var value in raw.AsBsonArray)
|
||||
{
|
||||
if (!value.IsString)
|
||||
continue;
|
||||
var reference = value.AsString.Trim();
|
||||
var candidate = reference.TrimEnd('/').Split('/').LastOrDefault() ?? "";
|
||||
if (ObjectId.TryParse(reference, out var direct))
|
||||
ids.Add(direct);
|
||||
else if (ObjectId.TryParse(candidate, out var fromPath))
|
||||
ids.Add(fromPath);
|
||||
else if (reference.Contains("/api/questions/media/", StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException(
|
||||
$"question {ExternalId(question)} has an invalid local media reference: {reference}");
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
internal static QuestionComparison ClassifyQuestions(
|
||||
IEnumerable<BsonDocument> sourceQuestions,
|
||||
IEnumerable<BsonDocument> targetQuestions)
|
||||
{
|
||||
var target = targetQuestions.ToDictionary(ExternalId, StringComparer.Ordinal);
|
||||
var inserted = 0;
|
||||
var updated = 0;
|
||||
var unchanged = 0;
|
||||
foreach (var source in sourceQuestions)
|
||||
{
|
||||
var externalId = ExternalId(source);
|
||||
if (!target.TryGetValue(externalId, out var existing))
|
||||
inserted++;
|
||||
else if (EquivalentQuestion(source, existing))
|
||||
unchanged++;
|
||||
else
|
||||
updated++;
|
||||
}
|
||||
return new QuestionComparison(inserted, updated, unchanged);
|
||||
}
|
||||
|
||||
internal static string HashQuestions(IEnumerable<BsonDocument> questions)
|
||||
{
|
||||
using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
foreach (var document in questions.OrderBy(ExternalId, StringComparer.Ordinal))
|
||||
hash.AppendData(Encoding.UTF8.GetBytes(CanonicalQuestion(document).ToJson()));
|
||||
return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool EquivalentQuestion(BsonDocument source, BsonDocument target) =>
|
||||
CanonicalQuestion(source).Equals(CanonicalQuestion(target));
|
||||
|
||||
private static BsonDocument CanonicalQuestion(BsonDocument source) =>
|
||||
Canonicalize(new BsonDocument(source.Where(element => element.Name != "_id"))).AsBsonDocument;
|
||||
|
||||
private static BsonValue Canonicalize(BsonValue value)
|
||||
{
|
||||
if (value.IsBsonDocument)
|
||||
return new BsonDocument(value.AsBsonDocument
|
||||
.OrderBy(element => element.Name, StringComparer.Ordinal)
|
||||
.Select(element => new BsonElement(element.Name, Canonicalize(element.Value))));
|
||||
if (value.IsBsonArray)
|
||||
return new BsonArray(value.AsBsonArray.Select(Canonicalize));
|
||||
return value;
|
||||
}
|
||||
|
||||
private static string ExternalId(BsonDocument document) =>
|
||||
document.GetValue("ExternalId", "").AsString;
|
||||
|
||||
private static bool HasMedia(BsonDocument question) =>
|
||||
question.TryGetValue("Media", out var media)
|
||||
&& media.IsBsonArray
|
||||
&& media.AsBsonArray.Count > 0;
|
||||
|
||||
private static async Task UpsertQuestionsAsync(IMongoDatabase target, IEnumerable<BsonDocument> questions)
|
||||
{
|
||||
var collection = target.GetCollection<BsonDocument>("Questions");
|
||||
foreach (var source in questions)
|
||||
{
|
||||
var replacement = new BsonDocument(source.Where(element => element.Name != "_id"));
|
||||
await collection.ReplaceOneAsync(
|
||||
Builders<BsonDocument>.Filter.Eq("ExternalId", source["ExternalId"]),
|
||||
replacement,
|
||||
new ReplaceOptions { IsUpsert = true });
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task UpsertMediaAsync(IMongoDatabase target, IEnumerable<BsonDocument> media)
|
||||
{
|
||||
var collection = target.GetCollection<BsonDocument>("QuestionMediaAssets");
|
||||
foreach (var source in media)
|
||||
await collection.ReplaceOneAsync(
|
||||
Builders<BsonDocument>.Filter.Eq("_id", source["_id"]),
|
||||
source,
|
||||
new ReplaceOptions { IsUpsert = true });
|
||||
}
|
||||
|
||||
private static Task WriteSummaryAsync(
|
||||
TextWriter output,
|
||||
string mode,
|
||||
PromotionOptions options,
|
||||
PromotionSummary summary) =>
|
||||
output.WriteLineAsync(JsonSerializer.Serialize(new
|
||||
{
|
||||
mode,
|
||||
scope = "questions",
|
||||
eligibility = "published_unflagged; media_requires_production_review",
|
||||
source = options.SourceDatabase,
|
||||
target = options.TargetDatabase,
|
||||
eligibleQuestions = summary.EligibleQuestions,
|
||||
mediaAssets = summary.MediaAssets,
|
||||
inserted = summary.Inserted,
|
||||
updated = summary.Updated,
|
||||
unchanged = summary.Unchanged,
|
||||
targetExtras = summary.TargetExtras,
|
||||
contentHash = summary.ContentHash
|
||||
}));
|
||||
|
||||
private static PromotionOptions Parse(IReadOnlyList<string> args)
|
||||
{
|
||||
string Required(string name, string environmentName)
|
||||
{
|
||||
var index = args.IndexOf($"--{name}");
|
||||
var value = index >= 0 && index + 1 < args.Count
|
||||
? args[index + 1]
|
||||
: Environment.GetEnvironmentVariable(environmentName);
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? throw new ArgumentException($"--{name} is required")
|
||||
: value;
|
||||
}
|
||||
string Optional(string name, string fallback)
|
||||
{
|
||||
var index = args.IndexOf($"--{name}");
|
||||
return index >= 0 && index + 1 < args.Count ? args[index + 1] : fallback;
|
||||
}
|
||||
return new PromotionOptions(
|
||||
Required("source-connection", "SOURCE_MONGODB_CONNECTION_STRING"),
|
||||
Required("source-database", "SOURCE_MONGODB_DATABASE"),
|
||||
Required("target-connection", "TARGET_MONGODB_CONNECTION_STRING"),
|
||||
Required("target-database", "TARGET_MONGODB_DATABASE"),
|
||||
args.Contains("--apply"),
|
||||
Optional("confirm", ""),
|
||||
Optional("operator", "unknown"),
|
||||
Optional("build-revision", "unknown"));
|
||||
}
|
||||
|
||||
private sealed record PromotionOptions(
|
||||
string SourceConnection,
|
||||
string SourceDatabase,
|
||||
string TargetConnection,
|
||||
string TargetDatabase,
|
||||
bool Apply,
|
||||
string Confirmation,
|
||||
string Operator,
|
||||
string BuildRevision);
|
||||
|
||||
internal sealed record QuestionComparison(int Inserted, int Updated, int Unchanged);
|
||||
internal sealed record PromotionSummary(
|
||||
int EligibleQuestions,
|
||||
int MediaAssets,
|
||||
int Inserted,
|
||||
int Updated,
|
||||
int Unchanged,
|
||||
int TargetExtras,
|
||||
string ContentHash);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -68,11 +83,41 @@ public static class QuestionValidator
|
||||
if (string.IsNullOrWhiteSpace(req.System)) return "system required";
|
||||
if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required";
|
||||
if (req.Difficulty is < 1 or > 5) return "difficulty must be between 1 and 5";
|
||||
var labValidation = ValidateLabs(req.Labs, req.IsPublished);
|
||||
if (labValidation is not null) return labValidation;
|
||||
var referenceValidation = ValidateReferences(req.References, req.IsPublished);
|
||||
if (referenceValidation is not null) return referenceValidation;
|
||||
return null;
|
||||
}
|
||||
|
||||
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)
|
||||
return "labs must be an array";
|
||||
if (labs.Count > 50)
|
||||
return "questions support at most fifty lab results";
|
||||
|
||||
foreach (var lab in labs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lab.Name) || string.IsNullOrWhiteSpace(lab.Value))
|
||||
return "lab name and value are required";
|
||||
if (requireComplete && string.IsNullOrWhiteSpace(lab.Reference))
|
||||
return $"published question lab '{lab.Name.Trim()}' requires a reference range";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string? ValidateReferences(IReadOnlyCollection<QuestionReference>? references, bool required)
|
||||
{
|
||||
if (references is null)
|
||||
|
||||
@@ -27,14 +27,49 @@ public class RatingStore
|
||||
public async Task<RatingProfileResponse> GetProfileAsync(string userId)
|
||||
{
|
||||
var rating = await GetOrCreateUserRatingAsync(userId);
|
||||
var (tierName, tierDivision) = GetTier(rating.OverallElo);
|
||||
return new RatingProfileResponse
|
||||
{
|
||||
OverallElo = rating.OverallElo,
|
||||
RatedQuestionCount = rating.RatedQuestionCount,
|
||||
CategoryElos = rating.CategoryElos
|
||||
CategoryElos = rating.CategoryElos,
|
||||
Tier = tierName,
|
||||
TierDivision = tierDivision
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly (string Name, int FloorElo)[] TierBands =
|
||||
[
|
||||
("Bronze", MinElo),
|
||||
("Silver", 1000),
|
||||
("Gold", 1300),
|
||||
("Platinum", 1900),
|
||||
("Diamond", 2200)
|
||||
];
|
||||
|
||||
private static (string Name, int Division) GetTier(int elo)
|
||||
{
|
||||
var bandIndex = 0;
|
||||
for (var i = 0; i < TierBands.Length; i++)
|
||||
{
|
||||
if (elo >= TierBands[i].FloorElo)
|
||||
bandIndex = i;
|
||||
}
|
||||
|
||||
var band = TierBands[bandIndex];
|
||||
var nextFloor = bandIndex + 1 < TierBands.Length ? TierBands[bandIndex + 1].FloorElo : band.FloorElo + 300;
|
||||
var bandWidth = nextFloor - band.FloorElo;
|
||||
var progress = bandWidth > 0 ? (elo - band.FloorElo) / (double)bandWidth : 0;
|
||||
var division = 3 - Math.Clamp((int)(progress * 3), 0, 2);
|
||||
return (band.Name, division);
|
||||
}
|
||||
|
||||
public async Task<UserRating> GetOrInitializeHistoryAsync(string userId, DateTime nowUtc)
|
||||
{
|
||||
var rating = await GetOrCreateUserRatingAsync(userId);
|
||||
return await EnsureHistoryBaselineAsync(rating, nowUtc);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, int>> GetQuestionElosAsync(IEnumerable<string> externalIds)
|
||||
{
|
||||
var ids = externalIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
|
||||
@@ -71,6 +106,7 @@ public class RatingStore
|
||||
};
|
||||
}
|
||||
|
||||
userRating = await EnsureHistoryBaselineAsync(userRating, DateTime.UtcNow);
|
||||
var actual = isCorrect ? 1.0 : 0.0;
|
||||
var userK = userRating.RatedQuestionCount < 20 ? 40 : 24;
|
||||
var questionK = questionRating.AttemptCount < 20 ? 32 : 24;
|
||||
@@ -164,6 +200,26 @@ public class RatingStore
|
||||
return rating;
|
||||
}
|
||||
|
||||
private async Task<UserRating> EnsureHistoryBaselineAsync(UserRating rating, DateTime nowUtc)
|
||||
{
|
||||
if (rating.HistoryBaselineElo.HasValue && rating.HistoryStartedUtc.HasValue)
|
||||
return rating;
|
||||
|
||||
var result = await _users.UpdateOneAsync(
|
||||
value => value.UserId == rating.UserId && value.HistoryStartedUtc == null,
|
||||
Builders<UserRating>.Update
|
||||
.Set(value => value.HistoryBaselineElo, rating.OverallElo)
|
||||
.Set(value => value.HistoryStartedUtc, nowUtc));
|
||||
if (result.ModifiedCount > 0)
|
||||
{
|
||||
rating.HistoryBaselineElo = rating.OverallElo;
|
||||
rating.HistoryStartedUtc = nowUtc;
|
||||
return rating;
|
||||
}
|
||||
|
||||
return await _users.Find(value => value.UserId == rating.UserId).FirstAsync();
|
||||
}
|
||||
|
||||
private async Task<QuestionRating> GetOrCreateQuestionRatingAsync(string questionExternalId)
|
||||
{
|
||||
var create = Builders<QuestionRating>.Update
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,9 @@ public sealed class UserProfileStore
|
||||
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
|
||||
"ExternalIdentities", "ExternalAuthTransactions",
|
||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews"
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
|
||||
"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; }
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 84 KiB |
@@ -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,14 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="#ffffff"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 7v14" />
|
||||
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 373 B |
@@ -0,0 +1,43 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://bgv42gr8xm8rf"
|
||||
path="res://.godot/imported/book-open.svg-683be4c8f98553ef842aedfa9c35759c.ctex"
|
||||
metadata={
|
||||
"vram_texture": false
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://assets/icons/lucide/book-open.svg"
|
||||
dest_files=["res://.godot/imported/book-open.svg-683be4c8f98553ef842aedfa9c35759c.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=0
|
||||
compress/high_quality=false
|
||||
compress/lossy_quality=0.7
|
||||
compress/uastc_level=0
|
||||
compress/rdo_quality_loss=0.0
|
||||
compress/hdr_compression=1
|
||||
compress/normal_map=0
|
||||
compress/channel_pack=0
|
||||
mipmaps/generate=false
|
||||
mipmaps/limit=-1
|
||||
roughness/mode=0
|
||||
roughness/src_normal=""
|
||||
process/channel_remap/red=0
|
||||
process/channel_remap/green=1
|
||||
process/channel_remap/blue=2
|
||||
process/channel_remap/alpha=3
|
||||
process/fix_alpha_border=true
|
||||
process/premult_alpha=false
|
||||
process/normal_map_invert_y=false
|
||||
process/hdr_as_srgb=false
|
||||
process/hdr_clamp_exposure=false
|
||||
process/size_limit=0
|
||||
detect_3d/compress_to=1
|
||||
svg/scale=1.0
|
||||
editor/scale_with_editor_scale=false
|
||||
editor/convert_colors_with_editor_theme=false
|
||||
@@ -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 |